sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit 4733be73c5d01220f0ba0e99786975ea74493904
parent 0ee4dec4e3e689cb7120262034eb551cf2095ca7
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sun, 26 Jul 2026 10:36:26 +0900

Add date front matter and Atom feeds, and fix what verifying them found

A page with a `date` enters its language's feed. `sort_by = "date"` orders a
section newest first — the opposite of weight and title, because that is the
order anyone reading a dated list expects — and undated pages go last rather
than first.

Atom rather than RSS 2.0. RSS dates are RFC 2822, which needs a day-of-week we
would compute ourselves and English month names we would embed, including in a
Korean feed where `Jul` is simply wrong. Atom uses RFC 3339, which is the shape
a TOML date already has.

A language with no dated pages gets no feed and no autodiscovery link. An empty
feed is worse than an absent one; a subscriber who sees nothing arriving reads
it as broken. This documentation has no dates, so it publishes no feed.

`page.date` is parts — year, month, day, the date, the timestamp — not a format
string. A format filter means shipping a date-formatting mini-language and then
owning locale rules for every language someone writes in.

Then I had the implementation attacked, and it came back with nine defects.
Every one of them was real.

The escaper passed C0 control characters through. XML 1.0 cannot carry those
even as character references, so a single one in a title made the whole feed
unparseable while the build reported success. They are dropped now, in the
sitemap escaper too.

Offsets were rewritten as `Z`, which publishes 09:00 in Seoul as 09:00 UTC — a
morning announced as an evening, off by up to a day. RFC 3339 accepts any
offset, so there was never any arithmetic to avoid: the offset is carried
through, and ordering compares instants rather than text.

The feed's `updated` came off the head of a sort that ties on title, so a feed
holding an 18:00 entry could announce 09:00 — and aggregators that poll on it
would never fetch the newer entries. It is the maximum now, taken after the
entry cap so it cannot name a page the feed does not contain.

The feed's `id` was the home page URL, which is character-for-character the
`id` of its own first entry. It is the feed's own address now.

A `date` TOML reads as a time with no date was dropped in silence. It is an
error.

Three were integration rather than feed. Linking to `/feed-en.xml` — or
`/sitemap.xml`, or `/llms.txt` — failed the build, because the link checker
added last week knows about pages and assets and not about the files the build
generates. A `static/feed-en.xml` was silently overwritten, where sitemap and
robots step aside. And a page whose URL matched a generated filename died
halfway through writing with `File exists (os error 17)`, which says nothing
about what is wrong; it is a named collision error now, beside the URL
collision check it belongs with.

`base_url` without a scheme now fails validation. Every absolute URL this tool
emits — canonical, sitemap, feed id, OpenGraph — comes from it, and RFC 4287
forbids a relative `id` outright.

Diffstat:
MREADME.md | 3++-
Mdocs/content/_index.ko.md | 2+-
Mdocs/content/_index.md | 4++--
Mdocs/content/content/_index.ko.md | 10++++++++--
Mdocs/content/content/_index.md | 11++++++++---
Mdocs/content/content/front-matter.ko.md | 3++-
Mdocs/content/content/front-matter.md | 3++-
Mdocs/content/content/links.ko.md | 9+++++++++
Mdocs/content/content/links.md | 9+++++++++
Mdocs/content/deploy/_index.ko.md | 1+
Mdocs/content/deploy/_index.md | 1+
Adocs/content/features/feeds.ko.md | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/features/feeds.md | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/content/start/configuration.ko.md | 2+-
Mdocs/content/start/configuration.md | 2+-
Mdocs/templates/base.html | 5+++++
Msrc/assets.rs | 2+-
Msrc/config.rs | 24++++++++++++++++++++++++
Msrc/content.rs | 16++++++++++++++++
Asrc/feed.rs | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render.rs | 6++++++
Msrc/seo.rs | 3+++
Msrc/site.rs | 57++++++++++++++++++++++++++++++++++++++++++++++++++-------
24 files changed, 738 insertion(+), 21 deletion(-)

diff --git a/README.md b/README.md
@@ -8,7 +8,8 @@ Vercel, Cloudflare Pages, or a plain directory served over HTTP.
 
 > **Status: early, and honest about it.** The pipeline works end to end —
 > markdown, sections, templates, syntax highlighting, table of contents, the
-> asset pipeline, search, a dev server with live reload, sitemap and robots.txt.
+> asset pipeline, search, a dev server with live reload, sitemap, robots.txt and
+> Atom feeds.
 > The documentation site at <https://sqzass.sqzer.com> is built with it, from
 > `docs/` in this repository, and CI builds that site with the binary it just
 > compiled. There are no prebuilt binaries yet, and no theme system.
diff --git a/docs/content/_index.ko.md b/docs/content/_index.ko.md
@@ -38,7 +38,7 @@ push마다 확인합니다.
 ## 상태
 
 초기 단계이고, 그 점을 숨기지 않습니다. 구문 강조, 내비게이션, 목차, 에셋
-파이프라인, 개발 서버, 검색은 동작합니다. 피드와 테마 시스템은 아직 없습니다.
+파이프라인, 개발 서버, 검색, Atom 피드는 동작합니다. 테마 시스템은 아직 없습니다.
 [시작하기](@/start/_index.md)부터 보세요.
 
 [comrak]: https://github.com/kivikakk/comrak
diff --git a/docs/content/_index.md b/docs/content/_index.md
@@ -40,8 +40,8 @@ matches substrings rather than words, which is the only way `최적화` inside
 ## Status
 
 Early, and honest about it: syntax highlighting, navigation, the table of
-contents, the asset pipeline, the dev server and search all work. Feeds and a
-theme system do not exist yet. Start at [Getting started](@/start/_index.md).
+contents, the asset pipeline, the dev server, search and Atom feeds all work.
+A theme system does not exist yet. Start at [Getting started](@/start/_index.md).
 
 [comrak]: https://github.com/kivikakk/comrak
 [minijinja]: https://github.com/mitsuhiko/minijinja
diff --git a/docs/content/content/_index.ko.md b/docs/content/content/_index.ko.md
@@ -55,8 +55,14 @@ UTF-8 그대로 둡니다.
 ## 정렬
 
 섹션은 자식 페이지를 `weight` 오름차순으로 정렬하고, `weight`가 없으면 제목으로
-넘어갑니다. 섹션의 `_index.md`에 `sort_by = "title"`을 주면 제목 기준이 되고,
-`sqzass.toml`의 `[nav] sort_by`로 사이트 전체 기본값을 바꿀 수 있습니다.
+넘어갑니다. 섹션의 `_index.md`에 `sort_by`를 주거나, `sqzass.toml`의
+`[nav] sort_by`로 사이트 전체 기본값을 바꿀 수 있습니다.
+
+| | |
+|---|---|
+| `weight` | 오름차순. 기본값. |
+| `title` | 오름차순. |
+| `date` | **내림차순** — 최신이 먼저, 날짜 없는 페이지는 뒤. [피드](@/features/feeds.md) 참고. |
 
 ## 드래프트
 
diff --git a/docs/content/content/_index.md b/docs/content/content/_index.md
@@ -54,9 +54,14 @@ URL is a build error**, not a last-writer-wins race.
 ## Ordering
 
 Sections sort their pages by `weight` (ascending), and pages without one fall
-back to their title. Set `sort_by = "title"` on a section's `_index.md` to sort
-by title instead, or `[nav] sort_by` in `sqzass.toml` to change the default for
-the whole site.
+back to their title. Set `sort_by` on a section's `_index.md`, or `[nav] sort_by`
+in `sqzass.toml` to change the default for the whole site.
+
+| | |
+|---|---|
+| `weight` | Ascending. The default. |
+| `title` | Ascending. |
+| `date` | **Descending** — newest first, undated pages last. See [Feeds](@/features/feeds.md). |
 
 ## Drafts
 
diff --git a/docs/content/content/front-matter.ko.md b/docs/content/content/front-matter.ko.md
@@ -25,6 +25,7 @@ title = "설치"
 | `description` | 문자열 | `""` | 템플릿의 `<meta name="description">`과 검색 결과에 쓰입니다. |
 | `weight` | 정수 | `0` | 섹션 안에서의 정렬 순서. 작을수록 앞. |
 | `draft` | bool | `false` | `--drafts`가 없으면 빌드에서 빠집니다. |
+| `date` | TOML 날짜 | — | 발행 날짜. 피드와 `sort_by = "date"`가 씁니다. |
 | `slug` | 문자열 | 파일명 stem | URL의 마지막 세그먼트. |
 | `template` | 문자열 | — | 평소 쓰던 것 대신 이 템플릿으로 렌더합니다. |
 | `toc` | bool | `false` | 목차를 보여줄지 여부. |
@@ -41,7 +42,7 @@ title = "설치"
 
 | 필드 | 타입 | 기본값 | |
 |---|---|---|---|
-| `sort_by` | `"weight"` \| `"title"` | 사이트 기본값 | 이 섹션이 자식 페이지를 정렬하는 기준. |
+| `sort_by` | `"weight"` \| `"title"` \| `"date"` | 사이트 기본값 | 이 섹션이 자식 페이지를 정렬하는 기준. |
 | `page_template` | 문자열 | — | 이 섹션 페이지들의 기본 템플릿. |
 
 ## extra
diff --git a/docs/content/content/front-matter.md b/docs/content/content/front-matter.md
@@ -26,6 +26,7 @@ title = "Installation"
 | `description` | string | `""` | Used by templates for `<meta name="description">` and in search results. |
 | `weight` | integer | `0` | Sort order within a section. Lower comes first. |
 | `draft` | bool | `false` | Excluded from the build unless `--drafts`. |
+| `date` | TOML date | — | Publication date. Feeds and `sort_by = "date"` use it. |
 | `slug` | string | filename stem | The last URL segment. |
 | `template` | string | — | Render with this template instead of the usual one. |
 | `toc` | bool | `false` | Whether a table of contents should be shown. |
@@ -43,7 +44,7 @@ These do nothing on an ordinary page, and belong in an `_index.md`.
 
 | Field | Type | Default | |
 |---|---|---|---|
-| `sort_by` | `"weight"` \| `"title"` | site default | How this section orders its pages. |
+| `sort_by` | `"weight"` \| `"title"` \| `"date"` | site default | How this section orders its pages. |
 | `page_template` | string | — | Default template for pages in this section. |
 
 ## Extra
diff --git a/docs/content/content/links.ko.md b/docs/content/content/links.ko.md
@@ -48,6 +48,15 @@ docs/content/start/first-site.md: 해석할 수 없는 내부 링크가 있습
 
 이미지 경로도 같은 길을 지나므로 `![](@/images/x.png)`도 동작합니다.
 
+## 생성된 파일도 링크 대상입니다
+
+`/sitemap.xml`, `/robots.txt`, `/llms.txt`, `/404.html`, `/feed-<언어>.xml`,
+`/search-<언어>.json` 은 검사기 입장에서 페이지와 같습니다. 빌드가 만들어 내므로
+그것을 가리키는 링크는 해석됩니다.
+
+예외는 콘텐츠 해시가 붙는 것들입니다. 빌드 뒤에 `/css/main.css`는 존재하지
+않으므로, 템플릿에서 `asset("css/main.css")`로 가야 합니다.
+
 ## 나머지는 건드리지 않습니다
 
 외부 링크, 앵커, `mailto:` — 그대로 둡니다. sqzass가 가져가는 건 `@/` 접두사뿐입니다.
diff --git a/docs/content/content/links.md b/docs/content/content/links.md
@@ -51,6 +51,15 @@ class of bug you find in production rather than in the build.
 
 Image sources go through the same path, so `![](@/images/x.png)` works too.
 
+## Generated files are link targets too
+
+`/sitemap.xml`, `/robots.txt`, `/llms.txt`, `/404.html`, `/feed-<lang>.xml` and
+`/search-<lang>.json` are pages as far as the checker is concerned — the build
+produces them, so a link to one resolves.
+
+The exception is anything content-hashed. `/css/main.css` does not exist after a
+build; `asset("css/main.css")` in a template is the way to reach it.
+
 ## Everything else is left alone
 
 External links, anchors, `mailto:` — untouched. sqzass only claims the `@/`
diff --git a/docs/content/deploy/_index.ko.md b/docs/content/deploy/_index.ko.md
@@ -114,6 +114,7 @@ GitHub이 영영 찾지 않을 파일입니다.
 | `asset-manifest.json` | 논리 이름 → 실제 URL, 항상 |
 | `<경로>/index.html` | 페이지마다 하나 |
 | `search-<언어>.json` | 언어마다 하나 |
+| `feed-<언어>.xml` | 날짜 있는 페이지가 있는 언어마다 하나 |
 | `sitemap.xml`, `robots.txt` | 같은 이름을 `static/`에 두지 않았다면 |
 | `llms.txt` | 같은 조건 |
 | `404.html` | `templates/404.html`이 있을 때 |
diff --git a/docs/content/deploy/_index.md b/docs/content/deploy/_index.md
@@ -120,6 +120,7 @@ rather than take on trust:
 | `asset-manifest.json` | logical name → written URL, always |
 | `<path>/index.html` | one per page |
 | `search-<lang>.json` | one per language |
+| `feed-<lang>.xml` | one per language that has a dated page |
 | `sitemap.xml`, `robots.txt` | unless `static/` supplied one with that name |
 | `llms.txt` | same terms |
 | `404.html` | when `templates/404.html` exists |
diff --git a/docs/content/features/feeds.ko.md b/docs/content/features/feeds.ko.md
@@ -0,0 +1,107 @@
++++
+title = "피드"
+description = "front matter에 적은 날짜로 만들어지는 언어별 Atom 피드"
+weight = 25
+toc = true
++++
+
+페이지에 `date`를 주면 그 언어의 피드에 들어갑니다.
+
+```toml
++++
+title = "0.2.0 릴리스"
+description = "무엇이 바뀌었나"
+date = 2026-07-26
++++
+```
+
+```
+public/
+├── feed-en.xml
+└── feed-ko.xml
+```
+
+## 날짜가 없으면 피드도 없습니다
+
+날짜 있는 페이지가 하나도 없는 언어에는 파일을 만들지 않고, 템플릿에도 링크할
+`site.feed`가 오지 않습니다. 빈 피드는 없는 것보다 나쁩니다. 구독자는 아무것도
+오지 않는 것을 "비어 있구나"가 아니라 "고장났구나"로 읽습니다.
+
+이 문서에는 날짜 있는 페이지가 없어서 이 사이트는 피드를 발행하지 않습니다.
+그게 기능이 제대로 도는 모습입니다.
+
+## 자동 발견
+
+```html
+{% if site.feed %}
+<link rel="alternate" type="application/atom+xml" title="{{ site.title }}" href="{{ site.feed }}">
+{% endif %}
+```
+
+`site.feed`는 현재 언어의 피드이거나 없습니다. 이 `if`는 방어가 아니라 규칙 그
+자체입니다.
+
+## RSS 2.0이 아니라 Atom
+
+RSS의 날짜는 RFC 2822입니다. `Tue, 26 Jul 2026 00:00:00 +0000` 이 형식은 우리가
+직접 계산해야 하는 **요일**과 직접 박아 넣어야 하는 **영어 월 이름**을 요구합니다.
+한국어 피드에도 그대로 들어가는데, 거기서 `Jul`은 그냥 틀린 표기입니다.
+
+Atom은 RFC 3339를 쓰고, 그건 TOML 날짜가 이미 갖고 있는 모양입니다. 코드가 줄고,
+미묘하게 틀릴 자리가 하나 줄고, 이번 세기에 만들어진 리더는 전부 Atom을 읽습니다.
+
+## 무엇이 들어가나
+
+날짜가 있는 최근 20개, 최신순. 각 항목에 제목·퍼머링크·`updated` 시각, 그리고
+`description`이 요약으로 들어갑니다. 20은 우연이 아니라 상한입니다 — 끝없이 자라는
+피드는 언젠가 다운로드가 됩니다.
+
+같은 날짜의 페이지는 제목순입니다. 그래야 같은 날 쓴 글 둘이 빌드할 때마다 같은
+순서로 나옵니다.
+
+시각이 없는 날짜는 자정 UTC가 됩니다. Atom은 날짜만으로는 받지 않고, 시각을 파싱
+못 한 리더는 그 항목을 아무 말 없이 버립니다.
+
+오프셋은 **쓴 그대로 실립니다.** `2026-07-26T09:00:00+09:00`은 그대로 나갑니다.
+RFC 3339가 어떤 오프셋도 받고, 이걸 `Z`로 고쳐 적으면 서울의 아침을 서울의 저녁이라고
+발표하는 셈이기 때문입니다. 정렬은 표기가 아니라 **순간**을 비교하므로, 같은 날의
+`+09:00` 아침이 `Z` 오후보다 앞섭니다.
+
+TOML이 시각으로만 읽는 `date`(`10:30:00`)는 조용히 버리지 않고 에러입니다. 당신은
+날짜를 적었는데 빌드가 말없이 다르게 생각해서는 안 됩니다.
+
+## 날짜순 정렬
+
+```toml
+# content/posts/_index.md
++++
+title = "글"
+sort_by = "date"
++++
+```
+
+**최신이 먼저**입니다. 오름차순인 `weight`·`title`과 다릅니다. 날짜가 붙은 목록을
+보는 사람이 기대하는 순서니까요. 날짜가 없는 페이지는 앞이 아니라 **뒤로** 갑니다.
+없는 날짜를 0으로 두고 정렬하면 앞으로 올라오는데, 그건 아무도 원하지 않습니다.
+
+## 날짜 보여주기
+
+날짜 필터도, 형식 문자열도 없습니다. `page.date`는 조각입니다.
+
+| | |
+|---|---|
+| `page.date.year` | `2026` |
+| `page.date.month` | `7` |
+| `page.date.day` | `26` |
+| `page.date.date` | `2026-07-26`, `<time datetime>`에 그대로 |
+| `page.date.iso` | `2026-07-26T00:00:00Z` |
+
+```html
+{% if page.date %}
+<time datetime="{{ page.date.date }}">{{ page.date.year }}년 {{ page.date.month }}월 {{ page.date.day }}일</time>
+{% endif %}
+```
+
+형식 문자열 필터를 만들면 날짜 서식 미니 언어를 하나 더 배포하게 되고, 그다음엔
+사람들이 쓰는 모든 언어의 로케일 규칙을 우리가 떠안게 됩니다. 조각은 데이터이고,
+템플릿은 데이터를 배열할 줄 이미 압니다.
diff --git a/docs/content/features/feeds.md b/docs/content/features/feeds.md
@@ -0,0 +1,109 @@
++++
+title = "Feeds"
+description = "One Atom feed per language, from the dates you put in front matter"
+weight = 25
+toc = true
++++
+
+Give a page a `date` and it enters that language's feed.
+
+```toml
++++
+title = "Release 0.2.0"
+description = "What changed"
+date = 2026-07-26
++++
+```
+
+```
+public/
+├── feed-en.xml
+└── feed-ko.xml
+```
+
+## No dates, no feed
+
+A language with no dated pages gets no file, and templates get no
+`site.feed` to link. An empty feed is worse than an absent one: a subscriber who
+sees nothing arriving reads it as broken rather than as empty on purpose.
+
+This documentation has no dated pages, so this site publishes no feed. That is
+the feature working.
+
+## Autodiscovery
+
+```html
+{% if site.feed %}
+<link rel="alternate" type="application/atom+xml" title="{{ site.title }}" href="{{ site.feed }}">
+{% endif %}
+```
+
+`site.feed` is the current language's feed, or nothing. The `if` is not
+defensive — it is the whole rule.
+
+## Atom, not RSS 2.0
+
+RSS dates are RFC 2822: `Tue, 26 Jul 2026 00:00:00 +0000`. That format needs a
+day-of-week we would have to compute and English month names we would have to
+embed — including in a Korean feed, where `Jul` is simply wrong.
+
+Atom uses RFC 3339, which is the shape a TOML date already has. Less code, one
+fewer thing to get subtly wrong, and every reader written this century handles
+Atom.
+
+## What is in it
+
+The 20 most recent dated pages, newest first, each with a title, a permalink, an
+`updated` timestamp, and the `description` as its summary. Twenty is a cap, not
+a coincidence — a feed that grows without limit eventually becomes a download.
+
+Pages within a day are ordered by title, so two posts dated the same day come
+out in the same order on every build.
+
+A date with no time becomes midnight UTC. Atom will not accept a bare date, and
+a reader that cannot parse a timestamp drops the entry without telling anyone.
+
+An offset is carried through as written — `2026-07-26T09:00:00+09:00` stays that,
+because RFC 3339 accepts any offset and rewriting it as `Z` would publish a
+morning in Seoul as an evening in Seoul. Ordering still compares instants rather
+than the text, so a `+09:00` morning sorts before a `Z` afternoon of the same day.
+
+A `date` that TOML reads as a time with no date — `10:30:00` — is an error rather
+than a value quietly dropped. You wrote a date; the build should not disagree in
+silence.
+
+## Sorting by date
+
+```toml
+# content/posts/_index.md
++++
+title = "Posts"
+sort_by = "date"
++++
+```
+
+**Newest first**, unlike `weight` and `title`, which ascend. It is the order
+anyone reading a dated list expects. Pages without a date go last rather than
+first, which is what happens if you sort a missing date as zero.
+
+## Showing the date
+
+There is no date filter and no format string. `page.date` is the parts:
+
+| | |
+|---|---|
+| `page.date.year` | `2026` |
+| `page.date.month` | `7` |
+| `page.date.day` | `26` |
+| `page.date.date` | `2026-07-26`, for `<time datetime>` |
+| `page.date.iso` | `2026-07-26T00:00:00Z` |
+
+```html
+{% if page.date %}
+<time datetime="{{ page.date.date }}">{{ page.date.year }}년 {{ page.date.month }}월 {{ page.date.day }}일</time>
+{% endif %}
+```
+
+A format-string filter would mean shipping a date-formatting mini-language and
+then owning locale rules for every language someone writes in. The parts are
+data, and a template already knows how to arrange data.
diff --git a/docs/content/start/configuration.ko.md b/docs/content/start/configuration.ko.md
@@ -105,7 +105,7 @@ CommonMark의 flanking 규칙은 단어 사이에 공백을 두는 언어를 전
 
 | 키 | 기본값 | |
 |---|---|---|
-| `sort_by` | `"weight"` | `weight` 또는 `title`. 섹션이 각자 덮어쓸 수 있습니다. |
+| `sort_by` | `"weight"` | `weight`, `title`, `date`. 섹션이 각자 덮어쓸 수 있습니다. |
 
 ## 명령줄에서 덮어쓰기
 
diff --git a/docs/content/start/configuration.md b/docs/content/start/configuration.md
@@ -106,7 +106,7 @@ See [Static assets](@/templates/assets.md).
 
 | Key | Default | |
 |---|---|---|
-| `sort_by` | `"weight"` | `weight` or `title`. A section can override it. |
+| `sort_by` | `"weight"` | `weight`, `title` or `date`. A section can override it. |
 
 ## Overriding from the command line
 
diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -16,6 +16,11 @@
 <title>{% if page.url == home %}{{ site.title }} — {{ t("tagline") }}{% else %}{{ page.title }} · {{ site.title }}{% endif %}</title>
 {% if page.description %}<meta name="description" content="{{ page.description }}">{% endif %}
 <link rel="canonical" href="{{ page.permalink }}">
+{#- 피드 자동 발견. 이 언어에 날짜 있는 페이지가 하나도 없으면 site.feed가 없고,
+    그때는 링크도 걸지 않는다 — 빈 피드를 가리키는 링크가 제일 나쁘다. #}
+{%- if site.feed %}
+<link rel="alternate" type="application/atom+xml" title="{{ site.title }}" href="{{ site.feed }}">
+{%- endif %}
 {#- 파비콘은 CSS 필터가 닿지 않는다. 탭 배경이 어두울 때 검은 선화는 보이지
     않으므로 흰 선 버전을 따로 준다. media를 이해 못 하는 브라우저는 마지막
     것을 쓰므로, 밝은 탭이 기본인 쪽을 뒤에 둔다. #}
diff --git a/src/assets.rs b/src/assets.rs
@@ -16,7 +16,7 @@ use walkdir::WalkDir;
 /// 있어서, 해시는 `asset()`으로 조회하는 것들에만 적용한다.
 const FINGERPRINTED: &[&str] = &["css", "js"];
 
-const MANIFEST_PATH: &str = "asset-manifest.json";
+pub const MANIFEST_PATH: &str = "asset-manifest.json";
 
 #[derive(Debug, Default)]
 pub struct Assets {
diff --git a/src/config.rs b/src/config.rs
@@ -150,6 +150,10 @@ impl Default for Nav {
 pub enum SortBy {
     Weight,
     Title,
+    /// **최신이 먼저.** weight와 title은 오름차순인데 이것만 내림차순인 건
+    /// 날짜순 목록에서 사람이 기대하는 게 최신 글이기 때문이다. 날짜가 없는
+    /// 페이지는 뒤로 간다.
+    Date,
 }
 
 fn default_language() -> String {
@@ -177,6 +181,16 @@ impl Config {
     }
 
     fn validate(&self) -> Result<()> {
+        // canonical URL, sitemap `<loc>`, Atom의 `id`, OpenGraph의 `og:url`이
+        // 전부 여기서 나온다. 스킴이 없으면 그것들이 전부 상대 참조가 되는데,
+        // RFC 4287은 상대 참조를 아예 금지하고 나머지도 조용히 틀린다.
+        if !self.base_url.contains("://") {
+            anyhow::bail!(
+                "base_url = \"{}\" 에 스킴이 없습니다. 절대 URL이어야 합니다 \
+                 (예: https://example.com). canonical·sitemap·피드가 전부 이 값에서 나옵니다.",
+                self.base_url
+            );
+        }
         if !self.languages.is_empty() && !self.languages.contains_key(&self.default_language) {
             anyhow::bail!(
                 "default_language = \"{}\" 인데 [languages.{}] 가 정의되어 있지 않습니다",
@@ -256,6 +270,16 @@ mod tests {
     }
 
     /// 조용히 무시되는 설정은 "깨진 참조는 빌드를 멈춘다"의 정확한 위반이다.
+    #[test]
+    fn base_url_must_be_absolute() {
+        let cfg: Config = toml::from_str("title = \"t\"\nbase_url = \"example.com\"\n").unwrap();
+        let err = cfg.validate().unwrap_err().to_string();
+        assert!(err.contains("스킴이 없습니다"), "실제: {err}");
+        let ok: Config =
+            toml::from_str("title = \"t\"\nbase_url = \"https://example.com\"\n").unwrap();
+        assert!(ok.validate().is_ok());
+    }
+
     #[test]
     fn unknown_keys_are_an_error() {
         let err = toml::from_str::<Config>(
diff --git a/src/content.rs b/src/content.rs
@@ -20,6 +20,9 @@ pub struct FrontMatter {
     pub description: String,
     pub weight: i64,
     pub draft: bool,
+    /// 발행 날짜. `sort_by = "date"`와 피드가 쓴다.
+    /// TOML 날짜라 `2026-07-26` 도, `2026-07-26T09:30:00Z` 도 된다.
+    pub date: Option<toml::value::Datetime>,
     /// 기본은 파일명 stem.
     pub slug: Option<String>,
     pub template: Option<String>,
@@ -103,6 +106,19 @@ fn parse_page(path: &Path, content_root: &Path, cfg: &Config) -> Result<Page> {
     let front: FrontMatter = toml::from_str(&format!("\n{fm_text}"))
         .with_context(|| format!("{}: front matter 파싱 실패", path.display()))?;
 
+    // TOML은 `10:30:00` 같은 시각도 날짜 타입으로 받는다. 그걸 그대로 두면
+    // 피드에도 정렬에도 쓰이지 못한 채 조용히 사라지고, 저자는 날짜를 적었다고
+    // 믿는다. 조용히 버리는 대신 멈춘다.
+    if let Some(dt) = &front.date
+        && dt.date.is_none()
+    {
+        anyhow::bail!(
+            "{}: date = {dt} 에 날짜가 없습니다 (시각만 있습니다).\n\
+             `2026-07-26` 이나 `2026-07-26T09:30:00+09:00` 처럼 적으세요.",
+            path.display()
+        );
+    }
+
     let title = front.title.clone().ok_or_else(|| {
         anyhow::anyhow!(
             "{}: front matter에 필수 필드 `title`이 없습니다",
diff --git a/src/feed.rs b/src/feed.rs
@@ -0,0 +1,269 @@
+//! 날짜와 Atom 피드.
+//!
+//! **Atom이고 RSS 2.0이 아니다.** RSS의 `<pubDate>`는 RFC 2822라서
+//! `Tue, 26 Jul 2026 …` 처럼 **요일과 영어 월 이름**을 우리가 만들어 넣어야 한다.
+//! 요일은 직접 계산해야 하고, 영어 월 이름은 한국어 피드에도 그대로 박힌다.
+//! Atom은 RFC 3339를 쓰는데 그건 TOML 날짜가 이미 갖고 있는 모양이라, 코드가
+//! 줄고 틀릴 자리도 줄어든다. 요즘 리더는 둘 다 읽는다.
+//!
+//! **날짜가 하나도 없는 언어에는 피드를 만들지 않는다.** 빈 피드는 없는 것보다
+//! 나쁘다 — 구독자는 아무것도 오지 않는 걸 고장으로 읽는다.
+
+use crate::content::Page;
+use serde::Serialize;
+
+pub const MAX_ENTRIES: usize = 20;
+
+/// 해당 언어 피드의 출력 경로. 검색 색인과 같은 규칙을 쓴다.
+pub fn path(language: &str) -> String {
+    format!("feed-{language}.xml")
+}
+
+/// 템플릿에 나가는 날짜.
+///
+/// 포맷 필터를 만들지 않고 조각을 준다. `{{ page.date.year }}년 {{ page.date.month }}월`
+/// 처럼 템플릿이 조립하면 되고, 그러면 날짜 서식 미니 언어를 하나 더 배울 필요도,
+/// 우리가 로케일별 서식을 떠안을 일도 없다.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct PageDate {
+    pub year: u16,
+    pub month: u8,
+    pub day: u8,
+    /// `2026-07-26` — `<time datetime>` 속성에 그대로 쓸 수 있다.
+    pub date: String,
+    /// `2026-07-26T00:00:00Z` 또는 `2026-07-26T09:00:00+09:00` — RFC 3339.
+    pub iso: String,
+    /// 시간대를 UTC로 옮긴 뒤의 정렬용 값. 서로 다른 오프셋의 순간을 비교하려면
+    /// 표기 그대로가 아니라 같은 기준으로 봐야 한다.
+    utc_minutes: i64,
+}
+
+impl PageDate {
+    /// TOML 날짜에서 만든다. 시각이 없으면 자정 UTC로 본다 — Atom은 날짜만으로는
+    /// 유효하지 않고, 하루 안에서의 순서는 어차피 저자가 정하지 않은 것이다.
+    pub fn from_toml(dt: &toml::value::Datetime) -> Option<Self> {
+        let d = dt.date?;
+        let t = dt.time;
+        let (h, mi, s) = t.map_or((0, 0, 0), |t| (t.hour, t.minute, t.second.unwrap_or(0)));
+
+        // 오프셋은 **그대로 싣는다.** 한때 전부 `Z`로 바꿔 적었는데, 그건
+        // `09:00+09:00`(한국 아침)을 `09:00Z`(한국 저녁)라고 발표하는 것이라
+        // 최대 하루까지 틀린 순간이 나간다. RFC 3339는 어떤 오프셋도 받으므로
+        // 시각 산술 없이 저자가 쓴 것을 그대로 두면 된다.
+        let (suffix, off_min) = match dt.offset {
+            Some(toml::value::Offset::Custom { minutes }) if minutes != 0 => {
+                let (sign, m) = if minutes < 0 {
+                    ('-', -i32::from(minutes))
+                } else {
+                    ('+', i32::from(minutes))
+                };
+                (
+                    format!("{sign}{:02}:{:02}", m / 60, m % 60),
+                    i64::from(minutes),
+                )
+            }
+            _ => ("Z".to_string(), 0),
+        };
+
+        // 정렬은 UTC 기준으로 한다. 표기가 다른 두 순간을 문자열로 비교하면
+        // `+09:00`이 붙은 아침이 `Z`가 붙은 저녁보다 뒤에 온다.
+        let days = i64::from(d.year) * 372 + i64::from(d.month) * 31 + i64::from(d.day);
+        let utc_minutes = days * 1440 + i64::from(h) * 60 + i64::from(mi) - off_min;
+
+        Some(Self {
+            year: d.year,
+            month: d.month,
+            day: d.day,
+            date: format!("{:04}-{:02}-{:02}", d.year, d.month, d.day),
+            iso: format!(
+                "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{suffix}",
+                d.year, d.month, d.day, h, mi, s
+            ),
+            utc_minutes,
+        })
+    }
+
+    /// 정렬용 키. UTC로 옮긴 분 단위 값이라 시각과 시간대까지 반영된다.
+    /// 날짜만 비교하면 같은 날의 아침과 저녁이 구분되지 않고, 그러면 피드의
+    /// `updated`가 자기 항목보다 과거가 되는 일이 생긴다.
+    pub fn sort_key(&self) -> i64 {
+        self.utc_minutes
+    }
+}
+
+/// XML 텍스트/속성값 이스케이프.
+///
+/// ⚠️ 이스케이프만으로는 부족하다. XML 1.0은 C0 제어문자를 **문자 참조로도** 담을
+/// 수 없다고 정한다. 제목에 그런 글자가 하나 섞이면 피드 전체가 파싱되지 않고,
+/// 빌드는 성공했다고 말한다. 그래서 넣을 수 없는 건 지운다.
+fn esc(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for ch in s.chars() {
+        match ch {
+            c if c < '\u{20}' && c != '\t' && c != '\n' && c != '\r' => {}
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            '"' => out.push_str("&quot;"),
+            '\'' => out.push_str("&apos;"),
+            _ => out.push(ch),
+        }
+    }
+    out
+}
+
+/// 날짜가 있는 페이지들로 Atom 피드를 만든다. 하나도 없으면 `None`.
+///
+/// `home_url`은 이 언어의 첫 페이지 URL이다(`/` 또는 `/ko/`).
+pub fn atom(
+    pages: &[&Page],
+    language: &str,
+    site_title: &str,
+    origin: &str,
+    home_url: &str,
+    self_url: &str,
+) -> Option<String> {
+    let mut dated: Vec<(&Page, PageDate)> = pages
+        .iter()
+        .filter_map(|p| {
+            p.front
+                .date
+                .as_ref()
+                .and_then(PageDate::from_toml)
+                .map(|d| (*p, d))
+        })
+        .collect();
+    if dated.is_empty() {
+        return None;
+    }
+
+    // 최신이 먼저. 같은 날이면 제목순 — 파일 순회 순서에 기대면 빌드가 결정적이지 않다.
+    dated.sort_by(|a, b| {
+        b.1.sort_key()
+            .cmp(&a.1.sort_key())
+            .then_with(|| a.0.title.cmp(&b.0.title))
+    });
+
+    dated.truncate(MAX_ENTRIES);
+    // 정렬의 머리가 아니라 **최대값**이다. 같은 날 여러 글이 있으면 정렬은 제목으로
+    // 갈리므로, 머리를 쓰면 피드가 자기 항목보다 과거 시각을 발표하게 된다.
+    // 자르기 뒤에 계산해야 피드에 없는 항목의 시각을 쓰지 않는다.
+    let updated = dated
+        .iter()
+        .max_by_key(|(_, d)| d.sort_key())
+        .map(|(_, d)| d.iso.clone())
+        .expect("비어 있지 않음을 위에서 확인했다");
+
+    let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
+    out.push_str(&format!(
+        "<feed xmlns=\"http://www.w3.org/2005/Atom\" xml:lang=\"{}\">\n",
+        esc(language)
+    ));
+    out.push_str(&format!("  <title>{}</title>\n", esc(site_title)));
+    // id는 영원히 바뀌지 않아야 하고, **다른 무엇과도 같으면 안 된다.** 홈 URL을
+    // 쓰면 홈 페이지 entry의 id와 글자 그대로 같아져서, 리더가 피드와 그 안의 항목
+    // 하나를 같은 것으로 본다. 피드 자기 주소는 그럴 일이 없다.
+    out.push_str(&format!("  <id>{}{}</id>\n", esc(origin), esc(self_url)));
+    out.push_str(&format!(
+        "  <link rel=\"alternate\" href=\"{}{}\"/>\n",
+        esc(origin),
+        esc(home_url)
+    ));
+    out.push_str(&format!(
+        "  <link rel=\"self\" href=\"{}{}\"/>\n",
+        esc(origin),
+        esc(self_url)
+    ));
+    out.push_str(&format!("  <updated>{}</updated>\n", esc(&updated)));
+    // RFC 4287은 feed나 모든 entry 중 한쪽에 author를 **요구한다**. 없으면 검증기가
+    // 무효로 판정하고, 일부 리더는 피드를 통째로 거른다. 사람 이름을 위한 설정 키를
+    // 새로 만드는 대신 사이트 제목을 쓴다 — 사이트 피드의 저자는 사이트다.
+    out.push_str(&format!(
+        "  <author><name>{}</name></author>\n",
+        esc(site_title)
+    ));
+
+    for (page, date) in &dated {
+        let permalink = format!("{origin}{}", page.url);
+        out.push_str("  <entry>\n");
+        out.push_str(&format!("    <title>{}</title>\n", esc(&page.title)));
+        out.push_str(&format!("    <id>{}</id>\n", esc(&permalink)));
+        out.push_str(&format!(
+            "    <link rel=\"alternate\" href=\"{}\"/>\n",
+            esc(&permalink)
+        ));
+        out.push_str(&format!("    <updated>{}</updated>\n", esc(&date.iso)));
+        if !page.front.description.is_empty() {
+            out.push_str(&format!(
+                "    <summary>{}</summary>\n",
+                esc(&page.front.description)
+            ));
+        }
+        out.push_str("  </entry>\n");
+    }
+
+    out.push_str("</feed>\n");
+    Some(out)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn dt(s: &str) -> toml::value::Datetime {
+        s.parse().unwrap()
+    }
+
+    #[test]
+    fn a_date_without_a_time_becomes_midnight_utc() {
+        // Atom은 날짜만으로는 유효하지 않다. `2026-07-26`을 그대로 내보내면
+        // 리더가 항목을 조용히 버린다.
+        let d = PageDate::from_toml(&dt("2026-07-26")).unwrap();
+        assert_eq!(d.date, "2026-07-26");
+        assert_eq!(d.iso, "2026-07-26T00:00:00Z");
+        assert_eq!((d.year, d.month, d.day), (2026, 7, 26));
+    }
+
+    #[test]
+    fn a_full_timestamp_keeps_its_time() {
+        let d = PageDate::from_toml(&dt("2026-07-26T09:30:00Z")).unwrap();
+        assert_eq!(d.iso, "2026-07-26T09:30:00Z");
+    }
+
+    /// 빈 피드는 없는 것보다 나쁘다. 구독자는 아무것도 오지 않는 걸 고장으로 읽는다.
+    #[test]
+    fn no_dated_pages_means_no_feed() {
+        assert!(atom(&[], "en", "S", "https://e.com", "/", "/feed-en.xml").is_none());
+    }
+
+    #[test]
+    fn sort_keys_order_by_calendar_not_by_string() {
+        let a = PageDate::from_toml(&dt("2026-09-01")).unwrap();
+        let b = PageDate::from_toml(&dt("2026-10-01")).unwrap();
+        assert!(b.sort_key() > a.sort_key());
+    }
+
+    /// 오프셋을 전부 Z로 바꿔 적던 시절이 있었다. 그건 한국 아침 9시를 한국
+    /// 저녁 6시라고 발표하는 것이라 최대 하루까지 틀린 순간이 나갔다.
+    #[test]
+    fn an_offset_is_carried_not_relabelled() {
+        let d = PageDate::from_toml(&dt("2026-07-26T09:00:00+09:00")).unwrap();
+        assert_eq!(d.iso, "2026-07-26T09:00:00+09:00");
+        // 그리고 정렬은 UTC 기준이라, 같은 날 18:00Z가 09:00+09:00보다 뒤다.
+        let z = PageDate::from_toml(&dt("2026-07-26T18:00:00Z")).unwrap();
+        assert!(
+            z.sort_key() > d.sort_key(),
+            "표기가 아니라 순간으로 비교해야 한다"
+        );
+    }
+
+    /// XML 1.0은 C0 제어문자를 문자 참조로도 담을 수 없다. 제목에 하나만 섞여도
+    /// 피드 전체가 파싱되지 않는데, 빌드는 성공했다고 말한다.
+    #[test]
+    fn control_characters_are_dropped_not_escaped() {
+        let out = esc("Bell \u{7} here\ttab");
+        assert!(!out.contains('\u{7}'), "실제: {out:?}");
+        assert!(out.contains('\t'), "탭은 XML에 넣을 수 있다: {out:?}");
+        assert_eq!(out, "Bell  here\ttab");
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -6,6 +6,7 @@ pub mod content;
 pub mod doctor;
 pub mod emit;
 pub mod error;
+pub mod feed;
 pub mod highlight;
 pub mod i18n;
 pub mod init;
@@ -106,13 +107,69 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
         None
     };
 
+    // 피드를 먼저 만든다. 페이지가 <head>에 자동 발견 링크를 넣으려면 자기 언어에
+    // 피드가 있는지를 렌더 시점에 알아야 한다.
+    let mut feeds: BTreeMap<String, String> = BTreeMap::new();
+    {
+        let mut by_lang: BTreeMap<&str, Vec<&Page>> = BTreeMap::new();
+        for p in &pages {
+            by_lang.entry(p.language.as_str()).or_default().push(p);
+        }
+        for (lang, langs_pages) in by_lang {
+            let home = if lang == cfg.default_language {
+                format!("{}/", cfg.base_path())
+            } else {
+                format!("{}/{lang}/", cfg.base_path())
+            };
+            let self_url = format!("{}/{}", cfg.base_path(), feed::path(lang));
+            if let Some(xml) = feed::atom(
+                &langs_pages,
+                lang,
+                &cfg.title,
+                cfg.origin(),
+                &home,
+                &self_url,
+            ) {
+                feeds.insert(lang.to_string(), xml);
+            }
+        }
+    }
+
     // 사이트가 실제로 내보내는 URL 전체. 이게 있어야 `[설치](/start/install/)` 같은
     // 평범한 절대 경로 링크도 빌드가 검증할 수 있다 — 지금까지는 `@/`만 봤다.
+    let base = cfg.base_path();
+    let generated = [
+        seo::SITEMAP_PATH,
+        seo::ROBOTS_PATH,
+        emit::LLMS_PATH,
+        emit::NOT_FOUND_PATH,
+        assets::MANIFEST_PATH,
+    ];
     let known = links::KnownUrls {
         urls: pages
             .iter()
             .map(|p| p.url.clone())
             .chain(assets.manifest.values().cloned())
+            // 빌드가 만드는 파일도 존재하는 URL이다. 빼 두면 우리 사이트의
+            // 피드나 sitemap을 가리키는 링크가 "어디도 가리키지 않는다"며
+            // 빌드를 깨뜨린다 — 실제로 그랬다.
+            .chain(generated.iter().map(|p| format!("{base}/{p}")))
+            .chain(
+                feeds
+                    .keys()
+                    .map(|lang| format!("{base}/{}", feed::path(lang))),
+            )
+            // 설정의 [languages]가 아니라 **실제 페이지의 언어**로 만든다.
+            // 언어를 선언하지 않은 사이트도 기본 언어로 색인을 내보내므로,
+            // 설정을 기준으로 삼으면 그 사이트에서 색인 링크가 죽는다.
+            .chain(
+                pages
+                    .iter()
+                    .map(|p| p.language.as_str())
+                    .collect::<std::collections::BTreeSet<_>>()
+                    .into_iter()
+                    .map(|lang| format!("{base}/{}", search::path(lang))),
+            )
             .collect(),
     };
     md = md.with_known_urls(known);
@@ -140,6 +197,10 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
             &templates,
             &md,
             highlight_css_url.as_deref(),
+            feeds
+                .contains_key(&page.language)
+                .then(|| format!("{}/{}", cfg.base_path(), feed::path(&page.language)))
+                .as_deref(),
         )?;
         out.files.insert(
             page.out_path.to_string_lossy().replace('\\', "/"),
@@ -186,6 +247,14 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
             .insert(emit::NOT_FOUND_PATH.into(), html.into_bytes());
     }
 
+    for (lang, xml) in feeds {
+        // `static/`에 같은 이름이 있으면 그쪽이 이긴다. sitemap·robots과 같은
+        // 규칙이다 — 직접 넣은 파일이 조용히 덮이는 건 어느 쪽이든 나쁘다.
+        out.files
+            .entry(feed::path(&lang))
+            .or_insert_with(|| xml.into_bytes());
+    }
+
     out.files.entry(emit::LLMS_PATH.into()).or_insert_with(|| {
         emit::llms_txt(&cfg.title, &cfg.description, cfg.origin(), &pages).into_bytes()
     });
@@ -204,6 +273,8 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
     // `_`로 시작하는 디렉터리를 통째로 삼킨다.
     out.files.insert(".nojekyll".into(), Vec::new());
 
+    check_output_collisions(&out).map_err(Kind::Content.tag())?;
+
     Ok(out)
 }
 
@@ -254,6 +325,31 @@ fn resolve_output_dir(opts: &BuildOptions, cfg: &Config) -> PathBuf {
     }
 }
 
+/// 한 경로가 파일이면서 동시에 디렉터리일 수는 없다.
+///
+/// `feed-en.xml`이라는 이름의 페이지를 만들면 출력에 `feed-en.xml`(파일)과
+/// `feed-en.xml/index.html`(디렉터리)이 함께 생긴다. 쓰는 단계에서야
+/// `File exists (os error 17)`로 죽는데, 그때는 이미 절반이 디스크에 나가 있고
+/// 메시지는 원인을 하나도 말해 주지 않는다. URL 충돌과 같은 부류이므로 같은
+/// 자리에서, 같은 방식으로 막는다.
+fn check_output_collisions(out: &BuildOutput) -> Result<()> {
+    for key in out.files.keys() {
+        let prefix = format!("{key}/");
+        // BTreeMap이 정렬돼 있으므로 접두사를 공유하는 이웃만 보면 된다.
+        if let Some(other) = out.files.range(prefix.clone()..).next()
+            && other.0.starts_with(&prefix)
+        {
+            anyhow::bail!(
+                "출력 경로 충돌: `{key}` 가 파일이면서 동시에 `{}` 의 디렉터리입니다.\n\
+                 빌드가 만드는 파일과 같은 이름의 페이지가 있는지 확인하세요 \
+                 (sitemap.xml, robots.txt, llms.txt, 404.html, feed-<언어>.xml, search-<언어>.json).",
+                other.0
+            );
+        }
+    }
+    Ok(())
+}
+
 /// 페이지 HTML과 그 페이지의 검색 색인 행을 함께 낸다. 본문 평문은 렌더 과정에서
 /// 이미 나오므로, 색인을 위해 문서를 다시 파싱하지 않는다.
 fn render_page(
@@ -263,6 +359,7 @@ fn render_page(
     templates: &Templates,
     md: &markdown::Renderer,
     highlight_css: Option<&str>,
+    feed_url: Option<&str>,
 ) -> Result<(String, search::Entry)> {
     let template = select_template(page, site, templates).map_err(Kind::Template.tag())?;
 
@@ -273,6 +370,7 @@ fn render_page(
         base_path: cfg.base_path().to_string(),
         language: page.language.clone(),
         sections: site::section_ctx(site.sections(&page.language), site.pages),
+        feed: feed_url.map(str::to_string),
         highlight_css: highlight_css.map(str::to_string),
     };
 
@@ -332,6 +430,7 @@ fn render_page(
         next,
         is_section: page.is_section,
         extra: page.front.extra.clone(),
+        date: page.front.date.as_ref().and_then(feed::PageDate::from_toml),
     };
 
     let html = templates
@@ -363,6 +462,7 @@ fn render_standalone(
         base_path: cfg.base_path().to_string(),
         language: language.clone(),
         sections: site::section_ctx(site.sections(&language), site.pages),
+        feed: None,
         highlight_css: highlight_css.map(str::to_string),
     };
 
@@ -384,6 +484,7 @@ fn render_standalone(
         next: None,
         is_section: false,
         extra: toml::Table::new(),
+        date: None,
     };
 
     templates
diff --git a/src/render.rs b/src/render.rs
@@ -211,6 +211,9 @@ pub struct SiteCtx {
     pub language: String,
     /// 이 언어의 최상위 섹션들. 사이드바는 여기서 만든다.
     pub sections: Vec<crate::site::SectionCtx>,
+    /// 이 언어의 Atom 피드 URL. 날짜 있는 페이지가 하나도 없으면 `None`이고,
+    /// 그때 템플릿은 자동 발견 링크를 넣지 않으면 된다.
+    pub feed: Option<String>,
     /// 생성된 하이라이트 스타일시트 URL. 강조가 꺼져 있으면 `None`.
     pub highlight_css: Option<String>,
 }
@@ -243,6 +246,9 @@ pub struct PageCtx {
     pub is_section: bool,
     /// front matter의 `[extra]`. sqzass는 여기를 읽지 않는다 — 템플릿이 읽는다.
     pub extra: toml::Table,
+    /// 발행 날짜. 없으면 없다. 서식 필터 대신 조각을 준다 —
+    /// `{{ page.date.year }}년 {{ page.date.month }}월` 처럼 템플릿이 조립한다.
+    pub date: Option<crate::feed::PageDate>,
 }
 
 #[cfg(test)]
diff --git a/src/seo.rs b/src/seo.rs
@@ -70,6 +70,9 @@ pub fn robots(origin: &str, base_path: &str) -> String {
 fn escape_into(out: &mut String, s: &str) {
     for ch in s.chars() {
         match ch {
+            // XML 1.0은 C0 제어문자를 문자 참조로도 담을 수 없다. 지금은 URL과
+            // 언어 코드만 지나가지만, 언젠가 제목이 지나가면 sitemap 전체가 깨진다.
+            c if c < '\u{20}' && c != '\t' && c != '\n' && c != '\r' => {}
             '&' => out.push_str("&amp;"),
             '<' => out.push_str("&lt;"),
             '>' => out.push_str("&gt;"),
diff --git a/src/site.rs b/src/site.rs
@@ -361,18 +361,34 @@ fn sort_section(sec: &mut Section, pages: &[Page], cfg: &Config) {
         .and_then(|i| pages[i].front.sort_by)
         .unwrap_or(cfg.nav.sort_by);
 
-    let key = |i: &usize| -> (i64, String) {
+    // 날짜순은 **내림차순**이고 날짜 없는 페이지는 뒤로 간다. 정렬 키를 뒤집는
+    // 대신 없는 날짜를 0으로 두면 그것들이 맨 앞으로 올라온다.
+    let date_key = |i: &usize| -> (std::cmp::Reverse<i64>, String) {
         let p = &pages[*i];
-        match sort_by {
-            SortBy::Weight => (p.front.weight, p.title.clone()),
-            SortBy::Title => (0, p.title.clone()),
-        }
+        let k = p
+            .front
+            .date
+            .as_ref()
+            .and_then(crate::feed::PageDate::from_toml)
+            .map_or(0, |d| d.sort_key());
+        (std::cmp::Reverse(k), p.title.clone())
     };
-    sec.pages.sort_by_key(key);
 
+    match sort_by {
+        SortBy::Date => sec.pages.sort_by_key(date_key),
+        _ => sec.pages.sort_by_key(|i: &usize| {
+            let p = &pages[*i];
+            match sort_by {
+                SortBy::Title => (0, p.title.clone()),
+                _ => (p.front.weight, p.title.clone()),
+            }
+        }),
+    }
+
+    // 하위 섹션에는 날짜가 없다. 날짜순 섹션에서도 제목순으로 둔다.
     sec.subsections.sort_by_key(|s| match sort_by {
         SortBy::Weight => (s.weight, s.title.clone()),
-        SortBy::Title => (0, s.title.clone()),
+        _ => (0, s.title.clone()),
     });
 
     for sub in &mut sec.subsections {
@@ -476,6 +492,33 @@ mod tests {
         format!("+++\ntitle = \"{title}\"\n{extra}+++\n\nbody\n")
     }
 
+    /// weight와 title은 오름차순인데 date만 내림차순이다. 날짜순 목록에서
+    /// 사람이 기대하는 건 최신 글이고, 날짜 없는 글은 뒤로 가야 한다.
+    #[test]
+    fn date_sorting_is_newest_first_and_undated_last() {
+        let cfg = cfg();
+        let f = Fixture::new(
+            "dates",
+            &[
+                ("_index.md", &fm("Home", "")),
+                ("posts/_index.md", &fm("Posts", "sort_by = \"date\"\n")),
+                ("posts/a.md", &fm("A", "date = 2026-01-15\n")),
+                ("posts/b.md", &fm("B", "date = 2026-09-01\n")),
+                ("posts/c.md", &fm("C", "date = 2026-05-20\n")),
+                ("posts/none.md", &fm("None", "")),
+            ],
+        );
+        let pages = f.pages(&cfg);
+        let site = Site::build(&pages, &cfg);
+        let posts = &site.sections("en")[0];
+        let titles: Vec<&str> = posts
+            .pages
+            .iter()
+            .map(|&i| pages[i].title.as_str())
+            .collect();
+        assert_eq!(titles, vec!["B", "C", "A", "None"], "실제: {titles:?}");
+    }
+
     #[test]
     fn neighbours_follow_the_section_order() {
         let cfg = cfg();