sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit ae9cb15306bc1e22374536950d2add7b875d9017
parent 1c0ca27f86f24c2327e4839ed2905fe31522e953
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 22:08:35 +0900

Write the documentation, and stop writing Korean like notes

The site had three pages and a sidebar with one entry in it. Whatever else was
wrong with it, most of what made it feel unfinished was that it was
unfinished. There are now five sections — getting started, writing content,
templates, features, deploying — and thirty-eight pages across two languages.

Only what the code actually does is documented. Every configuration key, front
matter field and template value here was read out of the source rather than
recalled, because a documented key that does not exist is worse than an
undocumented one that does: it sends a reader looking for a bug in their own
file. The pages that explain a decision explain the reasoning too — why URLs
are directories, why slugs come from filenames rather than titles, why template
selection is four steps and not twenty — since a rule whose reason is invisible
reads as an arbitrary restriction.

Cross-references are all `@/` links, which means the build checks them. Thirty
new pages of prose is exactly where a stale link would otherwise slip in
unnoticed.

The Korean was rewritten. It was in the terse declarative register you use for
your own notes — 빌드한다, 필요 없다 — which is fine in a commit message and
wrong in documentation a stranger is reading; it lands somewhere between curt
and machine-translated. It now uses complete sentences throughout, matching how
Korean technical writing actually reads.

The search index grew to 19 rows and 38 KB per language, still one fetch on
first use. Checked against the finished site: 최적화 finds the three pages that
mention 검색엔진최적화, and 존성 finds the three that say 의존성.

Diffstat:
Mdocs/content/_index.ko.md | 36+++++++++++++++++++-----------------
Mdocs/content/_index.md | 12+++++++-----
Adocs/content/content/_index.ko.md | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/content/_index.md | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/content/front-matter.ko.md | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/content/front-matter.md | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/content/languages.ko.md | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/content/languages.md | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/content/links.ko.md | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/content/links.md | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/content/markdown.ko.md | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/content/markdown.md | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/_index.ko.md | 39+++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/_index.md | 41+++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/github-pages.ko.md | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/github-pages.md | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/features/_index.ko.md | 12++++++++++++
Adocs/content/features/_index.md | 12++++++++++++
Adocs/content/features/dev-server.ko.md | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/features/dev-server.md | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/features/highlighting.ko.md | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/features/highlighting.md | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/features/search.ko.md | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/features/search.md | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/content/start/_index.ko.md | 4++--
Adocs/content/start/first-site.ko.md | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/start/first-site.md | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/content/start/installation.ko.md | 12+++++++-----
Adocs/content/templates/_index.ko.md | 39+++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/_index.md | 41+++++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/assets.ko.md | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/assets.md | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/data.ko.md | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/data.md | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/selection.ko.md | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/templates/selection.md | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
36 files changed, 2055 insertion(+), 29 deletion(-)

diff --git a/docs/content/_index.ko.md b/docs/content/_index.ko.md
@@ -4,9 +4,9 @@ description = "Rust로 만든 정적 사이트 생성기"
 weight = 0
 +++
 
-대부분의 생성기는 예쁜 URL·리다이렉트·캐시 헤더를 호스트에 맡긴다. sqzass는 그걸
-직접 한다. 그래서 같은 출력물이 GitHub Pages든 Vercel이든 Cloudflare Pages든,
-그냥 디렉터리를 HTTP로 서빙하든 똑같이 동작한다.
+대부분의 생성기는 예쁜 URL·리다이렉트·캐시 헤더를 호스트에 맡깁니다. sqzass는 그
+일을 직접 합니다. 그래서 같은 출력물이 GitHub Pages든 Vercel이든 Cloudflare Pages든,
+그냥 디렉터리를 HTTP로 서빙하든 똑같이 동작합니다.
 
 ```bash
 sqzass build -i docs
@@ -15,29 +15,31 @@ sqzass serve -i docs
 
 ## 그래서 얻는 것
 
-**마크다운을 트리에서 변환한다.** 링크 재작성, heading anchor, 목차 모두
-[comrak]의 AST 연산이다. 흔한 지름길인 "완성된 HTML에 정규식 돌리기"는 속성을
+**마크다운을 트리에서 변환합니다.** 링크 재작성, heading anchor, 목차가 모두
+[comrak]의 AST 연산입니다. 흔한 지름길인 "완성된 HTML에 정규식 돌리기"는 속성을
 홑따옴표로 쓰거나 따옴표를 빼면 조용히 처리에서 빠지고, 그 사실을 프로덕션에서
-알게 된다.
+알게 됩니다.
 
-**템플릿이 반쯤 쓰인 파일을 읽을 수 없다.** `templates/`를 빌드마다 한 번
+**템플릿이 반쯤 쓰인 파일을 읽을 수 없습니다.** `templates/`를 빌드마다 한 번
 스냅샷으로 뜨고 [minijinja]의 로더가 `include`와 `extends`를 그 스냅샷에서
-해석한다. 저장하는 도중에 리빌드가 걸려도 항상 일관된 상태를 본다.
+해석합니다. 저장하는 도중에 리빌드가 걸려도 항상 일관된 상태를 봅니다.
 
-**깨진 참조는 빌드를 멈춘다.** 해석 안 되는 `@/` 링크, 없는 템플릿, 같은 URL을
-주장하는 두 페이지, 없는 에셋 이름 — 전부 경고가 아니라 에러다.
+**깨진 참조는 빌드를 멈춥니다.** 해석되지 않는 `@/` 링크, 없는 템플릿, 같은 URL을
+주장하는 두 페이지, 없는 에셋 이름 — 전부 경고가 아니라 에러입니다.
 
-**같은 입력은 같은 바이트를 낸다.** 두 번 빌드하면 바이트까지 동일하고, CI가
-push마다 확인한다.
+**같은 입력은 같은 바이트를 냅니다.** 두 번 빌드하면 바이트까지 동일하고, CI가
+push마다 확인합니다.
 
-**처음부터 이중 언어.** 번역은 자동으로 연결되고, 각 언어의 내비게이션에는 그
-언어에 실제로 있는 페이지만 담긴다. 미번역 페이지가 죽은 링크를 남길 수 없다.
+**한국어를 나중에 얹지 않았습니다.** 번역은 자동으로 연결되고, 각 언어의
+내비게이션에는 그 언어에 실제로 있는 페이지만 담깁니다. 미번역 페이지가 죽은 링크를
+남길 수 없습니다. [검색](@/features/search.md)은 형태소 분석 대신 부분 문자열로
+찾기 때문에 `검색엔진최적화` 안의 `최적화`도 걸립니다.
 
 ## 상태
 
-초기 단계이고, 그 점을 숨기지 않는다. 구문 강조·내비게이션·목차·에셋
-파이프라인·개발 서버는 동작한다. 검색과 피드, 테마는 아직 없다.
-[시작하기](@/start/_index.md)부터 보라.
+초기 단계이고, 그 점을 숨기지 않습니다. 구문 강조, 내비게이션, 목차, 에셋
+파이프라인, 개발 서버, 검색은 동작합니다. 피드와 테마 시스템은 아직 없습니다.
+[시작하기](@/start/_index.md)부터 보세요.
 
 [comrak]: https://github.com/kivikakk/comrak
 [minijinja]: https://github.com/mitsuhiko/minijinja
diff --git a/docs/content/_index.md b/docs/content/_index.md
@@ -31,15 +31,17 @@ an error, not a warning you scroll past.
 **The same input produces the same bytes.** Two builds are byte-identical, and
 CI checks it on every push.
 
-**Bilingual from the start.** A page's translations are linked automatically, and
-a language's navigation contains only pages that exist in it, so an untranslated
-page cannot leave a dead link behind.
+**Korean was not bolted on afterwards.** A page's translations are linked
+automatically, and a language's navigation contains only pages that exist in it,
+so an untranslated page cannot leave a dead link behind. [Search](@/features/search.md)
+matches substrings rather than words, which is the only way `최적화` inside
+`검색엔진최적화` is ever found.
 
 ## Status
 
 Early, and honest about it: syntax highlighting, navigation, the table of
-contents, the asset pipeline and the dev server work. Search, feeds and themes
-do not exist yet. Start at [Getting started](@/start/_index.md).
+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).
 
 [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
@@ -0,0 +1,64 @@
++++
+title = "콘텐츠 작성"
+description = "페이지와 섹션, front matter, 그리고 거기서 나오는 URL"
+weight = 20
+sort_by = "weight"
++++
+
+`content/` 아래의 모든 파일이 페이지입니다. `_index.md`가 들어 있는 디렉터리는
+섹션이 되고, 섹션은 그 옆의 페이지들을 묶습니다.
+
+## 페이지와 섹션
+
+```
+content/
+├── _index.md            →  /
+├── about.md             →  /about/
+└── guide/
+    ├── _index.md        →  /guide/
+    ├── install.md       →  /guide/install/
+    └── deep/
+        ├── _index.md    →  /guide/deep/
+        └── dive.md      →  /guide/deep/dive/
+```
+
+`_index.md`가 **없는** 디렉터리도 그 안의 페이지 URL은 그대로 만들어집니다.
+다만 섹션은 아닙니다. 그 페이지들을 묶어 주는 것이 없고, 디렉터리 자체를 가리키는
+내비게이션 항목도 생기지 않습니다.
+
+## 모든 URL이 디렉터리인 이유
+
+페이지는 `<경로>.html`이 아니라 `<경로>/index.html`로 쓰입니다. rewrite 규칙이
+있는 호스트라면 `about.html`을 `/about`으로 내줄 수 있지만, 규칙이 없는 호스트는
+못 합니다. sqzass는 아무것도 해 주지 않는 호스트에서 옳게 도는 것을 기준으로
+만들었습니다. 디렉터리 형태는 서버에게 아무 재주도 요구하지 않기 때문에 GitHub
+Pages든 Cloudflare Pages든 S3든 `python3 -m http.server`든 똑같이 동작합니다.
+
+대가는 있습니다. 슬래시 없는 `/about` 링크는 대부분의 서버가 리다이렉트를 한 번
+거친 뒤에야 도착합니다. `/about/`으로 쓰거나, 더 나은 방법으로는
+[`@/` 링크](@/content/links.md)를 써서 URL을 sqzass가 쓰게 하세요.
+
+## 슬러그는 제목이 아니라 파일명에서 나옵니다
+
+`install.md`는 `/guide/install/`이 됩니다. 제목은 여기에 관여하지 않습니다.
+
+이 규칙이 가장 중요해지는 곳이 한국어입니다. 흔한 방법은 제목을 로마자로 옮기는
+것인데, `slug` 계열 크레이트는 한글을 문맥 없이 한 음절씩 ASCII로 매핑하기 때문에
+서로 다른 한국어 제목이 같은 경로로 겹칠 수 있습니다. 직접 정한 파일명은 모호하지
+않고, 같은 디렉터리 안에서 이미 유일합니다. 한국어 파일명은 퍼센트 인코딩된
+UTF-8 그대로 둡니다.
+
+페이지마다 바꾸고 싶으면 front matter의 `slug`로 덮어쓰면 됩니다. **두 페이지가
+같은 URL을 주장하면 빌드 에러입니다.** 나중에 쓴 쪽이 이긴다는 식으로 처리하지
+않습니다.
+
+## 정렬
+
+섹션은 자식 페이지를 `weight` 오름차순으로 정렬하고, `weight`가 없으면 제목으로
+넘어갑니다. 섹션의 `_index.md`에 `sort_by = "title"`을 주면 제목 기준이 되고,
+`sqzass.toml`의 `[nav] sort_by`로 사이트 전체 기본값을 바꿀 수 있습니다.
+
+## 드래프트
+
+`draft = true`인 페이지는 빌드에서 빠집니다. 명령줄의 `--drafts`나 설정의
+`[build] drafts = true`로 다시 넣을 수 있습니다.
diff --git a/docs/content/content/_index.md b/docs/content/content/_index.md
@@ -0,0 +1,64 @@
++++
+title = "Writing content"
+description = "Pages, sections, front matter and the URLs they produce"
+weight = 20
+sort_by = "weight"
++++
+
+Everything under `content/` is a page. A directory with an `_index.md` in it is
+a section, and a section collects the pages beside it.
+
+## Pages and sections
+
+```
+content/
+├── _index.md            →  /
+├── about.md             →  /about/
+└── guide/
+    ├── _index.md        →  /guide/
+    ├── install.md       →  /guide/install/
+    └── deep/
+        ├── _index.md    →  /guide/deep/
+        └── dive.md      →  /guide/deep/dive/
+```
+
+A directory *without* an `_index.md` still produces URLs for the pages inside
+it, but it is not a section: nothing collects those pages, and no navigation
+entry exists for the directory itself.
+
+## Why every URL is a directory
+
+Pages are written as `<path>/index.html`, never `<path>.html`. A host with
+rewrite rules can serve `/about` from `about.html`, but a host without them
+cannot, and sqzass is built to be correct on the host that gives you nothing.
+The directory form works everywhere — GitHub Pages, Cloudflare Pages, S3, or
+`python3 -m http.server` — because it asks for no cleverness from the server.
+
+The cost is that a link to `/about` (no trailing slash) gets redirected by most
+servers before it resolves. Link to `/about/`, or better, use
+[`@/` links](@/content/links.md) and let sqzass write the URL.
+
+## Slugs come from filenames
+
+`install.md` becomes `/guide/install/`. The title has nothing to do with it.
+
+This matters most in Korean. Transliterating a title would be the usual trick,
+but `slug`-style crates map Hangul to ASCII one syllable at a time and without
+context, so two different Korean titles can collapse onto the same path. A
+filename you chose is unambiguous and it is already unique in its directory.
+A Korean filename is percent-encoded UTF-8 and left alone.
+
+Override it per page with `slug` in front matter. **Two pages claiming the same
+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.
+
+## Drafts
+
+`draft = true` keeps a page out of the build. `--drafts` on the command line, or
+`[build] drafts = true` in the config, puts them back.
diff --git a/docs/content/content/front-matter.ko.md b/docs/content/content/front-matter.ko.md
@@ -0,0 +1,70 @@
++++
+title = "front matter"
+description = "페이지가 가질 수 있는 모든 필드와 그 역할"
+weight = 10
+toc = true
++++
+
+front matter는 파일 맨 위 `+++` 펜스 사이에 쓰는 TOML입니다. 형식은 TOML 하나뿐인데,
+YAML을 쓰려면 파서를 골라야 하고 러스트의 대표 크레이트가 이미 아카이브된 상태라
+선택지가 "관리되지 않는 의존성"과 "의존성이 필요 없는 형식" 둘이었기 때문입니다.
+
+```toml
++++
+title = "설치"
++++
+```
+
+반드시 있어야 하는 필드는 `title` 하나입니다.
+
+## 필드
+
+| 필드 | 타입 | 기본값 | |
+|---|---|---|---|
+| `title` | 문자열 | — | **필수.** |
+| `description` | 문자열 | `""` | 템플릿의 `<meta name="description">`과 검색 결과에 쓰입니다. |
+| `weight` | 정수 | `0` | 섹션 안에서의 정렬 순서. 작을수록 앞. |
+| `draft` | bool | `false` | `--drafts`가 없으면 빌드에서 빠집니다. |
+| `slug` | 문자열 | 파일명 stem | URL의 마지막 세그먼트. |
+| `template` | 문자열 | — | 평소 쓰던 것 대신 이 템플릿으로 렌더합니다. |
+| `toc` | bool | `false` | 목차를 보여줄지 여부. |
+| `translation_key` | 문자열 | 파일명 stem | 이 페이지와 번역본을 연결합니다. |
+| `extra` | 테이블 | `{}` | 무엇이든. 템플릿에 `page.extra`로 갑니다. |
+
+`toc`는 데이터가 아니라 **저자의 의사**입니다. 목차 자체는 모든 페이지에서 수집해
+`page.toc_entries`로 템플릿에 넘어갑니다. 이렇게 나눠 두면 긴 글에만 목차를 띄우는
+템플릿을 만들 때 데이터를 따로 걷어낼 필요가 없습니다.
+
+## 섹션 전용 필드
+
+일반 페이지에서는 아무 일도 하지 않습니다. `_index.md`에 씁니다.
+
+| 필드 | 타입 | 기본값 | |
+|---|---|---|---|
+| `sort_by` | `"weight"` \| `"title"` | 사이트 기본값 | 이 섹션이 자식 페이지를 정렬하는 기준. |
+| `page_template` | 문자열 | — | 이 섹션 페이지들의 기본 템플릿. |
+
+## extra
+
+`[extra]`는 열린 테이블입니다. sqzass는 여기를 읽지 않고, 템플릿이 읽습니다.
+
+```toml
++++
+title = "릴리스 노트"
+[extra]
+version = "0.2.0"
+badge = "beta"
++++
+```
+
+```html
+{% if page.extra.badge %}<span class="badge">{{ page.extra.badge }}</span>{% endif %}
+```
+
+없는 키를 읽으면 빈 문자열이 아니라 에러입니다. 얼마나 엄격한지와 그 이유는
+[템플릿 데이터](@/templates/data.md)에 적어 두었습니다.
+
+## 에러는 당신의 파일을 가리킵니다
+
+값이 잘못되면 소스 파일의 줄 번호로 보고합니다. front matter가 끝난 지점부터가
+아니라 **파일 맨 위부터** 센 줄 번호입니다. 후자는 엉뚱한 줄을 가리키니까요.
diff --git a/docs/content/content/front-matter.md b/docs/content/content/front-matter.md
@@ -0,0 +1,73 @@
++++
+title = "Front matter"
+description = "Every field a page can carry, and what it does"
+weight = 10
+toc = true
++++
+
+Front matter is TOML between `+++` fences at the top of the file. TOML is the
+only format — YAML would mean picking a parser, and the obvious Rust one has
+been archived, so the choice was between an unmaintained dependency and a
+format that needs none.
+
+```toml
++++
+title = "Installation"
++++
+```
+
+`title` is the only required field.
+
+## Fields
+
+| Field | Type | Default | |
+|---|---|---|---|
+| `title` | string | — | **Required.** |
+| `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`. |
+| `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. |
+| `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
+for every page regardless, and templates get it as `page.toc_entries`. That
+split lets a template show a contents list on long pages only, without you
+having to strip the data out.
+
+## Section-only fields
+
+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. |
+| `page_template` | string | — | Default template for pages in this section. |
+
+## Extra
+
+`[extra]` is an open table. Nothing in sqzass reads it; templates do.
+
+```toml
++++
+title = "Release notes"
+[extra]
+version = "0.2.0"
+badge = "beta"
++++
+```
+
+```html
+{% if page.extra.badge %}<span class="badge">{{ page.extra.badge }}</span>{% endif %}
+```
+
+Reading a key that does not exist is an error, not an empty string — see
+[Template data](@/templates/data.md) for how strict that is and why.
+
+## Errors point at your file
+
+A malformed value is reported with the line number in the source file, counted
+from the top of the file rather than from the end of the front matter, because
+the second one sends you to the wrong line.
diff --git a/docs/content/content/languages.ko.md b/docs/content/content/languages.ko.md
@@ -0,0 +1,87 @@
++++
+title = "언어"
+description = "하나의 콘텐츠 트리로 두 언어를, 미번역 페이지는 감춘 채"
+weight = 30
+toc = true
++++
+
+언어는 `sqzass.toml`에 선언합니다. 기본 언어가 루트를 쓰고, 나머지는 접두사를 갖습니다.
+
+```toml
+default_language = "en"
+
+[languages.en]
+name   = "English"
+weight = 1
+
+[languages.ko]
+name   = "한국어"
+weight = 2
+```
+
+`/start/`가 영어, `/ko/start/`가 한국어입니다.
+
+## 파일명 접미사
+
+```
+content/start/
+├── installation.md        →  /start/installation/
+└── installation.ko.md     →  /ko/start/installation/
+```
+
+두 파일이 나란히 놓이므로 `ls` 한 번이면 무엇이 아직 번역되지 않았는지 보입니다.
+`content.ko/` 같은 병렬 트리를 두면 그게 diff 뒤로 숨습니다.
+
+## 미번역 페이지는 복제하지 않고 감춥니다
+
+한국어판이 없는 페이지는 한국어 내비게이션에 아예 나타나지 않습니다. 나머지 두
+선택지는 둘 다 더 나쁩니다. 한국어 URL에 영어 본문을 렌더하면 검색 엔진 입장에서
+중복 콘텐츠가 되고, 404를 내면 사이트가 스스로 그린 링크로 독자를 막다른 길에
+보내는 셈입니다.
+
+템플릿에는 `page.translations`가 넘어가는데, 여기엔 이 페이지가 **실제로 존재하는**
+언어만 담깁니다. 그래서 언어 전환 UI는 동작하는 선택지만 그릴 수 있습니다.
+
+```html
+{% for t in page.translations %}
+<a href="{{ t.url }}" hreflang="{{ t.code }}">{{ t.name }}</a>
+{% endfor %}
+```
+
+목록이 비면 전환 버튼도 없습니다. 버튼이 거짓말을 하는 상태가 생기지 않습니다.
+
+## 번역을 짝짓는 방법
+
+파일명 stem으로 짝짓습니다. `installation.md`와 `installation.ko.md`는 한 페이지의
+두 언어판입니다. 파일명이 달라야 할 때 — 슬러그를 현지화하는 경우 등 — 는 양쪽
+파일에 같은 `translation_key`를 주면 됩니다.
+
+```toml
+# content/start/installation.md
++++
+title = "Installation"
+translation_key = "install"
++++
+```
+
+```toml
+# content/start/설치.ko.md
++++
+title = "설치"
+translation_key = "install"
++++
+```
+
+## 한국어에서 알아 둘 것
+
+알아서 처리되지만 알고는 있어야 하는 게 둘 있습니다. 둘 다 어긋났을 때 조용하기
+때문입니다.
+
+**`**강조**한다`가 강조로 파싱됩니다.** CommonMark의 flanking 규칙은 단어 사이에
+공백을 두는 언어를 전제로 쓰였고, 그 규칙대로면 `**굵게**` 바로 뒤에 조사가 붙은
+형태는 강조가 아예 아닙니다. sqzass는 comrak의 `cjk_friendly_emphasis`를 켜 두기
+때문에 한국어로 자연스럽게 쓴 마크다운이 그대로 동작합니다. `[markdown]`의 키이며,
+이걸 끄면 한국어 본문이 "내 마크다운이 틀렸나" 싶은 모양으로 깨집니다.
+
+**한국어 제목은 한글 id를 유지합니다.** `## 설치`는 `id="설치"`가 되므로 로마자
+변환 없이 앵커와 목차가 동작합니다.
diff --git a/docs/content/content/languages.md b/docs/content/content/languages.md
@@ -0,0 +1,90 @@
++++
+title = "Languages"
+description = "Two languages from one content tree, with untranslated pages hidden"
+weight = 30
+toc = true
++++
+
+Declare the languages in `sqzass.toml`. The default one lives at the root;
+every other one gets a prefix.
+
+```toml
+default_language = "en"
+
+[languages.en]
+name   = "English"
+weight = 1
+
+[languages.ko]
+name   = "한국어"
+weight = 2
+```
+
+`/start/` is English. `/ko/start/` is Korean.
+
+## A suffix on the filename
+
+```
+content/start/
+├── installation.md        →  /start/installation/
+└── installation.ko.md     →  /ko/start/installation/
+```
+
+The two files sit next to each other, which means `ls` tells you what is not
+translated yet. A parallel `content.ko/` tree would hide that behind a diff.
+
+## Untranslated pages are hidden, not duplicated
+
+A page with no Korean version does not appear in the Korean navigation at all.
+The two alternatives are both worse: rendering the English text at a Korean URL
+creates duplicate content for search engines, and emitting a 404 sends a reader
+to a dead end from a link the site itself drew.
+
+Templates see `page.translations`, which contains only the languages this page
+actually exists in — so a language switcher can render exactly the choices that
+work:
+
+```html
+{% for t in page.translations %}
+<a href="{{ t.url }}" hreflang="{{ t.code }}">{{ t.name }}</a>
+{% endfor %}
+```
+
+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 —
+set `translation_key` in both files to the same value.
+
+```toml
+# content/start/installation.md
++++
+title = "Installation"
+translation_key = "install"
++++
+```
+
+```toml
+# content/start/설치.ko.md
++++
+title = "설치"
+translation_key = "install"
++++
+```
+
+## Korean specifics
+
+Two things are handled for you and are worth knowing about, because both are
+silent when they go wrong.
+
+**`**강조**한다` parses as emphasis.** CommonMark's flanking rules were written
+for languages that put spaces around words, and under them a `**bold**` run
+immediately followed by a Korean particle is not emphasis at all. sqzass turns
+on comrak's `cjk_friendly_emphasis`, which is why the markdown you would
+naturally write works. It is a `[markdown]` key, and turning it off breaks
+Korean text in a way that looks like your markdown is wrong.
+
+**Korean headings keep their Hangul ids.** `## 설치` becomes `id="설치"`, so
+anchors and the table of contents work without transliteration.
diff --git a/docs/content/content/links.ko.md b/docs/content/content/links.ko.md
@@ -0,0 +1,53 @@
++++
+title = "내부 링크"
+description = "소스 파일을 가리키면 URL은 빌드가 씁니다"
+weight = 20
+toc = true
++++
+
+링크를 **파일**로 쓰면 sqzass가 그 페이지의 URL로 바꿔 줍니다.
+
+```markdown
+[설치](@/start/installation.md) 문서를 보세요.
+```
+
+`@/` 뒤의 경로는 `content/` 기준이고, URL이 아니라 마크다운 파일을 가리킵니다.
+파일을 옮기든 이름을 바꾸든 `slug`를 바꾸든 링크가 따라옵니다.
+
+## 깨진 링크는 빌드를 멈춥니다
+
+아무것도 가리키지 못하는 `@/` 링크는 에러입니다.
+
+```
+docs/content/start/first-site.md: 해석할 수 없는 내부 링크가 있습니다:
+  @/start/setup.md
+```
+
+이게 이 문법의 존재 이유입니다. 아무 데도 안 가는 `/start/setup/` 링크는 누군가
+프로덕션에서 눌러 보기 전까지 멀쩡한 링크와 구분되지 않습니다. 빌드가 검사할 수
+있는 참조라면 빌드가 **검사합니다**.
+
+## 읽는 사람의 언어를 따라갑니다
+
+`@/start/installation.md`는 영어 독자에게는 `/start/installation/`으로,
+한국어 독자에게는 `/ko/start/installation/`으로 풀립니다. 같은 마크다운이 양쪽
+언어 트리에서, 조건문 하나 없이 동작합니다.
+
+대상 페이지에 해당 언어 번역이 없으면 링크가 깨지는 대신 기본 언어로 넘어갑니다.
+[언어](@/content/languages.md)를 참고하세요.
+
+그 폴백이 있기 때문에 경로는 한 번만 쓰고, 언어 접두사는 절대 붙이지 않습니다.
+`@/ko/start/installation.md` 같은 건 없습니다.
+
+## 트리에서 일어납니다
+
+재작성은 comrak의 URL 재작성기를 통해 HTML이 만들어지기 **전에**, AST 단계에서
+일어납니다. 흔한 지름길인 "완성된 HTML에 정규식 돌리기"는 속성을 홑따옴표로 쓰거나
+따옴표를 빼면 조용히 처리에서 빠집니다. 빌드가 아니라 프로덕션에서 발견되는 종류의
+버그입니다.
+
+이미지 경로도 같은 길을 지나므로 `![](@/images/x.png)`도 동작합니다.
+
+## 나머지는 건드리지 않습니다
+
+외부 링크, 앵커, `mailto:` — 그대로 둡니다. sqzass가 가져가는 건 `@/` 접두사뿐입니다.
diff --git a/docs/content/content/links.md b/docs/content/content/links.md
@@ -0,0 +1,57 @@
++++
+title = "Internal links"
+description = "Link to a source file and let the build write the URL"
+weight = 20
+toc = true
++++
+
+Write a link to the *file* and sqzass turns it into that page's URL:
+
+```markdown
+See [Installation](@/start/installation.md).
+```
+
+The path after `@/` is relative to `content/`, and it points at the markdown
+file, not the URL. Move the file, rename it, or change its `slug`, and the link
+follows.
+
+## Broken links stop the build
+
+An `@/` link that resolves to nothing is an error:
+
+```
+docs/content/start/first-site.md: 해석할 수 없는 내부 링크가 있습니다:
+  @/start/setup.md
+```
+
+This is the point of the syntax. A plain `/start/setup/` link that goes nowhere
+is indistinguishable from one that works until somebody clicks it in
+production. A reference the build can check is a reference the build *does*
+check.
+
+## They follow the reader's language
+
+`@/start/installation.md` resolves to `/start/installation/` for an English
+reader and `/ko/start/installation/` for a Korean one — the same markdown, in
+both language trees, without a single conditional.
+
+If the target has no translation in the current language, the link falls back
+to the default language rather than breaking. See
+[Languages](@/content/languages.md).
+
+Because of that fallback, you write the path once and never write it with a
+language prefix. `@/ko/start/installation.md` is not a thing.
+
+## It happens on the tree
+
+Rewriting is done through comrak's URL rewriter, on the AST, before any HTML
+exists. The shortcut — running a regex over the finished HTML — silently skips
+any element whose attributes are single-quoted or unquoted, which produces a
+class of bug you find in production rather than in the build.
+
+Image sources go through the same path, so `![](@/images/x.png)` works too.
+
+## Everything else is left alone
+
+External links, anchors, `mailto:` — untouched. sqzass only claims the `@/`
+prefix.
diff --git a/docs/content/content/markdown.ko.md b/docs/content/content/markdown.ko.md
@@ -0,0 +1,91 @@
++++
+title = "마크다운"
+description = "기본으로 켜져 있는 확장과, 끌 수 있는 것들"
+weight = 40
+toc = true
++++
+
+[comrak]을 통한 CommonMark이고, 확장 몇 가지가 기본으로 켜져 있습니다. 각각이
+`sqzass.toml`의 `[markdown]` 아래 키입니다.
+
+```toml
+[markdown]
+footnotes             = true
+tables                = true
+tasklist              = true
+strikethrough         = true
+autolink              = true
+alerts                = true
+cjk_friendly_emphasis = true
+heading_anchors       = "right"   # none | left | right
+```
+
+## 알림 상자
+
+인용문 위에 얹힌 GitHub의 콜아웃 문법입니다.
+
+```markdown
+> [!NOTE]
+> 미리 빌드된 바이너리는 계획에 있지만 아직 배포하지 않았습니다.
+```
+
+> [!NOTE]
+> 미리 빌드된 바이너리는 계획에 있지만 아직 배포하지 않았습니다.
+
+`NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`을 인식합니다.
+
+## 표
+
+```markdown
+| 키 | 기본값 |
+|---|---|
+| `output_dir` | `public` |
+```
+
+| 키 | 기본값 |
+|---|---|
+| `output_dir` | `public` |
+
+## 체크리스트
+
+```markdown
+- [x] 검색
+- [ ] 피드
+```
+
+- [x] 검색
+- [ ] 피드
+
+## 각주
+
+각주가 붙은 문장입니다[^1].
+
+[^1]: 각주 본문입니다.
+
+```markdown
+각주가 붙은 문장입니다[^1].
+
+[^1]: 각주 본문입니다.
+```
+
+## 제목
+
+모든 제목에 `id`가 붙습니다. anchor를 보여주든 말든 붙는데, 목차와 당신이 남에게
+건네는 딥링크가 둘 다 여기에 기대기 때문입니다. 같은 제목이 반복되면 `-1`, `-2`가
+붙고, anchor와 목차 항목은 반드시 일치합니다. 한 번의 순회에서 같은 카운터로
+만들어지니까요.
+
+`heading_anchors`는 눈에 보이는 `#` 링크를 정합니다. `"right"`(기본), `"left"`,
+`"none"` 중 하나입니다.
+
+## 원시 HTML
+
+그대로 통과시킵니다. 여기 콘텐츠는 신뢰된 것입니다 — 당신의 저장소에, 당신이 쓰고,
+코드와 같은 커밋에서 리뷰됩니다. 이걸 새니타이즈하는 건 시늉일 뿐입니다.
+
+## 코드
+
+빌드 시점에 CSS 클래스로 강조됩니다. [구문 강조](@/features/highlighting.md)를
+참고하세요.
+
+[comrak]: https://github.com/kivikakk/comrak
diff --git a/docs/content/content/markdown.md b/docs/content/content/markdown.md
@@ -0,0 +1,92 @@
++++
+title = "Markdown"
+description = "The extensions that are on, and the ones you can turn off"
+weight = 40
+toc = true
++++
+
+CommonMark, via [comrak], with a set of extensions on by default. Each is a key
+under `[markdown]` in `sqzass.toml`.
+
+```toml
+[markdown]
+footnotes             = true
+tables                = true
+tasklist              = true
+strikethrough         = true
+autolink              = true
+alerts                = true
+cjk_friendly_emphasis = true
+heading_anchors       = "right"   # none | left | right
+```
+
+## Alerts
+
+GitHub's callout syntax, built on blockquotes:
+
+```markdown
+> [!NOTE]
+> Prebuilt binaries are planned but not published yet.
+```
+
+> [!NOTE]
+> Prebuilt binaries are planned but not published yet.
+
+`NOTE`, `TIP`, `IMPORTANT`, `WARNING` and `CAUTION` are recognised.
+
+## Tables
+
+```markdown
+| Key | Default |
+|---|---|
+| `output_dir` | `public` |
+```
+
+| Key | Default |
+|---|---|
+| `output_dir` | `public` |
+
+## Task lists
+
+```markdown
+- [x] Search
+- [ ] Feeds
+```
+
+- [x] Search
+- [ ] Feeds
+
+## Footnotes
+
+Text with a note[^1].
+
+[^1]: The note itself.
+
+```markdown
+Text with a note[^1].
+
+[^1]: The note itself.
+```
+
+## Headings
+
+Every heading gets an `id`, whether or not anchors are shown, because the table
+of contents and any deep link you hand out both depend on it. Repeated headings
+get `-1`, `-2` suffixes, and the anchor and the contents entry are guaranteed to
+agree — they come from the same counter, in one pass.
+
+`heading_anchors` controls the visible `#` link: `"right"` (default), `"left"`,
+or `"none"`.
+
+## Raw HTML
+
+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.
+
+## Code
+
+Highlighted at build time into CSS classes. See
+[Syntax highlighting](@/features/highlighting.md).
+
+[comrak]: https://github.com/kivikakk/comrak
diff --git a/docs/content/deploy/_index.ko.md b/docs/content/deploy/_index.ko.md
@@ -0,0 +1,39 @@
++++
+title = "배포"
+description = "결과물은 디렉터리입니다. 파일을 서빙하는 호스트면 어디든 됩니다."
+weight = 50
+sort_by = "weight"
++++
+
+```bash
+sqzass build -i mysite
+```
+
+`mysite/public`이 사이트입니다. 파일을 서빙하는 곳에 복사하면 끝입니다. 런타임도,
+서버 컴포넌트도, 남아 있는 빌드 단계도 없습니다.
+
+## 호스트가 하지 않아도 되도록 sqzass가 하는 일
+
+모든 URL이 `index.html`을 담은 디렉터리라서 예쁜 URL에 rewrite 규칙이 필요 없습니다.
+캐시 무효화가 파일명에 들어 있어서 캐시 헤더가 필요 없습니다. 출력물 중 어느 것도
+호스트 설정에 기대지 않습니다.
+
+의도한 것이고, 이 문서 사이트를 [GitHub Pages](@/deploy/github-pages.md)에 올려 둔
+것도 그걸 정직하게 지키기 위해서입니다. Pages에는 커스텀 헤더도, 리다이렉트 규칙도,
+rewrite도 없으므로, 그런 것에 기대는 게 생기면 여기서 가장 먼저 깨집니다.
+
+보상은 호스트를 옮기는 일이 복사가 된다는 점입니다. 같은 디렉터리가 Cloudflare Pages
+에서도, Netlify에서도, CloudFront 뒤의 S3에서도, nginx에서도 그대로 맞습니다.
+
+## 알아 둘 파일 둘
+
+`.nojekyll`은 빌드마다 출력에 들어갑니다. 없으면 GitHub Pages가 출력을 Jekyll로 한 번
+더 굴려서 `_`로 시작하는 디렉터리를 통째로 삼킵니다.
+
+`CNAME`이 필요하면 `static/`에 두면 이름 그대로 복사됩니다. 해시가 붙은 `CNAME`은
+GitHub이 영영 찾지 않을 파일입니다.
+
+## 결정성
+
+같은 입력을 두 번 빌드하면 바이트까지 같은 출력이 나옵니다. 그래서 이 빌드를 CI에서
+검사로 돌려도 되고, 바뀐 게 없는 배포는 아무것도 올리지 않습니다.
diff --git a/docs/content/deploy/_index.md b/docs/content/deploy/_index.md
@@ -0,0 +1,41 @@
++++
+title = "Deploying"
+description = "The output is a directory. Any host that serves files will do."
+weight = 50
+sort_by = "weight"
++++
+
+```bash
+sqzass build -i mysite
+```
+
+`mysite/public` is the site. Copy it somewhere that serves files and you are
+done — there is no runtime, no server component and no build step left to run.
+
+## What sqzass does so the host does not have to
+
+Every URL is a directory containing `index.html`, so pretty URLs need no
+rewrite rules. Cache busting is in the filename, so it needs no cache headers.
+Nothing in the output depends on host configuration.
+
+This is deliberate, and the docs site is hosted on
+[GitHub Pages](@/deploy/github-pages.md) to keep it honest: Pages offers no
+custom headers, no redirect rules and no rewrites, so anything that needed them
+would break here first.
+
+The payoff is that moving hosts is a copy. The same directory is correct on
+Cloudflare Pages, Netlify, S3 behind CloudFront, or nginx.
+
+## Two files worth knowing about
+
+`.nojekyll` is written into every build. Without it GitHub Pages runs the
+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.
+
+## Determinism
+
+Two builds of the same input produce byte-identical output. That makes the
+build safe to run in CI as a check, and it means a deploy that changes nothing
+uploads nothing.
diff --git a/docs/content/deploy/github-pages.ko.md b/docs/content/deploy/github-pages.ko.md
@@ -0,0 +1,89 @@
++++
+title = "GitHub Pages"
+description = "이 사이트가 배포되는 워크플로"
+weight = 10
+toc = true
++++
+
+이 사이트는 도구와 같은 저장소에서 아래 워크플로로 빌드·배포됩니다. CI가 방금
+컴파일한 바이너리로 `docs/`를 빌드하므로, 이 문서가 곧 생성기의 회귀 테스트가 됩니다.
+
+## 워크플로
+
+```yaml
+name: Deploy docs
+
+on:
+  push:
+    branches: [main]
+
+permissions:
+  contents: read
+  pages: write
+  id-token: write
+
+concurrency:
+  group: pages
+  cancel-in-progress: false
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+      - uses: dtolnay/rust-toolchain@stable
+      - uses: Swatinem/rust-cache@v2
+      - uses: actions/configure-pages@v6
+      - run: cargo run --quiet -- build -i docs
+      - uses: actions/upload-pages-artifact@v5
+        with:
+          path: docs/public
+
+  deploy:
+    needs: build
+    runs-on: ubuntu-latest
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    steps:
+      - id: deployment
+        uses: actions/deploy-pages@v5
+```
+
+`cancel-in-progress: false`는 그대로 두는 게 좋습니다. 진행 중인 배포를 취소하면
+사이트가 반쯤 갱신된 상태로 남는데, 그건 기다리는 것보다 나쁩니다.
+
+## 커스텀 도메인
+
+도메인을 `static/CNAME`에 넣습니다.
+
+```
+sqzass.sqzer.com
+```
+
+이름 그대로 출력에 복사되고 GitHub이 거기서 읽습니다. 그래서 배포할 때마다 저장소
+설정에서 도메인을 다시 넣지 않아도 살아남습니다.
+
+DNS는 `CNAME` 레코드로 `<사용자>.github.io`를 가리키게 합니다. DNS 제공자가 트래픽을
+프록시한다면 **이 레코드에서는 프록시를 끄세요.** 프록시가 TLS를 자기가 종료하면
+GitHub이 인증서를 발급·갱신하지 못하는데, 이 실패는 배포가 깨지는 형태로 바로
+드러나지 않고 몇 주 뒤 인증서 만료로 나타납니다.
+
+## 확인하기
+
+```bash
+curl -sI https://example.com/ | head -1          # HTTPS로 200
+curl -sI https://example.com/nonexistent/        # 200이 아니라 404
+```
+
+두 번째가 중요합니다. 없는 페이지에 200을 돌려주는 호스트에서는 검색 엔진이 당신의
+404 페이지를 색인하게 됩니다.
+
+## 감수하는 것
+
+커스텀 헤더가 없습니다. GitHub Pages는 전부 `max-age=600`으로 내보냅니다. 리다이렉트
+규칙도 없습니다. 풀 리퀘스트 프리뷰 배포도 없습니다.
+
+앞의 둘은 출력물을 지금처럼 만든 이유 그 자체이고, 세 번째는 `sqzass serve`가
+대신합니다. 나중에 정말로 헤더 제어가 필요해지면, 답은 헤더가 없는 호스트 앞에 CDN을
+얹는 게 아니라 헤더가 있는 호스트로 옮기는 것입니다.
diff --git a/docs/content/deploy/github-pages.md b/docs/content/deploy/github-pages.md
@@ -0,0 +1,92 @@
++++
+title = "GitHub Pages"
+description = "The workflow this site is deployed with"
+weight = 10
+toc = true
++++
+
+This site is built and deployed by the workflow below, from the same repository
+as the tool. CI builds `docs/` with the binary it just compiled, so the
+documentation is a regression test for the generator.
+
+## The workflow
+
+```yaml
+name: Deploy docs
+
+on:
+  push:
+    branches: [main]
+
+permissions:
+  contents: read
+  pages: write
+  id-token: write
+
+concurrency:
+  group: pages
+  cancel-in-progress: false
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+      - uses: dtolnay/rust-toolchain@stable
+      - uses: Swatinem/rust-cache@v2
+      - uses: actions/configure-pages@v6
+      - run: cargo run --quiet -- build -i docs
+      - uses: actions/upload-pages-artifact@v5
+        with:
+          path: docs/public
+
+  deploy:
+    needs: build
+    runs-on: ubuntu-latest
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    steps:
+      - id: deployment
+        uses: actions/deploy-pages@v5
+```
+
+`cancel-in-progress: false` is worth keeping. Cancelling a deploy that is
+halfway through leaves the site partly updated, which is worse than waiting.
+
+## A custom domain
+
+Put the domain in `static/CNAME`:
+
+```
+sqzass.sqzer.com
+```
+
+It is copied to the output with its name intact, and GitHub reads it from
+there — so the domain survives every deploy without being configured in the
+repository settings again.
+
+Point DNS at `<user>.github.io` with a `CNAME` record. If your DNS provider
+proxies traffic, **turn the proxy off for this record**. A proxy that
+terminates TLS itself will stop GitHub from issuing and renewing the
+certificate, and the failure shows up weeks later as an expired certificate
+rather than immediately as a broken deploy.
+
+## Checking it
+
+```bash
+curl -sI https://example.com/ | head -1          # 200, over HTTPS
+curl -sI https://example.com/nonexistent/        # 404, not 200
+```
+
+The second one matters. A host that answers 200 for a missing page will have
+search engines index your 404.
+
+## What you give up
+
+No custom headers — GitHub Pages serves everything with `max-age=600`. No
+redirect rules. No preview deployments for pull requests.
+
+The first two are the reason the output is built the way it is, and the third
+is `sqzass serve`. If you later need real header control, the answer is moving
+to a host that has it, not stacking a CDN in front of one that does not.
diff --git a/docs/content/features/_index.ko.md b/docs/content/features/_index.ko.md
@@ -0,0 +1,12 @@
++++
+title = "기능"
+description = "구문 강조, 검색, 개발 서버"
+weight = 40
+sort_by = "weight"
++++
+
+마크다운을 HTML로 바꾸는 것 너머에서 sqzass가 하는 일들입니다.
+
+여기 있는 것들은 전부 기본으로 켜져 있고, 설정이 있다면 `sqzass.toml`에서 합니다.
+플러그인 시스템도 테마 시스템도 없습니다. 아래 것들은 이 도구에 붙는 확장이 아니라
+이 도구 자체입니다.
diff --git a/docs/content/features/_index.md b/docs/content/features/_index.md
@@ -0,0 +1,12 @@
++++
+title = "Features"
+description = "Highlighting, search, and the development server"
+weight = 40
+sort_by = "weight"
++++
+
+What sqzass does beyond turning markdown into HTML.
+
+Everything here is on by default and configured, if at all, from `sqzass.toml`.
+There is no plugin system and no theme system — the things below are the tool,
+not extensions to it.
diff --git a/docs/content/features/dev-server.ko.md b/docs/content/features/dev-server.ko.md
@@ -0,0 +1,50 @@
++++
+title = "개발 서버"
+description = "메모리에서 서빙하고, 바뀌면 다시 빌드하고, 보던 자리를 잃지 않고 새로고침합니다"
+weight = 30
+toc = true
++++
+
+```bash
+sqzass serve -i docs
+```
+
+<http://127.0.0.1:3000>에 뜨고, 사이트 루트 아래 파일이 바뀔 때마다 다시 빌드합니다.
+
+| 플래그 | | |
+|---|---|---|
+| `-i`, `--input` | `.` | 사이트 루트. |
+| `-b`, `--bind` | `127.0.0.1` | 바인드 주소. |
+| `-p`, `--port` | `3000` | 포트. |
+| `--drafts` | | 드래프트 페이지도 포함. |
+| `--base-url` | | `base_url` 덮어쓰기. |
+
+## 디스크에는 아무것도 쓰지 않습니다
+
+빌드 결과가 메모리로 들어가고 거기서 서빙됩니다. 서버가 도는 동안 `public/`은
+건드리지 않습니다.
+
+최적화가 아닙니다. 리빌드 중에 브라우저가 파일을 요청하면 그 시점까지 쓰인 바이트가
+그대로 나가고, 그렇게 나온 반쪽짜리 페이지는 "내 코드 문제가 아니었구나"를 깨닫기까지
+한 시간을 쓰게 되는 종류의 버그입니다. 완성된 빌드를 서빙하거나 아예 서빙하지 않으면
+그 창이 통째로 사라집니다.
+
+## 새로고침
+
+리로드 스크립트는 빌드 산출물에 쓰이는 게 아니라 **서빙하는 시점에** 주입됩니다.
+그래서 출력물은 프로덕션 빌드와 바이트 단위로 동일하게 유지됩니다.
+
+CSS만 바뀐 변경은 페이지를 새로고침하는 대신 스타일시트의 `href`를 그 자리에서
+갈아 끼웁니다. 페이지가 움직이지 않으니 여백을 조금씩 밀어 보는 동안 스크롤 위치를
+잃지 않습니다. 그 밖의 변경은 페이지를 새로고침합니다.
+
+## 빌드가 실패하면
+
+보고 있지도 않을 터미널에만 찍는 대신 브라우저에 에러를 띄우고, 그 아래에는 마지막
+성공 버전이 계속 서빙됩니다. 파일을 고쳐 저장하면 오버레이가 사라집니다.
+
+## 프로덕션 서버가 아닙니다
+
+캐싱도, 압축도, 접근 제어도, TLS도 없습니다. 그래서 기본 바인드가 localhost입니다.
+`--bind 0.0.0.0`을 주면 같은 네트워크의 휴대폰에서 사이트를 볼 수 있고, 유용하며,
+딱 거기까지입니다.
diff --git a/docs/content/features/dev-server.md b/docs/content/features/dev-server.md
@@ -0,0 +1,52 @@
++++
+title = "Development server"
+description = "Serves from memory, rebuilds on change, and reloads without losing your place"
+weight = 30
+toc = true
++++
+
+```bash
+sqzass serve -i docs
+```
+
+<http://127.0.0.1:3000>, rebuilding whenever a file under the site root changes.
+
+| Flag | | |
+|---|---|---|
+| `-i`, `--input` | `.` | Site root. |
+| `-b`, `--bind` | `127.0.0.1` | Bind address. |
+| `-p`, `--port` | `3000` | Port. |
+| `--drafts` | | Include draft pages. |
+| `--base-url` | | Override `base_url`. |
+
+## Nothing is written to disk
+
+The build goes into memory and is served from there. `public/` is not touched
+while the server runs.
+
+That is not an optimisation. A browser that requests a file mid-rebuild would
+otherwise get whatever bytes had been written so far, and the resulting
+half-page is the kind of bug you chase for an hour before realising it was not
+your code. Serving a build that is complete or not served at all removes the
+window entirely.
+
+## Reloading
+
+The reload script is injected as the page is served, not written into the
+build, so the output stays byte-identical to what a production build produces.
+
+A change that touched only CSS swaps the stylesheet's `href` in place instead
+of reloading — the page does not move, and you keep your scroll position while
+you nudge a margin. Anything else reloads the page.
+
+## When a build fails
+
+The error is shown in the browser rather than only in the terminal you may not
+be looking at, and the last good version stays served underneath. Fix the file,
+save, and the overlay goes away.
+
+## It is not a production server
+
+No caching, no compression, no access control, no TLS. It binds to localhost by
+default for that reason. `--bind 0.0.0.0` will let a phone on your network see
+the site, which is useful and is as far as it should go.
diff --git a/docs/content/features/highlighting.ko.md b/docs/content/features/highlighting.ko.md
@@ -0,0 +1,59 @@
++++
+title = "구문 강조"
+description = "빌드 시점에, 클래스로, 두 테마를 함께"
+weight = 10
+toc = true
++++
+
+코드 블록은 사이트를 빌드하는 동안 강조됩니다. 색을 입히려고 독자의 브라우저에서
+JavaScript가 도는 일이 없으므로, 페이지는 첫 페인트부터 색이 입혀져 있습니다.
+
+```toml
+[highlight]
+enabled     = true
+theme_light = "InspiredGitHub"
+theme_dark  = "base16-ocean.dark"
+```
+
+## 인라인 스타일이 아니라 클래스
+
+강조된 블록은 이렇게 생겼습니다.
+
+```html
+<code class="language-rust"><span class="hl-source hl-rust">…</span></code>
+```
+
+`style="color:#268bd2"`가 아닙니다. 이 차이가 세 가지를 결정합니다.
+
+**다크 모드가 가능해집니다.** 인라인 색상은 지금까지 만든 모든 문서에 테마 하나를
+박아 넣습니다. 테마를 바꾸려면 사이트를 다시 빌드해야 하고, 독자의 설정을 따르려면
+두 테마를 마크업 안에 같이 실어 보내야 합니다. 클래스라면 두 테마는 CSS 두 덩어리일
+뿐이고, 전환은 CSS 전환입니다.
+
+**엄격한 `style-src`를 쓸 수 있습니다.** 인라인 스타일을 금지하는 CSP는 HTML이
+인라인 스타일로 가득 찬 순간 선택지에서 사라집니다.
+
+**스타일시트가 파일 하나입니다.** 색 하나를 바꾸면 HTML 페이지를 하나도 다시 만들지
+않고 모든 페이지가 바뀝니다.
+
+## 두 테마, 하나의 스타일시트
+
+`theme_light`과 `theme_dark`이 둘 다 생성된 스타일시트에 들어갑니다. 다크 규칙은
+두 번 나갑니다. 토글을 쓰지 않는 독자를 위해 `prefers-color-scheme: dark` 아래
+한 번, 이 사이트처럼 전환 버튼을 제공하는 경우를 위해 `[data-theme="dark"]` 아래
+한 번입니다.
+
+syntect 기본 세트의 테마 이름이면 무엇이든 됩니다. 없는 이름을 쓰면 빌드 에러이고,
+메시지에 쓸 수 있는 이름들이 같이 나옵니다.
+
+## 접두사
+
+클래스에는 `hl-` 접두사가 붙습니다. 접두사가 없으면 syntect는 `source`, `keyword`,
+`string` 같은 이름을 내보내는데, 프로그래밍을 다루는 사이트에서 당신의 스타일시트와
+부딪히기 딱 좋은 일반적인 단어들입니다.
+
+## 끄기
+
+`enabled = false`면 강조를 건너뛰고 스타일시트도 만들지 않습니다. 코드 블록에는
+여전히 `class="language-rust"`가 붙으므로 클라이언트 쪽 하이라이터가 받아 쓸 수
+있습니다.
diff --git a/docs/content/features/highlighting.md b/docs/content/features/highlighting.md
@@ -0,0 +1,59 @@
++++
+title = "Syntax highlighting"
+description = "Highlighted at build time, as classes, in two themes"
+weight = 10
+toc = true
++++
+
+Code blocks are highlighted while the site is built. No JavaScript runs in the
+reader's browser to colour them, so the page is coloured on first paint.
+
+```toml
+[highlight]
+enabled     = true
+theme_light = "InspiredGitHub"
+theme_dark  = "base16-ocean.dark"
+```
+
+## Classes, never inline styles
+
+A highlighted block looks like this:
+
+```html
+<code class="language-rust"><span class="hl-source hl-rust">…</span></code>
+```
+
+Not `style="color:#268bd2"`. The distinction decides three things.
+
+**Dark mode is possible at all.** Inline colours pin one theme into every
+document you have ever generated. Changing it means rebuilding the site;
+following the reader's preference means shipping both themes inside the markup.
+With classes, the two themes are two blocks of CSS, and switching is a CSS
+switch.
+
+**A strict `style-src` stays available.** A Content-Security-Policy that
+forbids inline styles is off the table the moment your HTML is full of them.
+
+**The stylesheet is one file.** Change a colour and every page changes, without
+rebuilding a single page of HTML.
+
+## Two themes, one stylesheet
+
+`theme_light` and `theme_dark` are both compiled into the generated stylesheet.
+The dark rules are emitted twice — once under `prefers-color-scheme: dark` for
+readers who never touch a toggle, and once under `[data-theme="dark"]` for
+sites like this one that offer a switch.
+
+Any theme name from syntect's default set works. A name that does not exist is
+a build error, and the message lists the ones that do.
+
+## The prefix
+
+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.
+
+## Turning it off
+
+`enabled = false` skips highlighting and emits no stylesheet. Code blocks still
+get `class="language-rust"`, so a client-side highlighter can pick them up.
diff --git a/docs/content/features/search.ko.md b/docs/content/features/search.ko.md
@@ -0,0 +1,67 @@
++++
+title = "검색"
+description = "부분 문자열 색인, 언어마다 한 파일, 그리고 단어 색인이 아닌 이유"
+weight = 20
+toc = true
++++
+
+빌드할 때마다 언어마다 색인 하나를 씁니다.
+
+```
+public/
+├── search-en.json
+└── search-ko.json
+```
+
+각 행이 페이지 하나입니다. 제목, 설명, 섹션, URL, 본문 평문이 들어갑니다.
+클라이언트는 누군가 검색을 처음 열 때 현재 언어의 파일을 받아 부분 문자열로
+훑습니다.
+
+## 왜 부분 문자열인가
+
+흔한 방식은 단어 색인입니다. 빌드 때 본문을 단어로 쪼개 두고, 검색 때 질의의 단어를
+찾습니다. 더 작고 더 빠릅니다. 그리고 한국어에서는 틀립니다.
+
+한국어는 명사에 조사가 붙기 때문에 `생성기`가 본문에서는 `생성기는`, `생성기를`,
+`생성기가`로 나타납니다. 단어 색인도 접두 매칭이면 여기까지는 버팁니다. 버티지
+못하는 건 한국어가 합성어를 붙여 쓴다는 사실입니다. `검색엔진최적화` 안의 `최적화`는
+그 무엇의 접두사도 아니고, 단어 색인은 이걸 영영 돌려주지 않습니다. `의존성이`
+안의 `존성`도 마찬가지입니다.
+
+여기서 떠오르는 해법은 색인할 때 형태소 분석기를 돌리는 것인데, 오히려 더 나빠집니다.
+사전은 범용 국어사전이라 외래어를 모르고, 기술 문서의 어휘는 대부분 외래어입니다.
+`템플릿은`이 `템플`+`릿`+`은`으로 돌아오고, `템플릿`이라는 단어가 정작 템플릿을
+다루는 페이지들과 매칭되지 않게 됩니다. 한국어 2000페이지 코퍼스에서 실측했을 때
+`템플릿`의 recall이 1018페이지에서 27페이지로 떨어졌습니다.
+
+브라우저가 이걸 보정해 줄 수도 없습니다. `Intl.Segmenter("ko")`는 한국어 어절을
+통째로 돌려줍니다. ICU는 중국어·일본어·태국어용 사전 분절기를 싣고 한국어용은 싣지
+않기 때문입니다. 그러니 형태소로 만든 색인은 그 색인을 향해 입력된 질의와 맞을
+길이 없습니다.
+
+본문을 부분 문자열로 훑으면 이런 문제가 어떤 언어에서도 생기지 않습니다. 대가는
+JSON 파일 하나입니다.
+
+## 대가
+
+색인은 모든 페이지의 본문입니다. 이 사이트 기준으로 언어당 몇 KB이고, 첫 검색 때
+한 번 받습니다. 문서가 늘면 선형으로 커지고, 어느 크기를 넘으면 이 설계가 틀린
+선택이 되는 지점이 분명히 있습니다. 다만 그 지점은 문서 사이트보다 훨씬 뒤에 있고,
+거기 도달하는 건 자기 문서의 단어를 못 찾는 검색을 배포하는 것보다 나은 문제입니다.
+
+## 순위
+
+질의의 모든 단어가 그 행 어딘가에 나타나야 합니다. OR가 아니라 AND입니다. 제목에서
+맞은 것이 설명에서 맞은 것보다, 설명이 본문보다 앞섭니다. 제목이 그 단어로 **시작**하면
+단순히 포함하는 것보다 앞섭니다. 결과는 12개에서 자릅니다. 사람이 읽는 건 그 정도까지입니다.
+
+코드 블록도 색인합니다. 문서에서 사람들은 어렴풋이 기억나는 명령어를 검색하니까요.
+
+## 클라이언트
+
+이 사이트의 `search.js`는 150줄 정도이고 의존성이 없습니다. 대화상자는 `<dialog>`라서
+Esc, 백드롭, 포커스 트랩, 닫을 때 트리거로 포커스를 되돌리는 것까지 전부 코드가 아니라
+플랫폼의 동작입니다. `Ctrl`/`⌘` + `/`로 엽니다.
+
+이건 도구의 기능이 아니라 예제 코드입니다. sqzass는 색인을 쓰고, 그 위에 무엇을
+만들지는 당신의 몫입니다.
diff --git a/docs/content/features/search.md b/docs/content/features/search.md
@@ -0,0 +1,73 @@
++++
+title = "Search"
+description = "A substring index, one file per language, and why it is not a word index"
+weight = 20
+toc = true
++++
+
+Every build writes one index per language:
+
+```
+public/
+├── search-en.json
+└── search-ko.json
+```
+
+Each row is a page: its title, description, section, URL and body text. The
+client fetches the file for the current language the first time someone opens
+search, and scans it for substrings.
+
+## Why substrings
+
+The usual approach is a word index: split the text into words at build time,
+look the query's words up at search time. It is smaller and it is faster, and
+for Korean it is wrong.
+
+Korean attaches particles to nouns, so `생성기` appears in running text as
+`생성기는`, `생성기를`, `생성기가`. A word index can survive that with prefix
+matching. What it cannot survive is that Korean writes compounds without
+spaces: `최적화` inside `검색엔진최적화` is not a prefix of anything, and a word
+index will never return it. Neither will it find `존성` inside `의존성이`.
+
+The apparent fix is to run a morphological analyser at index time, and it makes
+things worse. The dictionaries are general-purpose and do not know loanwords,
+which is most of a technical vocabulary: `템플릿은` comes back as
+`템플`+`릿`+`은`, and the word `템플릿` stops matching the pages that are about
+it. Measured on a 2000-page Korean corpus, recall for `템플릿` fell from 1018
+pages to 27.
+
+Nor can the browser be taught to compensate. `Intl.Segmenter("ko")` returns
+Korean 어절 whole — ICU ships dictionary-based breakers for Chinese, Japanese
+and Thai, and none for Korean — so an index of morphemes has no way to agree
+with the query typed against it.
+
+Scanning the text for substrings has none of these problems, in any language,
+and it costs a JSON file.
+
+## What it costs
+
+The index is the body text of every page. For this site that is a couple of
+kilobytes per language, fetched once, on the first search. It grows linearly
+with your documentation, and there is a size past which this is the wrong
+design — but that size is far beyond a documentation site, and reaching it is a
+better problem than shipping search that cannot find your own words.
+
+## Ranking
+
+Every term in the query must appear somewhere in a row — an AND, not an OR.
+A hit in the title outranks one in the description, which outranks one in the
+body, and a title that *starts* with the term outranks one that merely contains
+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 client
+
+`search.js` on this site is about 150 lines and has no dependencies. The dialog
+is a `<dialog>`, so Escape, the backdrop, the focus trap and returning focus to
+the trigger are the platform's behaviour rather than code. Open it with
+`Ctrl`/`⌘` + `/`.
+
+It is example code, not a feature of the tool: sqzass writes the index, and
+what you build on top of it is yours.
diff --git a/docs/content/start/_index.ko.md b/docs/content/start/_index.ko.md
@@ -1,8 +1,8 @@
 +++
 title = "시작하기"
-description = "sqzass를 설치하고 첫 사이트를 빌드한다"
+description = "sqzass를 설치하고 첫 사이트를 빌드합니다"
 weight = 10
 sort_by = "weight"
 +++
 
-사이트를 화면에 띄우는 데 필요한 것들.
+사이트를 화면에 띄우는 데 필요한 것들입니다.
diff --git a/docs/content/start/first-site.ko.md b/docs/content/start/first-site.ko.md
@@ -0,0 +1,84 @@
++++
+title = "첫 사이트 만들기"
+description = "sqzass가 빌드하는 가장 작은 사이트를 한 파일씩"
+weight = 20
+toc = true
++++
+
+아직 `sqzass init`이 없어서, 사이트는 직접 만드는 파일 세 개로 시작합니다.
+그게 스캐폴드의 전부입니다. sqzass는 숨은 상태도, 자기가 관리한다고 가정하는
+생성 디렉터리도 두지 않습니다.
+
+## 파일 세 개
+
+```
+mysite/
+├── sqzass.toml
+├── content/
+│   └── _index.md
+└── templates/
+    └── page.html
+```
+
+`sqzass.toml`에 반드시 있어야 하는 키는 둘입니다. 나머지는 전부 기본값이 있습니다.
+
+```toml
+title    = "내 사이트"
+base_url = "https://example.com"
+```
+
+`content/_index.md`가 첫 페이지입니다. front matter는 `+++` 펜스 사이의 TOML이고,
+반드시 적어야 하는 필드는 `title` 하나뿐입니다.
+
+```markdown
++++
+title = "내 사이트"
++++
+
+안녕하세요.
+```
+
+`templates/page.html`이 이걸 렌더합니다. `page.content`는 이미 HTML이므로
+`| safe`가 필요합니다. 템플릿은 기본적으로 이스케이프하며, 그 기본값 덕분에
+본문에 섞인 `<` 하나가 마크업으로 둔갑하지 않습니다.
+
+```html
+<!doctype html>
+<html lang="{{ page.language }}">
+<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
+<body>{{ page.content | safe }}</body>
+</html>
+```
+
+## 빌드하기
+
+```bash
+sqzass build -i mysite
+```
+
+결과물은 `mysite/public`에 생깁니다. 모든 페이지는 `<경로>.html`이 아니라
+`<경로>/index.html`로 쓰이므로 URL이 `/about.html`이 아니라 `/about/`이 됩니다.
+rewrite 규칙이 없는 호스트에서 왜 이게 중요한지는
+[페이지와 섹션](@/content/_index.md)에 적어 두었습니다.
+
+## 작업하기
+
+```bash
+sqzass serve -i mysite
+```
+
+<http://127.0.0.1:3000>에 메모리에서 사이트를 띄우고, 파일이 바뀌면 다시
+빌드합니다. 도는 동안 `public/`에는 아무것도 쓰지 않으므로, 빌드 중인 사이트가
+반쯤 쓰인 파일을 내보내는 일이 없습니다. 페이지는 스스로 새로고침하고,
+CSS만 바뀐 변경은 스타일시트만 갈아 끼워 스크롤 위치를 유지합니다.
+
+> [!NOTE]
+> 개발 서버는 개발용 도구입니다. 캐싱도, 압축도, 접근 제어도 하지 않습니다.
+> 프로덕션에서는 빌드된 디렉터리를 제대로 된 서버로 서빙하세요.
+
+## 다음에 더할 것
+
+페이지를 하나 더 만드는 건 파일을 하나 더 만드는 일입니다. `content/about.md`는
+`/about/`이 되고, `content/guide/_index.md`는 그 옆의 파일들을 묶는 섹션을
+시작합니다. 페이지가 가질 수 있는 필드는
+[front matter](@/content/front-matter.md)에 정리해 두었습니다.
diff --git a/docs/content/start/first-site.md b/docs/content/start/first-site.md
@@ -0,0 +1,83 @@
++++
+title = "Your first site"
+description = "The smallest site sqzass will build, one file at a time"
+weight = 20
+toc = true
++++
+
+There is no `sqzass init` yet, so a site starts as three files you make
+yourself. That is the whole scaffold — sqzass has no hidden state and no
+generated directory it expects to own.
+
+## The three files
+
+```
+mysite/
+├── sqzass.toml
+├── content/
+│   └── _index.md
+└── templates/
+    └── page.html
+```
+
+`sqzass.toml` needs two keys. Everything else has a default.
+
+```toml
+title    = "My site"
+base_url = "https://example.com"
+```
+
+`content/_index.md` is the front page. Front matter is TOML between `+++`
+fences, and `title` is the only field you must supply.
+
+```markdown
++++
+title = "My site"
++++
+
+Hello.
+```
+
+`templates/page.html` renders it. `page.content` is already HTML, so it needs
+`| safe` — templates escape by default, and that default is what keeps a stray
+`<` in your prose from becoming markup.
+
+```html
+<!doctype html>
+<html lang="{{ page.language }}">
+<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
+<body>{{ page.content | safe }}</body>
+</html>
+```
+
+## Build it
+
+```bash
+sqzass build -i mysite
+```
+
+The output lands in `mysite/public`. Every page is written as
+`<path>/index.html`, never `<path>.html`, so the URL is `/about/` and not
+`/about.html` — see [Pages and sections](@/content/_index.md) for why that
+matters on a host with no rewrite rules.
+
+## Work on it
+
+```bash
+sqzass serve -i mysite
+```
+
+This serves the site from memory on <http://127.0.0.1:3000> and rebuilds when
+a file changes. Nothing is written to `public/` while it runs, so a rebuilding
+site never serves a half-written file. The page reloads itself; a change that
+only touched CSS swaps the stylesheet in place and keeps your scroll position.
+
+> [!NOTE]
+> The dev server is a development tool. It does no caching, no compression and
+> no access control. Serve the built directory with a real server in production.
+
+## What to add next
+
+A second page is a second file. `content/about.md` becomes `/about/`, and
+`content/guide/_index.md` starts a section that collects everything beside it.
+[Front matter](@/content/front-matter.md) lists the fields a page can carry.
diff --git a/docs/content/start/installation.ko.md b/docs/content/start/installation.ko.md
@@ -1,12 +1,12 @@
 +++
 title = "설치"
-description = "소스에서 sqzass를 빌드한다"
+description = "소스에서 sqzass를 빌드합니다"
 weight = 10
 toc = true
 +++
 
-sqzass는 런타임 의존성이 없는 단일 바이너리다. Node도, Python도, 시스템
-라이브러리도 필요 없다.
+sqzass는 런타임 의존성이 없는 단일 바이너리입니다. Node도, Python도, 시스템
+라이브러리도 필요 없습니다.
 
 ## 소스에서 빌드
 
@@ -16,7 +16,7 @@ cd sqzass
 cargo build --release
 ```
 
-바이너리는 `target/release/sqzass`에 생긴다.
+바이너리는 `target/release/sqzass`에 생깁니다.
 
 ## 요구사항
 
@@ -26,10 +26,12 @@ cargo build --release
 | 그 외 | — |
 
 > [!NOTE]
-> 미리 빌드된 바이너리와 AUR 패키지는 계획에 있지만 아직 배포하지 않았다.
+> 미리 빌드된 바이너리와 AUR 패키지는 계획에 있지만 아직 배포하지 않았습니다.
 
 ## 설치 확인
 
 ```bash
 sqzass --version
 ```
+
+다음은 [첫 사이트 만들기](@/start/first-site.md)입니다.
diff --git a/docs/content/templates/_index.ko.md b/docs/content/templates/_index.ko.md
@@ -0,0 +1,39 @@
++++
+title = "템플릿"
+description = "Jinja2 호환 템플릿, 엄격한 데이터 모델, 명시적인 선택 규칙"
+weight = 30
+sort_by = "weight"
++++
+
+템플릿은 `templates/`에 두며 [minijinja]입니다. Jinja2 문법이라 `{% extends %}`,
+`{% block %}`, `{% include %}`, `{% macro %}`와 필터가 아는 그대로 동작합니다.
+
+```
+templates/
+├── base.html
+├── page.html
+├── section.html
+└── partials/
+    ├── sidebar.html
+    └── toc.html
+```
+
+## 익숙한 것보다 엄격한 지점이 둘 있습니다
+
+**정의되지 않은 값을 읽으면 에러입니다.** front matter 키 이름을 바꾸면, 구멍이
+뚫린 페이지를 렌더하는 대신 템플릿 이름과 키 이름을 들고 빌드가 멈춥니다. 가장
+엄격한 설정은 아닙니다. 정의된 적 없는 값에 대한 `{% if optional %}`은 그대로
+동작하는데, "이게 있나?"를 물을 수 없는 템플릿으로는 페이지마다 생김새가 다른
+사이트를 만들 수 없기 때문입니다.
+
+**모든 값이 기본적으로 이스케이프됩니다.** `page.content`는 이미 HTML이므로
+`| safe`가 필요합니다. `| safe`를 의도적으로 한 번 쓰는 대신, 나머지 모든 값이
+아무 생각 없이도 안전해집니다.
+
+## 빌드마다 스냅샷
+
+`templates/`는 빌드 시작 때 한 번 읽고, `include`/`extends`는 그 스냅샷에서
+해석됩니다. 개발 서버가 지켜보는 중에 파셜을 저장해도, 그때 일어난 리빌드는 일관된
+한 벌의 파일만 봅니다. 반쯤 쓰인 파일을 보는 일이 없습니다.
+
+[minijinja]: https://github.com/mitsuhiko/minijinja
diff --git a/docs/content/templates/_index.md b/docs/content/templates/_index.md
@@ -0,0 +1,41 @@
++++
+title = "Templates"
+description = "Jinja2-compatible templates, a strict data model, and explicit selection"
+weight = 30
+sort_by = "weight"
++++
+
+Templates live in `templates/` and are [minijinja] — Jinja2 syntax, so
+`{% extends %}`, `{% block %}`, `{% include %}`, `{% macro %}` and filters all
+work the way you expect.
+
+```
+templates/
+├── base.html
+├── page.html
+├── section.html
+└── partials/
+    ├── sidebar.html
+    └── toc.html
+```
+
+## Two things are stricter than you may be used to
+
+**Undefined access is an error.** Rename a front matter key and the build stops
+with the name of the template and the key, instead of rendering a page with a
+hole in it. It is not the strictest setting available — `{% if optional %}` on
+something that was never defined still works, because a template that cannot
+ask "is this here?" is not usable for a site where pages differ.
+
+**Everything is escaped by default.** `page.content` is already HTML, so it
+needs `| safe`. That is one deliberate `| safe` in exchange for every other
+value being safe without you thinking about it.
+
+## A snapshot per build
+
+`templates/` is read once at the start of a build, and `include`/`extends`
+resolve against that snapshot. Save a partial while the dev server is watching
+and the rebuild it triggers sees one consistent set of files — never a
+half-written one.
+
+[minijinja]: https://github.com/mitsuhiko/minijinja
diff --git a/docs/content/templates/assets.ko.md b/docs/content/templates/assets.ko.md
@@ -0,0 +1,65 @@
++++
+title = "정적 에셋"
+description = "복사하고, 해시를 붙이고, 원래 이름으로 조회합니다"
+weight = 30
+toc = true
++++
+
+`static/` 아래의 모든 파일이 경로를 유지한 채 출력으로 복사됩니다.
+
+```
+static/
+├── css/main.css   →  /css/main.a1b2c3d4.css
+├── js/search.js   →  /js/search.e5f6a7b8.js
+├── images/x.png   →  /images/x.png
+└── CNAME          →  /CNAME
+```
+
+CSS와 JavaScript는 파일명에 콘텐츠 해시가 붙습니다. 조회는 당신이 쓴 이름으로
+합니다.
+
+```html
+<link rel="stylesheet" href="{{ asset("css/main.css") }}">
+<script src="{{ asset("js/search.js") }}" defer></script>
+```
+
+## 쿼리 문자열이 아니라 파일명에
+
+`main.css?v=123`에는 문제가 둘 있습니다. 일부 CDN과 프록시는 캐시 키에서 쿼리를
+빼 버려서 무효화가 일어나지 않습니다. 그리고 흔히 쓰이는 형태 — 빌드마다 스탬프
+하나를 전 파일에 붙이는 방식 — 은 CSS 한 줄만 고쳐도 사이트 전체를 무효화합니다.
+
+파일별 콘텐츠 해시는 바뀐 파일만 정확히 무효화하고, 캐시 설정이 아예 없는 호스트를
+포함해 어디서나 똑같이 동작합니다.
+
+## CSS와 JS만 해시하는 이유
+
+이미지는 보통 문자열 경로로 참조됩니다. 마크다운에서, CSS에서, 때로는 당신이 쓰지
+않은 템플릿에서요. 이름을 바꾸면 그 참조들이 깨지는데 고칠 방법이 없습니다.
+
+그리고 어떤 파일명은 그 자체가 계약입니다. `CNAME`은 GitHub Pages에 도메인을
+알려주고, `robots.txt`는 이름으로 찾아집니다. `CNAME.9f8e7d6c`는 아무도 읽지 않는
+파일입니다.
+
+## 생성된 에셋도 같은 길을 지납니다
+
+하이라이트 스타일시트는 복사되는 게 아니라 테마에서 만들어지지만, 해시와 서빙은
+동일합니다. 템플릿에서는 `site.highlight_css`로 닿습니다.
+
+## 끄기
+
+```toml
+[assets]
+source_dir  = "static"
+fingerprint = false
+```
+
+`fingerprint = false`면 파일명이 그대로 유지되고 `asset()`은 계속 동작합니다.
+템플릿을 고치지 않고 켜고 끌 수 있습니다.
+
+## 검색 색인은 해시하지 않습니다
+
+`/search-en.json`과 `/search-ko.json`은 고정된 이름을 씁니다. `<head>`에서 링크되는
+게 아니라 스크립트가 가져가고, 내용이 렌더된 페이지에서 나오는데 페이지는 에셋이
+정해진 **뒤에** 렌더되므로 해시를 붙이면 순환이 됩니다. 색인이 10분쯤 낡는 대가는
+검색 결과 몇 줄입니다. [검색](@/features/search.md)을 참고하세요.
diff --git a/docs/content/templates/assets.md b/docs/content/templates/assets.md
@@ -0,0 +1,69 @@
++++
+title = "Static assets"
+description = "Copied, hashed, and looked up by their original name"
+weight = 30
+toc = true
++++
+
+Everything under `static/` is copied into the output, keeping its path.
+
+```
+static/
+├── css/main.css   →  /css/main.a1b2c3d4.css
+├── js/search.js   →  /js/search.e5f6a7b8.js
+├── images/x.png   →  /images/x.png
+└── CNAME          →  /CNAME
+```
+
+CSS and JavaScript get a content hash in the filename. Look them up by the
+name you wrote:
+
+```html
+<link rel="stylesheet" href="{{ asset("css/main.css") }}">
+<script src="{{ asset("js/search.js") }}" defer></script>
+```
+
+## In the filename, not a query string
+
+`main.css?v=123` has two problems. Some CDNs and proxies drop the query from
+the cache key, so the bust does not happen. And the common shape of it — one
+build stamp on every file — invalidates the whole site when you fix one line of
+CSS.
+
+A per-file content hash invalidates exactly the file that changed, and works
+the same on every host, including hosts with no cache configuration at all.
+
+## Why only CSS and JS
+
+Images are usually referenced by a literal path — from markdown, from CSS,
+sometimes from a template you did not write. Renaming them breaks those
+references without a way to fix them up.
+
+And some filenames *are* the contract. `CNAME` tells GitHub Pages your domain;
+`robots.txt` is looked for by name. `CNAME.9f8e7d6c` is a file nothing will
+ever read.
+
+## Generated assets go through the same path
+
+The highlight stylesheet is built from your themes rather than copied, but it
+is hashed and served identically. Templates reach it as
+`site.highlight_css`.
+
+## Turning it off
+
+```toml
+[assets]
+source_dir  = "static"
+fingerprint = false
+```
+
+With `fingerprint = false`, files keep their names and `asset()` still works —
+so you can flip it without editing a template.
+
+## The search index is not hashed
+
+`/search-en.json` and `/search-ko.json` keep fixed names. They are fetched by
+script rather than linked from `<head>`, and their contents come from the
+rendered pages — which are rendered after assets are resolved, so hashing them
+would be circular. Ten minutes of a stale index costs a few search hits. See
+[Search](@/features/search.md).
diff --git a/docs/content/templates/data.ko.md b/docs/content/templates/data.ko.md
@@ -0,0 +1,68 @@
++++
+title = "템플릿 데이터"
+description = "템플릿이 읽을 수 있는 모든 것"
+weight = 20
+toc = true
++++
+
+모든 페이지에서 `site`와 `page` 두 객체를 쓸 수 있습니다.
+
+## site
+
+| | |
+|---|---|
+| `site.title` | `sqzass.toml`에서. |
+| `site.description` | `sqzass.toml`에서. |
+| `site.base_url` | 끝의 슬래시를 뗀 값이라 `{{ site.base_url }}{{ page.url }}`이 항상 맞습니다. |
+| `site.language` | 지금 렌더 중인 페이지의 언어. |
+| `site.sections` | **이 언어의** 최상위 섹션들. |
+| `site.highlight_css` | 생성된 하이라이트 스타일시트 URL. 강조가 꺼져 있으면 없습니다. |
+
+`site.sections`에는 현재 언어의 트리만 담깁니다. 내비게이션이 안전한 이유가
+이것입니다. 번역되지 않은 페이지는 여기 없으므로 그 링크를 그릴 수가 없습니다.
+각 섹션은 `title`, `description`, `url`, `weight`, `pages`, `subsections`를 갖고,
+`pages`의 각 항목은 `title`, `description`, `url`, `weight`를 갖습니다.
+
+## page
+
+| | |
+|---|---|
+| `page.title` | |
+| `page.description` | |
+| `page.url` | `/ko/start/installation/` |
+| `page.permalink` | `base_url` + `url`. |
+| `page.content` | 렌더된 HTML. **`\| safe`가 필요합니다.** |
+| `page.weight`, `page.draft`, `page.language` | front matter 그대로. |
+| `page.toc` | 저자가 목차를 원했는지. |
+| `page.toc_entries` | 목차 자체 — `{level, id, title, children}`, 중첩된 형태. |
+| `page.translations` | 이 페이지가 존재하는 언어만. 비어 있으면 전환 UI를 그리지 않으면 됩니다. |
+| `page.section` | 이 페이지가 속한 섹션. 최상위 페이지에는 없습니다. |
+| `page.children` | 섹션의 자식 페이지들. 일반 페이지에서는 비어 있습니다. |
+| `page.is_section` | |
+| `page.extra` | 당신의 `[extra]` 테이블. |
+
+## asset()
+
+`asset("css/main.css")`은 그 파일이 실제로 쓰인 해시 붙은 URL을 돌려줍니다.
+
+```html
+<link rel="stylesheet" href="{{ asset("css/main.css") }}">
+```
+
+수집되지 않은 파일을 요청하면 에러입니다. 스타일시트 이름을 바꿨을 때 모든
+방문자에게 조용히 404를 내는 대신 빌드가 실패합니다.
+
+## 슬래시는 이스케이프하지 않습니다
+
+Jinja2는 다섯 글자를 이스케이프합니다. 일부 포팅 구현은 `/`까지 이스케이프하는데,
+그러면 모든 페이지의 모든 URL이 `href="https:&#x2f;&#x2f;…"`가 됩니다. sqzass는
+Jinja2 본래의 동작을 되돌려 두었으므로 URL이 URL로 나옵니다.
+
+## 없는 키는 빌드를 멈춥니다
+
+```
+undefined value: page.descriptoin
+```
+
+설명이 있어야 할 자리에 빈 문자열이 들어가는 대신입니다.
+[템플릿](@/templates/_index.md)을 참고하세요.
diff --git a/docs/content/templates/data.md b/docs/content/templates/data.md
@@ -0,0 +1,69 @@
++++
+title = "Template data"
+description = "Everything a template can read"
+weight = 20
+toc = true
++++
+
+Two objects are in scope on every page: `site` and `page`.
+
+## site
+
+| | |
+|---|---|
+| `site.title` | From `sqzass.toml`. |
+| `site.description` | From `sqzass.toml`. |
+| `site.base_url` | Trailing slash removed, so `{{ site.base_url }}{{ page.url }}` is always right. |
+| `site.language` | The language of the page being rendered. |
+| `site.sections` | Top-level sections **in this language**. |
+| `site.highlight_css` | URL of the generated highlight stylesheet, or nothing if highlighting is off. |
+
+`site.sections` contains only the current language's tree, which is what makes
+navigation safe: an untranslated page is not in it, so a link to it cannot be
+drawn. Each section carries `title`, `description`, `url`, `weight`, `pages`
+and `subsections`, and each entry in `pages` has `title`, `description`, `url`
+and `weight`.
+
+## page
+
+| | |
+|---|---|
+| `page.title` | |
+| `page.description` | |
+| `page.url` | `/ko/start/installation/` |
+| `page.permalink` | `base_url` + `url`. |
+| `page.content` | Rendered HTML. **Needs `\| safe`.** |
+| `page.weight`, `page.draft`, `page.language` | Front matter, as given. |
+| `page.toc` | Whether the author asked for a contents list. |
+| `page.toc_entries` | The contents themselves — `{level, id, title, children}`, nested. |
+| `page.translations` | Only languages this page exists in. Empty means no switcher. |
+| `page.section` | The section this page belongs to, or nothing at the top level. |
+| `page.children` | A section's own pages. Empty on ordinary pages. |
+| `page.is_section` | |
+| `page.extra` | Your `[extra]` table. |
+
+## asset()
+
+`asset("css/main.css")` returns the hashed URL that file was written to:
+
+```html
+<link rel="stylesheet" href="{{ asset("css/main.css") }}">
+```
+
+Asking for a file that was not collected is an error, so a renamed stylesheet
+fails the build instead of silently 404ing for every visitor.
+
+## Slashes are not escaped
+
+Jinja2 escapes five characters. Some ports escape `/` as well, which turns
+every URL on every page into `href="https:&#x2f;&#x2f;…"`. sqzass restores
+Jinja2's own behaviour, so URLs come out as URLs.
+
+## Missing keys stop the build
+
+```
+undefined value: page.descriptoin
+```
+
+Rather than an empty string where your description was. See
+[Templates](@/templates/_index.md).
diff --git a/docs/content/templates/selection.ko.md b/docs/content/templates/selection.ko.md
@@ -0,0 +1,58 @@
++++
+title = "템플릿 선택"
+description = "네 단계, 순서대로, 캐스케이드 없이"
+weight = 10
+toc = true
++++
+
+페이지는 아래 중 **가장 먼저 존재하는** 것으로 렌더됩니다.
+
+1. 페이지 front matter의 `template`
+2. 페이지가 섹션 인덱스면 `section.html`
+3. 부모 섹션 `_index.md`의 `page_template`
+4. `page.html`
+
+규칙은 이게 전부입니다. 존재하지 않는 `template`을 지정하면 에러이고, 메시지에
+지금 갖고 있는 템플릿 목록이 같이 나옵니다.
+
+## 왜 스무 단계가 아니라 네 단계인가
+
+Hugo는 kind × section × type × layout × language × output format을 곱해 만든
+lookup order로 템플릿을 찾습니다. 더 강력하고, 동시에 Hugo 사용자가 가장 많이
+헤매는 바로 그 지점입니다. "어느 템플릿이 선택됐는지 출력이라도 해 달라"는 요청이
+10년째 열려 있습니다.
+
+머릿속에 담기는 네 단계에는 그런 명령이 필요 없습니다. 어느 템플릿이 이 페이지를
+렌더했는지 알 수 없다면, 그 규칙은 너무 복잡한 것입니다.
+
+## 섹션 기본값 지정하기
+
+```toml
+# content/blog/_index.md
++++
+title = "블로그"
+page_template = "post.html"
++++
+```
+
+이제 `content/blog/`의 페이지들은 자기 것을 지정하지 않는 한 `post.html`로
+렌더됩니다. 직속 자식에게만 적용되며, 하위 섹션은 자기 것을 따로 정합니다.
+
+## base 상속하기
+
+보통은 스켈레톤 하나에 얇은 템플릿을 얹는 구성을 씁니다.
+
+```html
+{# templates/base.html #}
+<!doctype html>
+<html lang="{{ page.language }}">
+<head><title>{{ page.title }}</title></head>
+<body>{% block content %}{% endblock %}</body>
+</html>
+```
+
+```html
+{# templates/page.html #}
+{% extends "base.html" %}
+{% block content %}<article class="prose">{{ page.content | safe }}</article>{% endblock %}
+```
diff --git a/docs/content/templates/selection.md b/docs/content/templates/selection.md
@@ -0,0 +1,58 @@
++++
+title = "Choosing a template"
+description = "Four steps, in order, and no cascade"
+weight = 10
+toc = true
++++
+
+A page is rendered with the first of these that exists:
+
+1. the `template` named in the page's front matter
+2. `section.html`, if the page is a section index
+3. the `page_template` set on the parent section's `_index.md`
+4. `page.html`
+
+That is the whole rule. Naming a `template` that does not exist is an error,
+and the message lists the templates you do have.
+
+## Why it is four steps and not twenty
+
+Hugo resolves templates through a lookup order built from kind × section × type
+× layout × language × output format. It is more powerful, and it is the single
+thing Hugo users get lost in most often — there is a decade-old request open
+asking the tool to at least *print* which template it picked.
+
+Four steps you can hold in your head need no such command. If you cannot tell
+which template rendered a page, the rule is too complicated.
+
+## Setting a section's default
+
+```toml
+# content/blog/_index.md
++++
+title = "Blog"
+page_template = "post.html"
++++
+```
+
+Every page in `content/blog/` now renders with `post.html` unless it names its
+own. Note that this reaches direct children only; a subsection sets its own.
+
+## Extending a base
+
+The usual arrangement is one skeleton and thin templates on top:
+
+```html
+{# templates/base.html #}
+<!doctype html>
+<html lang="{{ page.language }}">
+<head><title>{{ page.title }}</title></head>
+<body>{% block content %}{% endblock %}</body>
+</html>
+```
+
+```html
+{# templates/page.html #}
+{% extends "base.html" %}
+{% block content %}<article class="prose">{{ page.content | safe }}</article>{% endblock %}
+```