sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit 21014fcefe2efa3f3aa386e2aeca9db5d6538423
parent c9549468fe65caa16869ac4f0744a266cf66807c
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 23:55:35 +0900

Write down what already works, and fix a link bug the writing exposed

An audit against a much larger competitor's documentation found thirteen things
this tool does that nobody had written down. The absence of a page is
indistinguishable from the absence of the feature, so each of these was a
capability we had and did not have.

Two new pages. templates/functions.md is the complete callable surface —
`asset()`, `t()`, the minijinja builtins that are available, `tojson` which is
not, and the closing line that nothing else exists. That last part is the point:
without it a reader tries `url_for` from another generator and reads the build
error as a bug. templates/social.md is the OpenGraph and JSON-LD markup, which
needed no code at all — every input was already in the context and nobody had
written the eight lines down, so no sqzass site had them, including this one.
This one does now.

The rest went into the pages that were already almost right.
`asset-manifest.json` appears in every output and was named nowhere, so the
reasonable move for anyone auditing a deploy was to delete it. The search index
row schema was withheld on a page that tells you the client is yours to build.
`page.children` means two different things depending on where it is read and
neither was documented. `translation_key` was described as the filename stem
when it is the path with the language suffix removed — which matters, because
the stated version implies `a/notes.md` and `b/notes.md` are translations of
each other.

Writing that last one down is what exposed the bug. links.rs stripped any
trailing two-or-three-letter dotted segment as a language, while content.rs
strips one only when it is a declared code — so `content/notes.ab.md` keyed on
`notes.ab` and `@/notes.ab.md` looked up `notes` and failed the build on a file
that was right there. Fixed by trying the unstripped name first rather than by
giving links.rs its own copy of the language list, because a second copy of that
list is how these two drifted apart in the first place.

The static/ passthrough now has a test. `_headers`, `_redirects`, `.domains`,
`CNAME` and `.well-known/*` are files whose name is the contract, and the
guarantee that they come through unhashed was true by construction and asserted
nowhere.

And the docs now say plainly that every runtime message is Korean with no locale
switch. The English pages quote the real strings, which was honest but left an
English-speaking reader to discover the policy by running the binary.

Diffstat:
MREADME.md | 4++++
Mdocs/content/content/front-matter.ko.md | 2+-
Mdocs/content/content/front-matter.md | 2+-
Mdocs/content/content/languages.ko.md | 9+++++++--
Mdocs/content/content/languages.md | 10++++++++--
Mdocs/content/content/markdown.ko.md | 13+++++++++++++
Mdocs/content/content/markdown.md | 14++++++++++++++
Mdocs/content/deploy/_index.ko.md | 27++++++++++++++++++++++++++-
Mdocs/content/deploy/_index.md | 27++++++++++++++++++++++++++-
Mdocs/content/features/highlighting.ko.md | 37+++++++++++++++++++++++++++++++++++++
Mdocs/content/features/highlighting.md | 38++++++++++++++++++++++++++++++++++++++
Mdocs/content/features/search.ko.md | 16++++++++++++++++
Mdocs/content/features/search.md | 16++++++++++++++++
Mdocs/content/start/_index.ko.md | 7+++++++
Mdocs/content/start/_index.md | 8++++++++
Mdocs/content/start/cli.ko.md | 14++++++++++++++
Mdocs/content/start/cli.md | 14++++++++++++++
Mdocs/content/templates/assets.ko.md | 19+++++++++++++++++++
Mdocs/content/templates/assets.md | 21+++++++++++++++++++++
Mdocs/content/templates/data.ko.md | 38++++++++++++++++++++++++++++++++++++++
Mdocs/content/templates/data.md | 38++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/functions.ko.md | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/functions.md | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/social.ko.md | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/social.md | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/templates/base.html | 13+++++++++++++
Msrc/assets.rs | 40++++++++++++++++++++++++++++++++++++++++
Msrc/links.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
28 files changed, 848 insertion(+), 10 deletion(-)

diff --git a/README.md b/README.md
@@ -61,6 +61,10 @@ Body goes here.
   inside `검색엔진최적화` is ever found.
 - **One binary, no runtime.** No Node, no Python, no system libraries.
 
+Runtime messages — `--help`, errors, `doctor` findings, the dev server log — are
+in Korean, with no locale switch. Error identifiers (`SQZASS_E_CONTENT`) and
+`doctor` check names are ASCII and stable, so scripts have something to match on.
+
 ## Building
 
 ```bash
diff --git a/docs/content/content/front-matter.ko.md b/docs/content/content/front-matter.ko.md
@@ -28,7 +28,7 @@ title = "설치"
 | `slug` | 문자열 | 파일명 stem | URL의 마지막 세그먼트. |
 | `template` | 문자열 | — | 평소 쓰던 것 대신 이 템플릿으로 렌더합니다. |
 | `toc` | bool | `false` | 목차를 보여줄지 여부. |
-| `translation_key` | 문자열 | 파일명 stem | 이 페이지와 번역본을 연결합니다. |
+| `translation_key` | 문자열 | 아래 참고 | 이 페이지와 번역본을 연결합니다. |
 | `extra` | 테이블 | `{}` | 무엇이든. 템플릿에 `page.extra`로 갑니다. |
 
 `toc`는 데이터가 아니라 **저자의 의사**입니다. 목차 자체는 모든 페이지에서 수집해
diff --git a/docs/content/content/front-matter.md b/docs/content/content/front-matter.md
@@ -29,7 +29,7 @@ title = "Installation"
 | `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. |
-| `translation_key` | string | filename stem | Links this page to its translations. |
+| `translation_key` | string | see below | Links this page to its translations. |
 | `extra` | table | `{}` | Anything you want. Reaches templates as `page.extra`. |
 
 `toc` is the author's intent, not the data: the table of contents is collected
diff --git a/docs/content/content/languages.ko.md b/docs/content/content/languages.ko.md
@@ -52,8 +52,13 @@ content/start/
 
 ## 번역을 짝짓는 방법
 
-파일명 stem으로 짝짓습니다. `installation.md`와 `installation.ko.md`는 한 페이지의
-두 언어판입니다. 파일명이 달라야 할 때 — 슬러그를 현지화하는 경우 등 — 는 양쪽
+`content/` 기준 경로에서 언어 접미사를 뗀 값으로 짝짓습니다. 그래서
+`start/installation.md`와 `start/installation.ko.md`는 둘 다 `start/installation`을
+키로 갖는 한 페이지의 두 언어판입니다.
+
+이름만이 아니라 **경로**가 중요합니다. `a/notes.md`와 `b/notes.md`는 서로의 번역이
+아니라 다른 두 페이지입니다. 그리고 **선언한 언어인 접미사만** 떼므로,
+`notes.ab.md`라는 파일은 `ab`가 `[languages]`에 없는 한 `notes.ab`를 키로 갖습니다. 파일명이 달라야 할 때 — 슬러그를 현지화하는 경우 등 — 는 양쪽
 파일에 같은 `translation_key`를 주면 됩니다.
 
 ```toml
diff --git a/docs/content/content/languages.md b/docs/content/content/languages.md
@@ -54,8 +54,14 @@ Empty list, no switcher. There is no state where the button lies.
 
 ## How translations are matched
 
-By filename stem: `installation.md` and `installation.ko.md` are the same page
-in two languages. When the filenames have to differ — a localised slug, say —
+By the path under `content/` with the language suffix removed — so
+`start/installation.md` and `start/installation.ko.md` both key on
+`start/installation` and are the same page in two languages.
+
+The path matters, not just the name: `a/notes.md` and `b/notes.md` are two
+different pages, not translations of each other. Only a suffix that is a
+language you declared is stripped, so a file named `notes.ab.md` keys on
+`notes.ab` unless `ab` is in your `[languages]`. When the filenames have to differ — a localised slug, say —
 set `translation_key` in both files to the same value.
 
 ```toml
diff --git a/docs/content/content/markdown.ko.md b/docs/content/content/markdown.ko.md
@@ -83,6 +83,19 @@ heading_anchors       = "right"   # none | left | right
 그대로 통과시킵니다. 여기 콘텐츠는 신뢰된 것입니다 — 당신의 저장소에, 당신이 쓰고,
 코드와 같은 커밋에서 리뷰됩니다. 이걸 새니타이즈하는 건 시늉일 뿐입니다.
 
+## 키가 아닌 설정 둘
+
+둘 다 고정이고, 둘 다 의도한 것입니다. 옵션을 찾다가 없어서 이슈를 쓰기 전에 여기서
+끝나라고 있는 절입니다.
+
+**원시 HTML은 항상 통과합니다.** 콘텐츠는 신뢰된 것입니다. 당신의 저장소에, 당신이
+쓰고, 코드와 같은 커밋에서 리뷰됩니다. 이걸 새니타이즈하는 건 시늉입니다. sqzass가
+언젠가 신뢰되지 않은 마크다운을 받게 되면, 그건 전역 스위치가 아니라 소스별 신뢰
+등급이 될 것입니다.
+
+**코드 펜스는 `<pre lang="rust">`가 아니라 `<code class="language-rust">`를
+만듭니다.** 클라이언트 하이라이터와 복사 버튼 스니펫이 기대하는 형태가 클래스 쪽입니다.
+
 ## 코드
 
 빌드 시점에 CSS 클래스로 강조됩니다. [구문 강조](@/features/highlighting.md)를
diff --git a/docs/content/content/markdown.md b/docs/content/content/markdown.md
@@ -84,6 +84,20 @@ Passed through. Content here is trusted — it is in your repository, written by
 you, and reviewed in the same commit as the code. Sanitising it would be
 theatre.
 
+## Two settings that are not keys
+
+Both are fixed, and both are deliberate — this section exists so that looking
+for the option and not finding it ends here rather than in an issue.
+
+**Raw HTML always passes through.** Content is trusted: it is in your
+repository, written by you, reviewed in the same commit as the code.
+Sanitising it would be theatre. If sqzass ever ingests untrusted markdown, that
+becomes a per-source trust level rather than a global switch.
+
+**Code fences produce `<code class="language-rust">`**, not `<pre lang="rust">`.
+The class form is what every client-side highlighter and every copy-button
+snippet expects.
+
 ## Code
 
 Highlighted at build time into CSS classes. See
diff --git a/docs/content/deploy/_index.ko.md b/docs/content/deploy/_index.ko.md
@@ -40,7 +40,12 @@ rewrite도 없으므로, 그런 것에 기대는 게 생기면 여기서 가장
 됩니다. 구글은 한 번 믿을 수 없다고 판단하면 그 사이트의 `lastmod`를 통째로
 무시합니다. 틀린 값이 없는 것보다 나쁘다는 뜻입니다.
 
-`sitemap.xml`이나 `robots.txt`를 직접 `static/`에 넣으면 sqzass는 그 파일을 아예
+`llms.txt`는 모든 페이지의 제목·URL·설명을 담은 평평한 목록으로,
+[llmstxt.org가 제안한 형식](https://llmstxt.org)입니다. 이 사이트에 대해 질문받은
+언어 모델이 전체를 훑는 대신 파일 하나를 읽으면 됩니다. 제목·URL·설명이 이미 있으니
+만드는 데 드는 게 없습니다.
+
+`sitemap.xml`이나 `robots.txt`, `llms.txt`를 직접 `static/`에 넣으면 sqzass는 그 파일을 아예
 만들지 않습니다. 당신 것을 덮어쓰지도, 조용히 무시하지도 않습니다.
 
 ## 각 안내서
@@ -97,6 +102,26 @@ href도, 검색 색인도. 출력 디렉터리는 평평하게 남는데, 그 
 `CNAME`이 필요하면 `static/`에 두면 이름 그대로 복사됩니다. 해시가 붙은 `CNAME`은
 GitHub이 영영 찾지 않을 파일입니다.
 
+## 빌드가 내보내는 것
+
+전체 목록입니다. "호스트를 옮기는 일이 복사"라는 말을 믿는 대신 확인할 수 있으라고
+적어 둡니다.
+
+| | |
+|---|---|
+| `static/`의 전부 | 경로와 이름 그대로. CSS와 JS에만 콘텐츠 해시 |
+| `assets/highlight.<해시>.css` | `[highlight] enabled = false`가 아니면 |
+| `asset-manifest.json` | 논리 이름 → 실제 URL, 항상 |
+| `<경로>/index.html` | 페이지마다 하나 |
+| `search-<언어>.json` | 언어마다 하나 |
+| `sitemap.xml`, `robots.txt` | 같은 이름을 `static/`에 두지 않았다면 |
+| `llms.txt` | 같은 조건 |
+| `404.html` | `templates/404.html`이 있을 때 |
+| alias 스텁 | `aliases` 항목마다 하나 |
+| `.nojekyll` | 항상. `static/.nojekyll`보다 이쪽이 이깁니다 |
+
+그 밖엔 없고, 출력 디렉터리 밖으로는 아무것도 나가지 않습니다.
+
 ## 결정성
 
 같은 입력을 두 번 빌드하면 바이트까지 같은 출력이 나옵니다. 그래서 이 빌드를 CI에서
diff --git a/docs/content/deploy/_index.md b/docs/content/deploy/_index.md
@@ -42,7 +42,12 @@ bytes. Git commit times are accurate but need full history, and
 page with the same date. Google ignores a site's `lastmod` entirely once it
 finds it untrustworthy, which makes a wrong one worse than none.
 
-Put your own `sitemap.xml` or `robots.txt` in `static/` and sqzass will not
+`llms.txt` is a flat list of every page — title, URL and description — in the
+[format proposed at llmstxt.org](https://llmstxt.org). A language model asked
+about your site can read one file instead of crawling the whole thing. It costs
+nothing to emit because the title, URL and description already exist.
+
+Put your own `sitemap.xml`, `robots.txt` or `llms.txt` in `static/` and sqzass will not
 generate that file at all. It does not overwrite yours, and it does not
 silently ignore it either.
 
@@ -103,6 +108,26 @@ output through Jekyll, which swallows directories beginning with `_`.
 `CNAME`, if you need one, goes in `static/` and is copied through with its name
 intact — a hashed `CNAME` is a file GitHub will never look for.
 
+## What a build emits
+
+The complete list, so that "moving hosts is a copy" is something you can check
+rather than take on trust:
+
+| | |
+|---|---|
+| everything in `static/` | paths and names intact; CSS and JS get a content hash |
+| `assets/highlight.<hash>.css` | unless `[highlight] enabled = false` |
+| `asset-manifest.json` | logical name → written URL, always |
+| `<path>/index.html` | one per page |
+| `search-<lang>.json` | one per language |
+| `sitemap.xml`, `robots.txt` | unless `static/` supplied one with that name |
+| `llms.txt` | same terms |
+| `404.html` | when `templates/404.html` exists |
+| alias stubs | one per `aliases` entry |
+| `.nojekyll` | always, and it wins over a `static/.nojekyll` |
+
+Nothing else, and nothing outside the output directory.
+
 ## Determinism
 
 Two builds of the same input produce byte-identical output. That makes the
diff --git a/docs/content/features/highlighting.ko.md b/docs/content/features/highlighting.ko.md
@@ -52,6 +52,43 @@ syntect 기본 세트의 테마 이름이면 무엇이든 됩니다. 없는 이
 `string` 같은 이름을 내보내는데, 프로그래밍을 다루는 사이트에서 당신의 스타일시트와
 부딪히기 딱 좋은 일반적인 단어들입니다.
 
+## 줄 번호와 복사 버튼은 당신 몫입니다
+
+둘 다 설정 키가 아니고, `line_numbers`는 아무 일도 안 하는 채로 남기느니 **삭제**했습니다.
+아무것도 하지 않는 설정은 없는 것보다 나쁩니다. 누군가 그걸 켜 놓고 기다리게 되니까요.
+
+줄 번호는 하이라이터가 이미 내보내는 줄에 CSS 카운터를 얹는 것입니다.
+
+```css
+.prose pre code { counter-reset: line; }
+.prose pre code > .line::before {
+  counter-increment: line;
+  content: counter(line);
+  display: inline-block;
+  width: 2.5em;
+  text-align: right;
+  margin-right: 1em;
+  color: var(--ink-3);
+  user-select: none;   /* 블록을 복사할 때 번호까지 딸려가지 않게 */
+}
+```
+
+복사 버튼은 열 줄 남짓이고, 언어는 이미 엘리먼트에 붙어 있습니다.
+
+```js
+document.querySelectorAll(".prose pre").forEach(function (pre) {
+  var b = document.createElement("button");
+  b.textContent = "copy";
+  b.addEventListener("click", function () {
+    navigator.clipboard.writeText(pre.textContent);
+  });
+  pre.appendChild(b);
+});
+```
+
+둘 다 당신의 `static/`에 있고, 둘 다 당신이 다시 꾸밀 수 있으며, 둘 다 영원히 같은
+뜻을 유지해야 하는 설정 파일에 키를 하나도 더하지 않습니다.
+
 ## 끄기
 
 `enabled = false`면 강조를 건너뛰고 스타일시트도 만들지 않습니다. 코드 블록에는
diff --git a/docs/content/features/highlighting.md b/docs/content/features/highlighting.md
@@ -53,6 +53,44 @@ Classes are prefixed `hl-`. Without a prefix, syntect emits class names like
 `source`, `keyword` and `string` — words general enough to collide with your
 own stylesheet on a site about programming.
 
+## Line numbers and a copy button are yours
+
+Neither is a configuration key, and `line_numbers` was deleted rather than
+shipped inert — a setting that does nothing is worse than an absent one, because
+someone sets it and waits.
+
+Line numbers are a CSS counter over the lines the highlighter already emits:
+
+```css
+.prose pre code { counter-reset: line; }
+.prose pre code > .line::before {
+  counter-increment: line;
+  content: counter(line);
+  display: inline-block;
+  width: 2.5em;
+  text-align: right;
+  margin-right: 1em;
+  color: var(--ink-3);
+  user-select: none;   /* so copying the block does not copy the numbers */
+}
+```
+
+A copy button is about ten lines, and the language is already on the element:
+
+```js
+document.querySelectorAll(".prose pre").forEach(function (pre) {
+  var b = document.createElement("button");
+  b.textContent = "copy";
+  b.addEventListener("click", function () {
+    navigator.clipboard.writeText(pre.textContent);
+  });
+  pre.appendChild(b);
+});
+```
+
+Both live in your `static/`, both are yours to restyle, and neither adds a key
+to a configuration file that has to keep meaning the same thing forever.
+
 ## Turning it off
 
 `enabled = false` skips highlighting and emits no stylesheet. Code blocks still
diff --git a/docs/content/features/search.ko.md b/docs/content/features/search.ko.md
@@ -57,6 +57,22 @@ JSON 파일 하나입니다.
 
 코드 블록도 색인합니다. 문서에서 사람들은 어렴풋이 기억나는 명령어를 검색하니까요.
 
+## 행 스키마
+
+클라이언트는 당신이 만드는 것이니, 무엇을 읽게 되는지 적어 둡니다. 페이지 하나가
+한 행입니다.
+
+| 키 | | |
+|---|---|---|
+| `t` | 제목 | 항상 있음 |
+| `d` | 설명 | 비어 있으면 생략 |
+| `u` | URL | 항상 있음. 서브경로가 있으면 그것까지 포함 |
+| `s` | 소속 섹션 제목 | 비어 있거나 자기가 섹션이면 생략 |
+| `c` | 본문 평문, 코드 블록 포함 | 항상 있음 |
+
+키가 한 글자인 건 행마다 반복되기 때문입니다. 수백 페이지에서 `"t"` 대신
+`"title"`을 쓰면 수십 KB가 더 드는데, 사람이 손으로 읽을 파일도 아닙니다.
+
 ## 클라이언트
 
 이 사이트의 `search.js`는 150줄 정도이고 의존성이 없습니다. 대화상자는 `<dialog>`라서
diff --git a/docs/content/features/search.md b/docs/content/features/search.md
@@ -62,6 +62,22 @@ it. Results are capped at twelve, which is as many as anyone reads.
 Code blocks are indexed, because in documentation people search for the command
 they half-remember.
 
+## The row schema
+
+The client is yours to build, so here is what it reads. One row per page:
+
+| Key | | |
+|---|---|---|
+| `t` | title | always present |
+| `d` | description | omitted when empty |
+| `u` | URL | always present, and it carries the subpath if you have one |
+| `s` | parent section title | omitted when empty, and on section index pages |
+| `c` | body plain text, code blocks included | always present |
+
+Keys are one character because they repeat on every row. `"title"` instead of
+`"t"` costs a few tens of kilobytes across a few hundred pages, for a file
+nobody reads by hand.
+
 ## The client
 
 `search.js` on this site is about 150 lines and has no dependencies. The dialog
diff --git a/docs/content/start/_index.ko.md b/docs/content/start/_index.ko.md
@@ -6,3 +6,10 @@ sort_by = "weight"
 +++
 
 사이트를 화면에 띄우는 데 필요한 것들입니다.
+
+> [!NOTE]
+> **실행 중 나오는 메시지는 전부 한국어입니다** — `--help`, 에러, doctor 지적,
+> 개발 서버 로그. 로케일 전환은 없고, 문서도 번역하는 대신 실제 문자열을 그대로
+> 인용합니다. 영문 문서에 한국어 에러가 그대로 실려 있는 것도 그래서입니다.
+> 에러 식별자(`SQZASS_E_CONTENT`)와 doctor 검사 이름(`untranslated`)은 ASCII이고
+> 안정적입니다 — 스크립트가 잡아야 할 것도 그쪽입니다.
diff --git a/docs/content/start/_index.md b/docs/content/start/_index.md
@@ -6,3 +6,11 @@ sort_by = "weight"
 +++
 
 Everything you need to get a site on screen.
+
+> [!NOTE]
+> **Every runtime message is in Korean** — `--help`, errors, doctor findings,
+> the dev server's log. There is no locale switch, and the documentation quotes
+> the real strings rather than translating them, so what you read here is what
+> the binary prints. The error identifiers (`SQZASS_E_CONTENT`) and the doctor
+> check names (`untranslated`) are ASCII and stable, which is what a script
+> should match on anyway.
diff --git a/docs/content/start/cli.ko.md b/docs/content/start/cli.ko.md
@@ -109,5 +109,19 @@ $ echo $?
 4
 ```
 
+`init`과 `doctor`는 각자의 모양이 있습니다.
+
+```console
+$ sqzass init mysite --json
+{"dir":"mysite","files":["sqzass.toml","content/_index.md","templates/page.html"],"ok":true}
+
+$ sqzass doctor -i mysite --json
+{"findings":[{"check":"base-url","file":"sqzass.toml","message":"…","severity":"warn"}],"gated":1,"ok":false}
+```
+
+`doctor`의 `ok`는 `--fail-on` 기준에 걸린 게 하나라도 있으면 false이고, `gated`가
+그 개수입니다. `file`은 해당 없는 지적에서는 생략됩니다. `check` 문자열은
+안정적입니다 — 스크립트가 잡아야 할 것은 메시지가 아니라 이쪽입니다.
+
 `--json` 없이는 메시지가 stderr로, 결과가 stdout으로 갑니다. 터미널 앞의 사람이
 기대하는 방식입니다.
diff --git a/docs/content/start/cli.md b/docs/content/start/cli.md
@@ -111,5 +111,19 @@ $ echo $?
 4
 ```
 
+`init` and `doctor` have their own shapes:
+
+```console
+$ sqzass init mysite --json
+{"dir":"mysite","files":["sqzass.toml","content/_index.md","templates/page.html"],"ok":true}
+
+$ sqzass doctor -i mysite --json
+{"findings":[{"check":"base-url","file":"sqzass.toml","message":"…","severity":"warn"}],"gated":1,"ok":false}
+```
+
+For `doctor`, `ok` is false whenever anything reached the `--fail-on` gate, and
+`gated` counts those. `file` is omitted when a finding has none. The `check`
+strings are stable — they are what a script should match on, not the message.
+
 Without `--json`, messages go to stderr and results to stdout, which is what a
 person at a terminal expects.
diff --git a/docs/content/templates/assets.ko.md b/docs/content/templates/assets.ko.md
@@ -57,6 +57,25 @@ fingerprint = false
 `fingerprint = false`면 파일명이 그대로 유지되고 `asset()`은 계속 동작합니다.
 템플릿을 고치지 않고 켜고 끌 수 있습니다.
 
+## asset-manifest.json
+
+빌드마다 출력 루트에 하나 쓰입니다. 논리 이름과 실제 쓰인 URL의 대응표입니다.
+
+```json
+{
+  "css/main.css": "/css/main.a1b2c3d4.css",
+  "CNAME": "/CNAME"
+}
+```
+
+해시가 붙은 것만이 아니라 모든 정적 파일이 들어가고, `fingerprint = false`여도
+쓰입니다. sqzass는 이걸 다시 읽지 않습니다. 빌드 **밖**의 무언가가 `asset()`이
+빌드 안에서 답하는 질문에 똑같이 답할 수 있으라고 있는 파일입니다 — 서비스 워커,
+배포 스크립트, 캐시 예열기 같은 것들.
+
+무시해도 됩니다. 다만 정리 단계에서 지운 뒤에 이걸 쓰던 도구가 왜 멈췄는지
+찾아 헤매는 건 곤란하고, 이 문단이 그걸 막으려고 있습니다.
+
 ## 검색 색인은 해시하지 않습니다
 
 `/search-en.json`과 `/search-ko.json`은 고정된 이름을 씁니다. `<head>`에서 링크되는
diff --git a/docs/content/templates/assets.md b/docs/content/templates/assets.md
@@ -60,6 +60,27 @@ fingerprint = false
 With `fingerprint = false`, files keep their names and `asset()` still works —
 so you can flip it without editing a template.
 
+## asset-manifest.json
+
+Every build writes one, at the output root, mapping logical names to the URLs
+they were written to:
+
+```json
+{
+  "css/main.css": "/css/main.a1b2c3d4.css",
+  "CNAME": "/CNAME"
+}
+```
+
+It contains every static file, not only the hashed ones, and it is written even
+with `fingerprint = false`. Nothing in sqzass reads it back — it is there so
+that something outside the build can answer the same question `asset()` answers
+inside it: a service worker, a deploy script, a cache warmer.
+
+It is safe to ignore. It is not safe to delete in a cleanup step and then wonder
+why a tool that depended on it stopped working, which is the situation this
+paragraph exists to prevent.
+
 ## The search index is not hashed
 
 `/search-en.json` and `/search-ko.json` keep fixed names. They are fetched by
diff --git a/docs/content/templates/data.ko.md b/docs/content/templates/data.ko.md
@@ -42,6 +42,44 @@ toc = true
 | `page.is_section` | |
 | `page.extra` | 당신의 `[extra]` 테이블. |
 
+## page.children은 두 가지입니다
+
+루트 `_index.md`에서는 최상위 섹션들이 담기고, 그 밖의 섹션에서는 그 섹션의 자식
+페이지들 뒤에 하위 섹션들이 붙습니다. 둘 다 목록 템플릿이 필요로 하는 것이고, 이름만
+봐서는 알 수 없습니다.
+
+```html
+{% for child in page.children %}
+<a href="{{ child.url }}">{{ child.title }}</a>
+{%- if child.description %}<p>{{ child.description }}</p>{% endif %}
+{% endfor %}
+```
+
+일반 페이지에서는 빈 목록입니다.
+
+## page.toc_entries
+
+`{level, id, title, children}`이고 상대적 깊이로 중첩됩니다. h2 다음에 h4가 와도
+중첩되며, 레벨이 연속이라고 가정하지 않습니다. `toc = true`든 아니든 모든 페이지에서
+수집됩니다. front matter의 `toc`는 저자의 의사이고, 데이터는 어느 쪽이든 있으니
+템플릿이 판단하면 됩니다.
+
+그리는 데는 재귀 매크로가 필요합니다.
+
+```html
+{% macro toc_list(entries) %}
+<ul>
+  {%- for e in entries %}
+  <li><a href="#{{ e.id }}">{{ e.title }}</a>
+    {%- if e.children %}{{ toc_list(e.children) }}{% endif %}
+  </li>
+  {%- endfor %}
+</ul>
+{% endmacro %}
+
+{% if page.toc and page.toc_entries %}{{ toc_list(page.toc_entries) }}{% endif %}
+```
+
 ## asset()
 
 `asset("css/main.css")`은 그 파일이 실제로 쓰인 해시 붙은 URL을 돌려줍니다.
diff --git a/docs/content/templates/data.md b/docs/content/templates/data.md
@@ -43,6 +43,44 @@ and `weight`.
 | `page.is_section` | |
 | `page.extra` | Your `[extra]` table. |
 
+## page.children is two things
+
+On the root `_index.md` it holds the top-level sections. On any other section it
+holds that section's own pages, followed by its subsections. Both are what a
+listing template needs, and neither is obvious from the name.
+
+```html
+{% for child in page.children %}
+<a href="{{ child.url }}">{{ child.title }}</a>
+{%- if child.description %}<p>{{ child.description }}</p>{% endif %}
+{% endfor %}
+```
+
+Ordinary pages have an empty list.
+
+## page.toc_entries
+
+`{level, id, title, children}`, nested by relative depth — an h2 followed by an
+h4 nests, without assuming the levels are consecutive. It is collected for every
+page, whether or not `toc = true`; the front matter field is the author's
+intent, and the data is there either way so a template can decide.
+
+Rendering it needs a recursive macro:
+
+```html
+{% macro toc_list(entries) %}
+<ul>
+  {%- for e in entries %}
+  <li><a href="#{{ e.id }}">{{ e.title }}</a>
+    {%- if e.children %}{{ toc_list(e.children) }}{% endif %}
+  </li>
+  {%- endfor %}
+</ul>
+{% endmacro %}
+
+{% if page.toc and page.toc_entries %}{{ toc_list(page.toc_entries) }}{% endif %}
+```
+
 ## asset()
 
 `asset("css/main.css")` returns the hashed URL that file was written to:
diff --git a/docs/content/templates/functions.ko.md b/docs/content/templates/functions.ko.md
@@ -0,0 +1,87 @@
++++
+title = "함수와 필터"
+description = "템플릿이 부를 수 있는 전부, 그리고 그 밖엔 없다는 마지막 줄"
+weight = 40
+toc = true
++++
+
+## sqzass가 제공하는 것
+
+함수 둘. 이게 전부입니다.
+
+### asset(path)
+
+정적 파일이 실제로 쓰인 URL을 돌려줍니다. 해시와 서브경로가 포함됩니다.
+
+```html
+<link rel="stylesheet" href="{{ asset("css/main.css") }}">
+<script src="{{ asset("js/search.js") }}" defer></script>
+```
+
+인자는 `static/` 기준 논리 경로이고, 앞의 슬래시는 있어도 없어도 됩니다. 수집되지
+않은 이름을 요청하면 수집된 이름 전부를 나열하는 빌드 에러가 납니다. 스타일시트
+이름을 바꿨을 때 모든 방문자에게 404를 내는 대신 빌드가 실패합니다.
+
+### t(key)
+
+지금 렌더 중인 페이지의 언어로 `i18n/<언어>.toml`에서 UI 문자열을 찾습니다.
+
+```html
+<a class="skip" href="#content">{{ t("skip_to_content") }}</a>
+```
+
+인자는 하나입니다. 언어는 절대 넘기지 않는데, 그게 왜 의도된 것인지는
+[언어](@/content/languages.md)에 적어 두었습니다. 현재 언어에 키가 없으면 빌드
+에러입니다.
+
+> [!WARNING]
+> 루프 변수 이름을 `t`로 두지 마세요. `{% for t in page.translations %}`는 그 블록
+> 안에서 함수를 가리고, 거기에 라벨을 처음 넣는 사람이 원인에서 멀리 떨어진 에러를
+> 받게 됩니다.
+
+## minijinja가 주는 것
+
+표준 Jinja2 문법이 동작합니다. `{% if %}`, `{% for %}`, `{% extends %}`,
+`{% block %}`, `{% include %}`, `{% macro %}`, `{% from … import … %}`,
+`{% set %}`, 그리고 익숙한 필터들 — `safe`, `escape`, `length`, `join`,
+`default`, `upper`, `lower`, `replace`, `trim`, `first`, `last`, `reverse`,
+`sort`, `map`, `select`, `selectattr`, `batch`, `slice`, `int`, `float`, `abs`,
+`round`, `urlencode`, `striptags`, `indent`.
+
+이 사이트의 템플릿이 `{% macro %}`와
+`{% from "partials/sidebar.html" import nav %}`를 쓰므로, 그 둘은 빌드마다 실제로
+돌고 있습니다.
+
+`tojson`은 **없습니다.** minijinja의 `json` 피처가 꺼져 있고, `{% for %}` 루프로
+이미 되는 일을 위해 의존성을 켜지는 않습니다.
+
+## 그 밖엔 없습니다
+
+`url_for`도, `markdownify`도, `date`도, `now()`도, `env()`도, 커스텀 테스트도
+없습니다. 그중 일부는 빈자리가 아니라 근거가 있는 부재입니다.
+
+`now()`와 `env()`는 "같은 입력이면 같은 바이트"라는 보장을 깹니다. CI가 push마다
+확인하는 그 보장입니다. 시계를 읽을 수 있는 템플릿은 재현 가능할 수 없습니다.
+
+`url_for`는 이미 한 줄인 비교를 감싸는 것입니다. 페이지와 섹션 URL은 바로 쓸 수
+있는 형태로 오고, 서브경로도 이미 붙어 있습니다.
+
+없는 이름을 읽으면 빈 문자열이 아니라 빌드 에러입니다. 다른 생성기의 습관으로
+무언가를 불렀다면 구멍 난 페이지가 아니라 빌드 시점에 알게 됩니다.
+
+## 현재 페이지 표시하기
+
+이걸 위한 헬퍼는 없고, 필요하지도 않습니다. 페이지는 정확히 일치로:
+
+```html
+<a href="{{ p.url }}"{% if p.url == page.url %} aria-current="page"{% endif %}>{{ p.title }}</a>
+```
+
+섹션은 `page.section`으로 조상 일치를:
+
+```html
+<a href="{{ s.url }}"{% if page.section and page.section.url == s.url %} aria-current="true"{% endif %}>{{ s.title }}</a>
+```
+
+접두사 검사에는 `startingwith`를 쓰되 루트를 조심하세요. 모든 URL이 `/`로
+시작하므로, 홈 링크는 반드시 정확히 일치로 비교해야 하고 조상으로 봐서는 안 됩니다.
diff --git a/docs/content/templates/functions.md b/docs/content/templates/functions.md
@@ -0,0 +1,91 @@
++++
+title = "Functions and filters"
+description = "Everything a template can call, and the closing line that nothing else exists"
+weight = 40
+toc = true
++++
+
+## Provided by sqzass
+
+Two functions. That is the whole list.
+
+### asset(path)
+
+Returns the URL a static file was written to, hash and subpath included.
+
+```html
+<link rel="stylesheet" href="{{ asset("css/main.css") }}">
+<script src="{{ asset("js/search.js") }}" defer></script>
+```
+
+The argument is the logical path under `static/`, with or without a leading
+slash. A name that was not collected is a build error listing every name that
+was — a renamed stylesheet fails the build instead of 404ing for every visitor.
+
+### t(key)
+
+Looks a UI string up in `i18n/<language>.toml`, for the language of the page
+being rendered.
+
+```html
+<a class="skip" href="#content">{{ t("skip_to_content") }}</a>
+```
+
+One argument. The language is never passed in — see
+[Languages](@/content/languages.md) for why that is deliberate. A key missing
+from the current language is a build error.
+
+> [!WARNING]
+> Do not name a loop variable `t`. `{% for t in page.translations %}` shadows
+> the function inside that block, and the first person to add a label there gets
+> an error a long way from its cause.
+
+## From minijinja
+
+Standard Jinja2 syntax works: `{% if %}`, `{% for %}`, `{% extends %}`,
+`{% block %}`, `{% include %}`, `{% macro %}`, `{% from … import … %}`, `{% set %}`,
+and the usual filters — `safe`, `escape`, `length`, `join`, `default`, `upper`,
+`lower`, `replace`, `trim`, `first`, `last`, `reverse`, `sort`, `map`,
+`select`, `selectattr`, `batch`, `slice`, `int`, `float`, `abs`, `round`,
+`urlencode`, `striptags`, `indent`.
+
+Our own templates use `{% macro %}` and `{% from "partials/sidebar.html" import nav %}`,
+so those two are exercised on every build.
+
+`tojson` is **not** available: minijinja's `json` feature is off, and turning it
+on to serialise a value into a template is a dependency for something a
+`{% for %}` loop already does.
+
+## Nothing else exists
+
+There is no `url_for`, no `markdownify`, no `date`, no `now()`, no `env()`, no
+custom tests. Some of those are absences with reasons rather than gaps:
+
+`now()` and `env()` would break the guarantee that two builds of the same input
+produce identical bytes, which CI checks on every push. A template that can read
+the clock cannot be reproducible.
+
+`url_for` would wrap a comparison that is already one line. Page and section
+URLs arrive ready to use, and they already carry the subpath.
+
+Reading a name that does not exist is a build error, not an empty string — so
+if you call something from another generator by habit, you find out at build
+time rather than from a page with a hole in it.
+
+## Marking the current page
+
+There is no helper for this, and none is needed. Exact match for a page:
+
+```html
+<a href="{{ p.url }}"{% if p.url == page.url %} aria-current="page"{% endif %}>{{ p.title }}</a>
+```
+
+Ancestor match for a section, using `page.section`:
+
+```html
+<a href="{{ s.url }}"{% if page.section and page.section.url == s.url %} aria-current="true"{% endif %}>{{ s.title }}</a>
+```
+
+For a prefix test use `startingwith`, but be careful with the root: every URL
+starts with `/`, so the home link must be compared exactly and never as an
+ancestor.
diff --git a/docs/content/templates/social.ko.md b/docs/content/templates/social.ko.md
@@ -0,0 +1,95 @@
++++
+title = "소셜 카드와 구조화 데이터"
+description = "이미 있는 값으로 만드는, 당신 소유의 마크업"
+weight = 50
+toc = true
++++
+
+sqzass는 `<head>`에 아무것도 주입하지 않습니다. 주입 지점이 없고 앞으로도 만들지
+않을 것입니다. 조용히 태그를 더하는 생성기는 출력물을 끝까지 읽을 수 없게 만드는
+생성기니까요. 아래는 전부 당신이 `base.html`에 직접 넣는 마크업이고, 재료는 이미
+템플릿 컨텍스트에 있는 값들입니다.
+
+## OpenGraph와 Twitter
+
+이게 없으면 사이트의 모든 페이지가 Slack·Discord·카카오톡에서 맨 URL로 나옵니다.
+
+```html
+<meta property="og:type" content="{{ "website" if page.is_section else "article" }}">
+<meta property="og:site_name" content="{{ site.title }}">
+<meta property="og:title" content="{{ page.title }}">
+<meta property="og:url" content="{{ page.permalink }}">
+<meta property="og:locale" content="{{ "ko_KR" if page.language == "ko" else "en_US" }}">
+{%- if page.description %}
+<meta property="og:description" content="{{ page.description }}">
+{%- endif %}
+<meta name="twitter:card" content="summary">
+```
+
+`summary_large_image`가 아니라 `summary`입니다. large 쪽은 이미지가 있어야 뜻이
+있고, 이미지 없이 선언하면 더 예쁜 카드가 아니라 빈 상자가 나옵니다. 페이지마다
+이미지가 있다면 front matter에 넣고 바꾸면 됩니다.
+
+```toml
++++
+title = "설치"
+[extra]
+image = "/images/install.png"
++++
+```
+
+```html
+{%- if page.extra.image %}
+<meta property="og:image" content="{{ site.origin }}{{ page.extra.image }}">
+<meta name="twitter:card" content="summary_large_image">
+{%- else %}
+<meta name="twitter:card" content="summary">
+{%- endif %}
+```
+
+`og:image`는 절대 URL이어야 합니다. `site.origin`이 그 자리에 있는 이유입니다.
+
+## JSON-LD 브레드크럼
+
+문서 사이트의 구글 검색 결과를 눈에 띄게 바꾸는 구조화 데이터는 이것 하나입니다.
+결과에 맨 URL 대신 `홈 › 콘텐츠 작성 › front matter`가 뜹니다.
+
+```html
+{%- if page.section %}
+<script type="application/ld+json">
+{
+  "@context": "https://schema.org",
+  "@type": "BreadcrumbList",
+  "itemListElement": [
+    {"@type": "ListItem", "position": 1, "name": "{{ site.title }}", "item": "{{ site.origin }}{{ site.base_path }}/"},
+    {"@type": "ListItem", "position": 2, "name": "{{ page.section.title }}", "item": "{{ site.origin }}{{ page.section.url }}"},
+    {"@type": "ListItem", "position": 3, "name": "{{ page.title }}", "item": "{{ page.permalink }}"}
+  ]
+}
+</script>
+{%- endif %}
+```
+
+`{%- if page.section %}`를 눈여겨보세요. 최상위 페이지에는 섹션이 없고, 단이 하나
+비어 있는 브레드크럼은 없느니만 못합니다.
+
+> [!WARNING]
+> 이 블록은 `<script>` 안이라 HTML 이스케이프가 맞지 않습니다. 제목에 `"`가 들어가면
+> 깨진 JSON이 나옵니다. 제목에 따옴표를 쓰지 않거나, 조용히 파싱에 실패하는 JSON을
+> 내보내느니 구조화 데이터를 빼세요. 템플릿에서 우리 이스케이프가 지켜 주지 못하는
+> 유일한 자리입니다.
+
+## 사이트 단위
+
+```html
+<script type="application/ld+json">
+{
+  "@context": "https://schema.org",
+  "@type": "WebSite",
+  "name": "{{ site.title }}",
+  "url": "{{ site.origin }}{{ site.base_path }}/"
+}
+</script>
+```
+
+모든 페이지에 이 블록 하나면 충분합니다. 검색 엔진은 한 번만 읽습니다.
diff --git a/docs/content/templates/social.md b/docs/content/templates/social.md
@@ -0,0 +1,97 @@
++++
+title = "Social cards and structured data"
+description = "Markup you own, from values that already exist"
+weight = 50
+toc = true
++++
+
+sqzass injects nothing into `<head>`. There is no injection point and there will
+not be one — a generator that quietly adds tags is a generator you cannot fully
+read the output of. Everything below is markup you put in your own `base.html`,
+built from values already in the template context.
+
+## OpenGraph and Twitter
+
+Without these, every page of your site renders as a bare URL in Slack, Discord
+and KakaoTalk.
+
+```html
+<meta property="og:type" content="{{ "website" if page.is_section else "article" }}">
+<meta property="og:site_name" content="{{ site.title }}">
+<meta property="og:title" content="{{ page.title }}">
+<meta property="og:url" content="{{ page.permalink }}">
+<meta property="og:locale" content="{{ "ko_KR" if page.language == "ko" else "en_US" }}">
+{%- if page.description %}
+<meta property="og:description" content="{{ page.description }}">
+{%- endif %}
+<meta name="twitter:card" content="summary">
+```
+
+`summary`, not `summary_large_image` — the large variant needs an image, and
+declaring it without one produces an empty box rather than a nicer card. If you
+have a per-page image, put it in front matter and switch:
+
+```toml
++++
+title = "Installation"
+[extra]
+image = "/images/install.png"
++++
+```
+
+```html
+{%- if page.extra.image %}
+<meta property="og:image" content="{{ site.origin }}{{ page.extra.image }}">
+<meta name="twitter:card" content="summary_large_image">
+{%- else %}
+<meta name="twitter:card" content="summary">
+{%- endif %}
+```
+
+`og:image` must be absolute, which is what `site.origin` is for.
+
+## Breadcrumbs as JSON-LD
+
+This is the one piece of structured data that visibly changes a Google result
+for a documentation site: the result shows `Home › Writing content › Front matter`
+instead of a bare URL.
+
+```html
+{%- if page.section %}
+<script type="application/ld+json">
+{
+  "@context": "https://schema.org",
+  "@type": "BreadcrumbList",
+  "itemListElement": [
+    {"@type": "ListItem", "position": 1, "name": "{{ site.title }}", "item": "{{ site.origin }}{{ site.base_path }}/"},
+    {"@type": "ListItem", "position": 2, "name": "{{ page.section.title }}", "item": "{{ site.origin }}{{ page.section.url }}"},
+    {"@type": "ListItem", "position": 3, "name": "{{ page.title }}", "item": "{{ page.permalink }}"}
+  ]
+}
+</script>
+{%- endif %}
+```
+
+Note the `{%- if page.section %}`: top-level pages have no section, and a
+breadcrumb with a missing rung is worse than none.
+
+> [!WARNING]
+> This block is inside `<script>`, where HTML escaping is wrong — a title
+> containing `"` produces invalid JSON. Keep titles free of quotes, or drop the
+> structured data rather than shipping JSON that silently fails to parse. This
+> is the one place in a template where our escaping does not protect you.
+
+## Site-level
+
+```html
+<script type="application/ld+json">
+{
+  "@context": "https://schema.org",
+  "@type": "WebSite",
+  "name": "{{ site.title }}",
+  "url": "{{ site.origin }}{{ site.base_path }}/"
+}
+</script>
+```
+
+One block, on every page, is enough. Search engines read it once.
diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -21,6 +21,19 @@
 {%- for lang in page.translations %}
 <link rel="alternate" hreflang="{{ lang.code }}" href="{{ site.origin }}{{ lang.url }}">
 {%- endfor %}
+{#- 소셜 카드. sqzass는 <head>에 아무것도 주입하지 않으므로 이건 우리 마크업이다.
+    입력은 전부 이미 컨텍스트에 있다 — 새로 만들 데이터가 없다. #}
+<meta property="og:type" content="{{ "website" if page.is_section else "article" }}">
+<meta property="og:site_name" content="{{ site.title }}">
+<meta property="og:title" content="{{ page.title }}">
+<meta property="og:url" content="{{ page.permalink }}">
+<meta property="og:locale" content="{{ "ko_KR" if ko else "en_US" }}">
+{%- if page.description %}
+<meta property="og:description" content="{{ page.description }}">
+{%- endif %}
+{#- summary_large_image는 이미지가 있을 때만 뜻이 있다. 이미지가 없으면 summary가
+    맞고, 이미지 없이 large를 선언하면 카드가 빈 상자로 나온다. #}
+<meta name="twitter:card" content="summary">
 <link rel="stylesheet" href="{{ asset("css/main.css") }}">
 {%- if site.highlight_css %}
 <link rel="stylesheet" href="{{ site.highlight_css }}">
diff --git a/src/assets.rs b/src/assets.rs
@@ -214,6 +214,46 @@ mod tests {
         std::fs::remove_dir_all(&root).ok();
     }
 
+    /// 호스트별 설정 파일은 **이름이 곧 계약**이다. `_headers.9f8e7d6c`는 Netlify가
+    /// 영영 찾지 않을 파일이고, `.domains.abc`도 Codeberg가 찾지 않는다. 이게
+    /// 조용히 깨지면 배포는 성공하면서 도메인만 안 붙는다.
+    #[test]
+    fn host_config_files_pass_through_with_their_names() {
+        let root =
+            std::env::temp_dir().join(format!("sqzass-assets-passthru-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&root);
+        let s = root.join("static");
+        std::fs::create_dir_all(s.join(".well-known")).unwrap();
+        for (name, body) in [
+            ("_headers", "/*\n  X-Frame-Options: DENY\n"),
+            ("_redirects", "/old /new 301\n"),
+            (".domains", "example.com\n"),
+            ("CNAME", "example.com\n"),
+            ("robots.txt", "User-agent: *\n"),
+        ] {
+            std::fs::write(s.join(name), body).unwrap();
+        }
+        std::fs::write(s.join(".well-known/security.txt"), "Contact: x\n").unwrap();
+
+        let a = Assets::collect(&root, &cfg(), "").unwrap();
+        for name in [
+            "_headers",
+            "_redirects",
+            ".domains",
+            "CNAME",
+            "robots.txt",
+            ".well-known/security.txt",
+        ] {
+            assert_eq!(
+                a.url(name),
+                Some(format!("/{name}").as_str()),
+                "{name} 의 이름이 바뀌었다"
+            );
+            assert!(a.files.contains_key(name), "{name} 이 출력에 없다");
+        }
+        let _ = std::fs::remove_dir_all(&root);
+    }
+
     #[test]
     fn missing_static_dir_is_not_an_error() {
         let root = std::env::temp_dir().join("sqzass-assets-nonexistent-xyz");
diff --git a/src/links.rs b/src/links.rs
@@ -88,8 +88,19 @@ impl Resolver {
             Some((p, f)) => (p, Some(f)),
             None => (url, None),
         };
-        let key = translation_key(path.trim_start_matches(PREFIX));
-        let by_lang = self.index.get(&key)?;
+        // 언어 접미사를 떼기 **전 이름을 먼저** 찾는다.
+        //
+        // 여기에는 선언된 언어 목록이 없어서 "짧은 ASCII 꼬리는 언어"라는 어림짐작을
+        // 쓸 수밖에 없는데, 콘텐츠 쪽은 설정에 선언된 코드만 언어로 인정한다. 두 규칙이
+        // 어긋나면 `notes.ab.md` 같은 파일이 키는 `notes.ab`로 잡히고 링크는 `notes`를
+        // 찾아 빌드가 깨진다. 목록을 여기로 한 벌 더 복제하면 그 둘은 언젠가 또 갈라지므로,
+        // 대신 두 형태를 차례로 본다 — 안 뗀 이름이 실제 키일 때만 먼저 걸린다.
+        let raw = path.trim_start_matches(PREFIX).trim_start_matches('/');
+        let raw_stem = raw.strip_suffix(".md").unwrap_or(raw);
+        let by_lang = self.index.get(raw_stem).or_else(|| {
+            self.index
+                .get(&translation_key(path.trim_start_matches(PREFIX)))
+        })?;
 
         // 지금 페이지와 같은 언어판을 먼저 찾는다. 한국어 문서가
         // `@/start/installation.md` 라고 써도 한국어판으로 간다.
@@ -153,6 +164,52 @@ fn translation_key(path: &str) -> String {
     }
 }
 
+#[cfg(test)]
+mod language_suffix_tests {
+    use super::*;
+
+    fn index(pairs: &[(&str, &str)]) -> LinkIndex {
+        let mut idx = LinkIndex::new();
+        for (key, url) in pairs {
+            idx.entry((*key).to_string())
+                .or_default()
+                .insert("en".to_string(), (*url).to_string());
+        }
+        idx
+    }
+
+    /// 콘텐츠 쪽은 **선언된** 언어 코드만 떼고, 링크 쪽은 목록을 갖고 있지 않다.
+    /// 두 규칙이 어긋나면 멀쩡한 파일을 가리키는 링크가 빌드를 깨뜨린다.
+    #[test]
+    fn a_dotted_name_that_is_not_a_language_still_resolves() {
+        let r = Resolver::new(index(&[("notes.ab", "/notes.ab/")]), "en", "en");
+        assert_eq!(r.resolve("@/notes.ab.md"), Some("/notes.ab/".to_string()));
+        assert!(r.take_unresolved().is_empty());
+    }
+
+    #[test]
+    fn a_real_language_suffix_is_still_stripped() {
+        let r = Resolver::new(index(&[("start/install", "/start/install/")]), "en", "en");
+        assert_eq!(
+            r.resolve("@/start/install.ko.md"),
+            Some("/start/install/".to_string())
+        );
+        assert_eq!(
+            r.resolve("@/start/install.md"),
+            Some("/start/install/".to_string())
+        );
+    }
+
+    #[test]
+    fn fragments_survive_both_paths() {
+        let r = Resolver::new(index(&[("notes.ab", "/notes.ab/")]), "en", "en");
+        assert_eq!(
+            r.resolve("@/notes.ab.md#설치"),
+            Some("/notes.ab/#설치".to_string())
+        );
+    }
+}
+
 #[cfg(test)]
 mod absolute_link_tests {
     use super::*;