SEO
Hwaro는 사이트맵, RSS 피드, robots.txt, 소셜 공유 메타 태그 같은 SEO 기능을 기본으로 제공합니다.
사이트맵
검색 엔진용 sitemap.xml을 자동 생성합니다.
설정
[sitemap]
enabled = true
filename = "sitemap.xml"
changefreq = "weekly"
priority = 0.5
exclude = ["/private", "/drafts"]
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| enabled | bool | false | sitemap.xml 생성 여부 |
| filename | string | "sitemap.xml" | 출력 파일 이름 |
| changefreq | string | "weekly" | 전체 페이지의 기본 변경 주기: always, hourly, daily, weekly, monthly, yearly, never 중 하나 (다른 값은 경고 후 "weekly" 유지, ""는 태그 생략) |
| priority | float | 0.5 | 전체 페이지의 기본 우선순위(0.0–1.0) |
| exclude | array | [] | 제외할 경로 접두사 (예: ["/private"]는 /private, /private/page.html을 제외) |
출력
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2024-01-15</lastmod>
<changefreq>weekly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://example.com/about/</loc>
<changefreq>weekly</changefreq>
<priority>0.5</priority>
</url>
</urlset>
페이지 제외
프론트 매터에 in_sitemap = false를 설정합니다:
+++
title = "Private Page"
in_sitemap = false
+++
RSS 피드
사이트와 섹션의 RSS 피드를 생성합니다.
설정
[feeds]
enabled = true
type = "rss" # "rss", "atom" 또는 "json"
limit = 20 # 최대 항목 수
truncate = 0 # N자로 자르기 (0 = 자르지 않음)
full_content = true # true = 전체 HTML 본문, false = description/요약만
filename = "" # 비워 두면 기본값 (rss.xml, atom.xml 또는 feed.json)
sections = [] # 특정 섹션으로 제한, 예: ["posts"]
| 옵션 | 기본값 | 설명 |
|---|---|---|
enabled |
false |
피드 생성 여부 |
type |
"rss" |
피드 형식: "rss", "atom" 또는 "json" (JSON Feed 1.1) |
limit |
10 |
피드의 최대 항목 수 |
truncate |
0 |
콘텐츠를 N자로 자르기 (0 = 전체 콘텐츠) |
full_content |
true |
true = 피드에 전체 HTML 포함, false = 프론트 매터 description 또는 자동 생성 요약 사용 |
filename |
"" |
메인 피드의 커스텀 파일 이름 (비어 있으면 rss.xml, atom.xml 또는 feed.json). 섹션·언어별·택소노미 용어 피드는 항상 기본 이름을 씁니다 |
sections |
[] |
피드를 특정 섹션으로 제한 |
default_language_only |
true |
다국어: 메인 피드에 기본 언어만 포함 |
JSON Feed
type = "json"으로 설정하면 JSON Feed 1.1 문서를 feed.json으로 생성합니다 (filename을 지정하면 그 이름을 씁니다). RSS·Atom과 같은 위치 모두에 생성됩니다. 메인 피드, 섹션 피드, 언어별 피드, 택소노미 용어 피드가 해당하며, limit, truncate, full_content, sections, default_language_only 설정도 똑같이 적용됩니다.
각 항목에는 id와 url(페이지 퍼머링크), title, summary(RSS <description>과 같은 텍스트), date_published, date_modified(updated에서), tags, authors, image(절대 URL)가 들어갑니다. 본문은 content_html이며, truncate나 full_content = false 때문에 일반 텍스트가 되면 content_text로 들어갑니다. 페이지에 값이 없는 선택 필드는 생략됩니다.
{
"version": "https://jsonfeed.org/version/1.1",
"title": "My Site",
"home_page_url": "https://example.com/",
"feed_url": "https://example.com/feed.json",
"items": [
{
"id": "https://example.com/posts/hello/",
"url": "https://example.com/posts/hello/",
"title": "Hello",
"content_html": "<p>…</p>",
"date_published": "2026-03-05T00:00:00Z",
"tags": ["crystal"]
}
]
}
섹션 피드
섹션별 피드를 활성화합니다:
+++
title = "Blog"
generate_feeds = true
+++
이렇게 하면 /blog/rss.xml이 생성됩니다.
루트 content/_index.md(또는 _index.ko.md 같은 언어 루트)에서는 generate_feeds가 효과가 없습니다. 그 피드는 사이트(또는 언어) 피드와 같은 /rss.xml에 쓰이게 되므로, 더 많은 항목을 담은 사이트 피드를 유지하고 빌드가 경고를 출력합니다.
출력
/rss.xml— 사이트 전체 피드/blog/rss.xml— 섹션 피드 (활성화한 경우)
다국어 피드
사이트가 다국어이면 피드는 언어별로 자동 생성됩니다:
| 언어 | 피드 경로 | 내용 |
|---|---|---|
기본 언어 (예: en) |
/rss.xml |
기본 언어 페이지만 (설정 가능) |
기본 외 언어 (예: ko) |
/ko/rss.xml |
한국어 페이지만 |
기본 외 언어 (예: ja) |
/ja/rss.xml |
일본어 페이지만 |
기본적으로 메인 사이트 피드에는 기본 언어 페이지만 포함됩니다(default_language_only = true). 메인 피드에 모든 언어를 포함하려면 default_language_only = false로 설정합니다. generate_feed = true인 기본 외 언어는 이 설정과 무관하게 각자 별도 피드를 갖습니다.
[feeds]
enabled = true
default_language_only = true # true (기본값): 메인 피드 = 기본 언어만
# false: 메인 피드에 모든 언어 포함
언어별 피드 제어:
[languages.ko]
language_name = "한국어"
generate_feed = true # /ko/rss.xml 생성 (기본값: true)
[languages.ja]
language_name = "日本語"
generate_feed = false # /ja/rss.xml을 생성하지 않음
언어 피드는 [feeds] 설정의 sections, limit, truncate, full_content 값을 그대로 공유합니다. RSS 언어 피드에는 <language> 태그가, Atom 피드에는 xml:lang 속성이, JSON 피드에는 최상위 "language" 필드가 들어갑니다. 피드 제목에는 언어 이름이 포함됩니다 (예: "My Site (한국어)").
커스텀 피드 템플릿
피드 마크업을 직접 제어하려면 피드 출력 파일 이름을 딴 템플릿을 만듭니다:
| 피드 종류 | 템플릿 파일 | 로드 키 |
|---|---|---|
| RSS | templates/rss.xml.jinja |
rss.xml |
| Atom | templates/atom.xml.jinja |
atom.xml |
| JSON Feed | templates/feed.json.jinja |
feed.json |
템플릿 확장자는 무엇이든 됩니다(.jinja, .j2, .jinja2, .html). 마지막 확장자만 제거되므로 rss.xml.jinja는 rss.xml 키로 로드됩니다. 확장자와 무관하게 파일은 항상 Jinja로 렌더링됩니다(.ecr 파일도 인식되지만 ECR <%= %> 태그는 문자 그대로 출력되므로 Jinja 문법을 써야 합니다). 템플릿 파일의 존재 자체가 옵트인입니다: 파일이 없으면 Hwaro는 기존과 똑같이 내장 피드를 내보내고, 템플릿을 삭제하면 내장 출력으로 되돌아갑니다. 오버라이드는 네 가지 피드 전부(메인 피드, 섹션별 피드, 언어별 피드, 택소노미 항목별 피드)에 적용되고, 커스텀 [feeds] filename은 여전히 메인 피드의 출력 경로를 결정합니다.
피드 템플릿 안에서 {% include %}를 쓸 수 있고, 템플릿이 잘못되면 해당 파일 이름을 담은 템플릿 오류와 함께 빌드가 실패합니다.
컨텍스트 변수
feed — 렌더링 중인 피드의 메타데이터:
| 변수 | 타입 | 설명 |
|---|---|---|
feed.type |
string | "rss", "atom" 또는 "json" ([feeds] type을 따름) |
feed.kind |
string | "main", "section", "language", "taxonomy" 중 하나 |
feed.title |
string | 피드 제목 (사이트 제목, Site - Section, Site (한국어) 등) |
feed.description |
string | 사이트 설명 |
feed.url |
string | 이 피드 파일의 절대 self URL (퍼센트 인코딩) |
feed.home_url |
string | 피드가 대표하는 표준 HTML URL (사이트 루트, 섹션 페이지, 언어 홈) |
feed.base_url |
string | 끝 슬래시를 뺀 base_url |
feed.language |
string? | 언어별 피드의 언어 코드, 그 외에는 없음 |
feed.updated |
time | 가장 최신 항목의 날짜 (결정적; 날짜 있는 항목이 없으면 epoch) |
feed.updated_rfc3339 |
string | feed.updated의 RFC 3339 표현 (Atom <updated>) |
feed.updated_rfc822 |
string | feed.updated의 RFC 822 표현 (RSS <lastBuildDate>/<pubDate> 형식) |
feed.author |
string | 사이트 제목 (없으면 피드 제목으로 대체) |
feed.section_url |
string? | 섹션 URL — 섹션 피드 전용 |
feed.taxonomy / feed.term |
string? | 택소노미 이름과 항목 — 택소노미 피드 전용 |
pages — 정렬과 limit이 적용된 항목 목록. 각 항목:
| 변수 | 타입 | 설명 |
|---|---|---|
title |
string | 페이지 제목 (비어 있으면 사이트 제목) |
url |
string | 절대 페이지 URL (퍼센트 인코딩) |
date / updated |
time? | 프론트 매터의 원본 날짜 (date 필터에 사용 가능) |
date_rfc822 |
string? | 미리 형식화된 RFC 822 날짜; 날짜 없는 페이지는 없음 |
date_rfc3339 |
string? | date를 RFC 3339 UTC로 형식화한 값 (JSON Feed date_published); 날짜 없는 페이지는 없음 |
updated_rfc3339 |
string | updated/date 기반 RFC 3339 타임스탬프 (없으면 epoch) |
description |
string? | 프론트 매터 description |
summary |
string | 일반 텍스트 요약 (description → <!-- more --> 요약 → 발췌 순) |
content |
string | full_content/truncate를 반영한 본문 (자를 때는 일반 텍스트) |
content_html |
string | 외부 리더를 위해 링크를 절대 URL로 바꾼 전체 HTML 본문 |
content_is_html |
bool | content가 HTML인지 여부 (truncate/full_content = false면 false) |
authors |
array | 프론트 매터 authors |
image |
string? | 페이지 image의 절대 URL (퍼센트 인코딩됨) |
categories |
array | 택소노미 항목 — tags가 먼저, 그다음 다른 택소노미, 중복 제거 |
section |
string | 페이지 섹션 경로 |
language |
string? | 페이지 언어 코드 |
예시
값은 미리 이스케이프되지 않습니다. xml_escape를 적용하거나 CDATA로 감싸는 일은 템플릿 작성자의 몫입니다. feed.json 템플릿에서는 tojson 필터를 쓰세요:
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ feed.title | xml_escape }}</title>
<link>{{ feed.home_url | xml_escape }}</link>
<description>{{ feed.description | xml_escape }}</description>
{% if feed.language %}<language>{{ feed.language | xml_escape }}</language>{% endif %}
<atom:link href="{{ feed.url | xml_escape }}" rel="self" type="application/rss+xml" />
{% for p in pages %}
<item>
<title>{{ p.title | xml_escape }}</title>
<link>{{ p.url | xml_escape }}</link>
<guid>{{ p.url | xml_escape }}</guid>
<description>{{ p.summary | xml_escape }}</description>
{% if p.date_rfc822 %}<pubDate>{{ p.date_rfc822 }}</pubDate>{% endif %}
{% for term in p.categories %}<category>{{ term | xml_escape }}</category>{% endfor %}
</item>
{% endfor %}
</channel>
</rss>
템플릿 링크
<link rel="alternate" type="application/rss+xml"
href="{{ base_url }}/rss.xml"
title="{{ site.title }}">
{% if page.language and page.language != "en" %}
<link rel="alternate" type="application/rss+xml"
href="{{ base_url }}/{{ page.language }}/rss.xml"
title="{{ site.title }} ({{ page.language }})">
{% endif %}
JSON Feed는 type="application/feed+json"과 feed.json을 씁니다:
<link rel="alternate" type="application/feed+json"
href="{{ base_url }}/feed.json"
title="{{ site.title }}">
Robots.txt
검색 엔진 크롤링을 제어합니다.
설정
[robots]
enabled = true
커스텀 규칙 사용:
[robots]
enabled = true
rules = [
{ user_agent = "*", disallow = ["/admin", "/private"] },
{ user_agent = "GPTBot", disallow = ["/"] }
]
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| enabled | bool | true | robots.txt 생성 여부 |
| filename | string | "robots.txt" | 출력 파일 이름 |
| rules | array | [] | allow/disallow 경로를 담은 user-agent 규칙 목록 |
규칙을 설정하지 않으면 Hwaro는 기본 전체 허용 규칙을 생성합니다. 규칙의 allow와 disallow가 모두 비어 있으면 모호한 동작을 막기 위해 명시적인 Allow: /가 추가됩니다.
Content Signals
규칙에 content_signal 테이블을 넣으면 Content Signals 줄을 출력합니다. 크롤러가 가져간 콘텐츠를 어떻게 써도 되는지 알리는 지시어입니다:
[robots]
rules = [
{ user_agent = "*", allow = ["/"], content_signal = { search = true, ai_input = true, ai_train = false } },
]
User-agent: *
Content-Signal: search=yes, ai-input=yes, ai-train=no
Allow: /
| 키 | 시그널 | 의미 |
|---|---|---|
search |
search |
검색 색인을 만들고 링크와 짧은 발췌문을 보여 주는 용도 |
ai_input |
ai-input |
AI 모델의 입력으로 콘텐츠를 넣는 용도 (검색 증강, 그라운딩) |
ai_train |
ai-train |
AI 모델 학습이나 파인튜닝 |
값은 true(yes) 또는 false(no)입니다. 생략한 키는 출력하지 않으며, 시그널은 항상 위 표의 순서로 나열됩니다. 알 수 없는 키나 불리언이 아닌 값은 설정 오류입니다. content_signal이 없는 규칙의 출력은 이전과 똑같습니다.
출력
User-agent: *
Allow: /
Sitemap: https://example.com/sitemap.xml
LLMs.txt
llms.txt 표준을 따르는 AI/LLM 크롤러용 안내 파일을 생성합니다.
[llms]
enabled = true
instructions = "This site's content is provided under the MIT license."
full_enabled = true
전체 설정과 출력 형식은 LLMs.txt에서 다룹니다.
OpenGraph 태그
Facebook, LinkedIn 등을 위한 소셜 공유 메타 태그입니다.
설정
[og]
default_image = "/images/og-default.png"
type = "website"
fb_app_id = "your_fb_app_id"
| 키 | 설명 |
|---|---|
| default_image | 페이지에 이미지가 없을 때 사용할 대체 이미지 |
| type | 콘텐츠 페이지의 OpenGraph 타입 (기본값: "article"; 목록 페이지는 항상 "website") |
| fb_app_id | Facebook 앱 ID (선택) |
페이지 단위 오버라이드
+++
title = "My Article"
description = "Article description"
image = "/images/article-cover.png"
+++
템플릿에서 사용
<head>
{{ og_tags | safe }}
</head>
출력
<meta property="og:title" content="My Article">
<meta property="og:type" content="article">
<meta property="og:url" content="https://example.com/my-article/">
<meta property="og:description" content="Article description">
<meta property="og:image" content="https://example.com/images/article-cover.png">
Twitter 카드
Twitter 전용 공유 태그입니다.
설정
[og]
twitter_card = "summary_large_image"
twitter_site = "@yourusername"
twitter_creator = "@authorusername"
| 키 | 설명 |
|---|---|
| twitter_card | 카드 타입: summary, summary_large_image (이미지가 없는 페이지는 summary. 이미지 없는 큰 카드는 빈 미리보기로 표시됩니다) |
| twitter_site | 사이트의 Twitter 핸들 |
| twitter_creator | 작성자의 Twitter 핸들 |
템플릿에서 사용
<head>
{{ twitter_tags | safe }}
</head>
또는 OG와 Twitter 태그를 함께 포함합니다:
<head>
{{ og_all_tags | safe }}
</head>
출력
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="My Article">
<meta name="twitter:description" content="Article description">
<meta name="twitter:image" content="https://example.com/images/article-cover.png">
<meta name="twitter:site" content="@yourusername">
JSON-LD 구조화된 데이터
Hwaro는 모든 페이지에 페이지 단위 JSON-LD를 자동 생성합니다. 콘텐츠 페이지에는 Article(목록 페이지는 CollectionPage, 홈 페이지는 WebSite)과 BreadcrumbList가 생성됩니다.
<head>
{{ jsonld | safe }}
</head>
FAQ, HowTo, WebSite, Organization 같은 추가 스키마 타입도 사용할 수 있습니다. 전체 타입, 설정, 출력 예시는 구조화된 데이터에서 다룹니다.
템플릿 변수
미리 렌더링된 HTML
다음 변수는 바로 쓸 수 있는 HTML 태그를 출력합니다:
| 변수 | 설명 |
|---|---|
| og_tags | OpenGraph 메타 태그 |
| twitter_tags | Twitter 카드 메타 태그 |
| og_all_tags | OG와 Twitter 태그 전체 |
| canonical_tag | canonical 링크 태그 |
| hreflang_tags | hreflang 대체 링크 태그 |
| jsonld | Article(또는 CollectionPage/WebSite) + BreadcrumbList JSON-LD |
| jsonld_article | Article JSON-LD만 |
| jsonld_breadcrumb | BreadcrumbList JSON-LD만 |
| page_description | 페이지 설명 (없으면 사이트 설명) |
| page_image | 페이지 이미지 (없으면 og.default_image) |
SEO 객체
seo 객체는 커스텀 메타 태그를 만들 수 있도록 필드 단위 접근을 제공합니다:
| 속성 | 타입 | 설명 |
|---|---|---|
| seo.canonical_url | String | 전체 canonical URL |
| seo.og_type | String | OpenGraph 타입 (기본값: "article") |
| seo.og_image | String | 해석된 절대 이미지 URL |
| seo.twitter_card | String | 이 페이지에 twitter_tags가 출력하는 Twitter 카드 타입 |
| seo.twitter_site | String | Twitter 사이트 핸들 |
| seo.twitter_creator | String | Twitter 작성자 핸들 |
| seo.fb_app_id | String | Facebook 앱 ID |
| seo.hreflang | Array | 언어 번역 링크 |
<head>
<link rel="canonical" href="{{ seo.canonical_url }}">
<meta property="og:title" content="{{ page.title }}">
<meta property="og:type" content="{{ seo.og_type }}">
<meta property="og:url" content="{{ seo.canonical_url }}">
{% if page.description %}
<meta property="og:description" content="{{ page.description }}">
{% endif %}
{% if seo.og_image %}
<meta property="og:image" content="{{ seo.og_image }}">
{% endif %}
{% if seo.fb_app_id %}
<meta property="fb:app_id" content="{{ seo.fb_app_id }}">
{% endif %}
<meta name="twitter:card" content="{{ seo.twitter_card }}">
<meta name="twitter:title" content="{{ page.title }}">
{% if seo.twitter_site %}
<meta name="twitter:site" content="{{ seo.twitter_site }}">
{% endif %}
</head>
전체 예시
config.toml
title = "My Site"
description = "A great site"
base_url = "https://example.com"
[sitemap]
enabled = true
[feeds]
enabled = true
limit = 20
[robots]
enabled = true
[og]
default_image = "/images/og-default.png"
type = "website"
twitter_card = "summary_large_image"
twitter_site = "@mysite"
templates/base.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ page.title }} - {{ site.title }}</title>
<meta name="description" content="{{ page.description | default(value=site.description) }}">
{{ og_all_tags | safe }}
{{ canonical_tag | safe }}
{{ hreflang_tags | safe }}
{{ jsonld | safe }}
<link rel="alternate" type="application/rss+xml" href="{{ base_url }}/rss.xml">
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>