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: 해석할 수 없는 내부 링크가 있습
이미지 경로도 같은 길을 지나므로 ``도 동작합니다.
+## 생성된 파일도 링크 대상입니다
+
+`/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 `` 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("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '"' => out.push_str("""),
+ '\'' => out.push_str("'"),
+ _ => 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("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
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();
|