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::*;
|