sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit 53ddf1967a34e8fa313ccd83911c6f0157e709f8
parent 553e0b1f0f6147216ef60e3811166c9376ef1be1
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 18:41:34 +0900

Resolve @/ links, and design the documentation site

The seed content used @/start/_index.md, a source-path link that nothing
implemented, so href="@/start/_index.md" shipped to production. Links now
resolve through comrak's URL rewriter at the AST stage, and an unresolved one
fails the build -- shipping a dead link is exactly the class of bug this tool
claims to prevent.

Resolution is language-aware: the same @/start/installation.md sends an English
page to /start/installation/ and a Korean page to /ko/start/installation/, so
translated markdown needs no per-language link edits. A page with no
translation falls back to the default language rather than breaking.

For the design: the page is monochrome and the only colour on it comes from the
code. Highlighting is the feature this tool does properly, and a brand colour
competing with it would be noise. One accent exists, for links and focus.

Mono carries structure -- wordmark, headings, labels, code -- and the sans face
is reserved for prose. The +++ rule appears once per page, between the title
block and the body: the same boundary it marks in the source file.

Korean overrides all three of those Latin devices. letter-spacing pulls syllable
blocks apart, text-transform does nothing, and mono is the worst of them: Hangul
falls back to a proportional font while the space still comes from the mono
face, so every gap between 어절 renders double width. Verified by screenshot,
which is how the double-space was caught.

Diffstat:
Mdocs/content/_index.ko.md | 44+++++++++++++++++++++++++++++++-------------
Mdocs/content/_index.md | 46+++++++++++++++++++++++++++++++++-------------
Mdocs/content/start/installation.ko.md | 4++--
Mdocs/content/start/installation.md | 4++--
Adocs/static/css/main.css | 465+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/templates/base.html | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mdocs/templates/page.html | 7+++----
Mdocs/templates/section.html | 21++++++++++++++++-----
Msrc/lib.rs | 16++++++++++++++--
Asrc/links.rs | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/markdown.rs | 39++++++++++++++++++++++++++++++++++++++-
Msrc/render.rs | 4++++
Msrc/site.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 923 insertion(+), 52 deletion(-)

diff --git a/docs/content/_index.ko.md b/docs/content/_index.ko.md
@@ -4,22 +4,40 @@ description = "Rust로 만든 정적 사이트 생성기"
 weight = 0
 +++
 
-**sqzass**는 Rust로 만든 정적 사이트 생성기다. 이 사이트가 sqzass로 만들어졌다.
+대부분의 생성기는 예쁜 URL·리다이렉트·캐시 헤더를 호스트에 맡긴다. sqzass는 그걸
+직접 한다. 그래서 같은 출력물이 GitHub Pages든 Vercel이든 Cloudflare Pages든,
+그냥 디렉터리를 HTTP로 서빙하든 똑같이 동작한다.
 
-## 왜 또 만드나
+```bash
+sqzass build -i docs
+sqzass serve -i docs
+```
 
-대부분의 생성기는 예쁜 URL·리다이렉트·캐시 헤더를 호스트에 맡긴다.
-sqzass는 그걸 직접 한다. 그래서 같은 출력물이 GitHub Pages든 Vercel이든
-Cloudflare Pages든, 그냥 디렉터리를 HTTP로 서빙하든 똑같이 동작한다.
+## 그래서 얻는 것
 
-- 마크다운은 [comrak](https://github.com/kivikakk/comrak). 진짜 AST를 쓴다 —
-  링크 재작성과 heading anchor를 트리에서 처리하지, 최종 HTML에 정규식을
-  돌리지 않는다.
-- 템플릿은 [minijinja](https://github.com/mitsuhiko/minijinja). 기본으로
-  이스케이프하고, 스냅샷에서 해석하므로 저장 도중 리빌드가 걸려도 반쯤 쓰인
-  파셜을 읽는 일이 없다.
-- 처음부터 이중 언어.
+**마크다운을 트리에서 변환한다.** 링크 재작성, heading anchor, 목차 모두
+[comrak]의 AST 연산이다. 흔한 지름길인 "완성된 HTML에 정규식 돌리기"는 속성을
+홑따옴표로 쓰거나 따옴표를 빼면 조용히 처리에서 빠지고, 그 사실을 프로덕션에서
+알게 된다.
+
+**템플릿이 반쯤 쓰인 파일을 읽을 수 없다.** `templates/`를 빌드마다 한 번
+스냅샷으로 뜨고 [minijinja]의 로더가 `include`와 `extends`를 그 스냅샷에서
+해석한다. 저장하는 도중에 리빌드가 걸려도 항상 일관된 상태를 본다.
+
+**깨진 참조는 빌드를 멈춘다.** 해석 안 되는 `@/` 링크, 없는 템플릿, 같은 URL을
+주장하는 두 페이지, 없는 에셋 이름 — 전부 경고가 아니라 에러다.
+
+**같은 입력은 같은 바이트를 낸다.** 두 번 빌드하면 바이트까지 동일하고, CI가
+push마다 확인한다.
+
+**처음부터 이중 언어.** 번역은 자동으로 연결되고, 각 언어의 내비게이션에는 그
+언어에 실제로 있는 페이지만 담긴다. 미번역 페이지가 죽은 링크를 남길 수 없다.
 
 ## 상태
 
-초기 단계. [시작하기](@/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
@@ -4,22 +4,42 @@ description = "A static site generator written in Rust"
 weight = 0
 +++
 
-**sqzass** is a static site generator written in Rust. This site is built with it.
+Most generators lean on their host for pretty URLs, redirects and cache
+headers. sqzass does that work itself, so the same output is correct on GitHub
+Pages, Vercel, Cloudflare Pages, or a plain directory served over HTTP.
 
-## Why another one?
+```bash
+sqzass build -i docs
+sqzass serve -i docs
+```
 
-Most generators lean on their host for pretty URLs, redirects and cache headers.
-sqzass does that work itself, so the same output is correct on GitHub Pages,
-Vercel, Cloudflare Pages or a plain directory served over HTTP.
+## What that buys you
 
-- Markdown via [comrak](https://github.com/kivikakk/comrak), with a real AST — link
-  rewriting and heading anchors happen on the tree, never as a regex pass over the
-  final HTML.
-- Templates via [minijinja](https://github.com/mitsuhiko/minijinja), autoescaped by
-  default, resolved from a snapshot so a rebuild mid-save can never read a
-  half-written partial.
-- Bilingual from the start.
+**Markdown is transformed on the tree.** Link rewriting, heading anchors and the
+table of contents are AST operations via [comrak]. The common shortcut — running
+regexes over the finished HTML — silently skips any element whose attributes are
+single-quoted or unquoted, and you find out in production.
+
+**Templates cannot read a half-written file.** `templates/` is snapshotted once
+per build and [minijinja]'s loader resolves `include` and `extends` from that
+snapshot, so a rebuild triggered while you are saving sees one consistent state.
+
+**Broken references stop the build.** An unresolved `@/` link, a template that
+does not exist, two pages claiming the same URL, an unknown asset name — each is
+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.
 
 ## Status
 
-Early. See [Getting started](@/start/_index.md).
+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).
+
+[comrak]: https://github.com/kivikakk/comrak
+[minijinja]: https://github.com/mitsuhiko/minijinja
diff --git a/docs/content/start/installation.ko.md b/docs/content/start/installation.ko.md
@@ -20,10 +20,10 @@ cargo build --release
 
 ## 요구사항
 
-| | |
+| 요구사항 | 버전 |
 |---|---|
 | Rust | 1.97 이상 |
-| 그 외 | 없음 |
+| 그 외 | — |
 
 > [!NOTE]
 > 미리 빌드된 바이너리와 AUR 패키지는 계획에 있지만 아직 배포하지 않았다.
diff --git a/docs/content/start/installation.md b/docs/content/start/installation.md
@@ -20,10 +20,10 @@ The binary lands at `target/release/sqzass`.
 
 ## Requirements
 
-| | |
+| Requirement | Version |
 |---|---|
 | Rust | 1.97 or newer |
-| Everything else | nothing |
+| Everything else | — |
 
 > [!NOTE]
 > Prebuilt binaries and an AUR package are planned but not published yet.
diff --git a/docs/static/css/main.css b/docs/static/css/main.css
@@ -0,0 +1,465 @@
+/* sqzass documentation
+ *
+ * The page is monochrome. The only colour on it comes from the code, which is
+ * the point: highlighting is the feature this tool does properly, and a brand
+ * colour competing with it would be noise. One accent exists, used solely for
+ * links and focus, because a link that is not visibly a link is a defect.
+ *
+ * The +++ rule appears once per page, between the title block and the body.
+ * That is the same boundary it marks in a source file: front matter ends,
+ * content begins. It is the one decorative-looking thing here and it is not
+ * decoration.
+ */
+
+:root {
+  color-scheme: light dark;
+
+  --paper:   #fbfbfc;
+  --raised:  #f1f3f5;
+  --ink:     #14171a;
+  --ink-2:   #4a5058;
+  --ink-3:   #767d86;
+  --rule:    #e2e5e9;
+  --accent:  #1946c9;
+
+  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR",
+          "Apple SD Gothic Neo", "Malgun Gothic", Roboto, sans-serif;
+  --mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas,
+          "Liberation Mono", monospace;
+
+  --measure: 68ch;
+  --gutter: clamp(1.25rem, 4vw, 3rem);
+
+  --s-1: 0.8125rem;
+  --s0:  1.0625rem;
+  --s1:  1.25rem;
+  --s2:  1.5rem;
+  --s3:  1.9rem;
+  --s4:  2.5rem;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root:not([data-theme="light"]) {
+    --paper:  #0f1113;
+    --raised: #17191d;
+    --ink:    #e9ebed;
+    --ink-2:  #a9afb6;
+    --ink-3:  #71787f;
+    --rule:   #262a2f;
+    --accent: #86a8ff;
+  }
+}
+:root[data-theme="dark"] {
+  --paper:  #0f1113;
+  --raised: #17191d;
+  --ink:    #e9ebed;
+  --ink-2:  #a9afb6;
+  --ink-3:  #71787f;
+  --rule:   #262a2f;
+  --accent: #86a8ff;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; }
+
+body {
+  margin: 0;
+  background: var(--paper);
+  color: var(--ink);
+  font: var(--s0)/1.7 var(--sans);
+  /* Korean does not break inside a word. Without this, browsers split 어절
+     mid-word at narrow widths and the text becomes hard to read. */
+  word-break: keep-all;
+  overflow-wrap: anywhere;
+}
+
+a { color: var(--accent); text-underline-offset: 0.2em; }
+a:hover { text-decoration-thickness: 2px; }
+
+:focus-visible {
+  outline: 2px solid var(--accent);
+  outline-offset: 2px;
+  border-radius: 2px;
+}
+
+.skip {
+  position: absolute;
+  left: -9999px;
+  top: 0;
+  background: var(--paper);
+  color: var(--ink);
+  padding: 0.6rem 1rem;
+  z-index: 10;
+}
+.skip:focus { left: 0; }
+
+/* ---------- header ---------- */
+
+.masthead {
+  display: flex;
+  align-items: baseline;
+  gap: 1rem;
+  padding: 1.15rem var(--gutter);
+  border-bottom: 1px solid var(--rule);
+}
+
+.brand {
+  font-family: var(--mono);
+  font-size: var(--s1);
+  font-weight: 600;
+  letter-spacing: -0.03em;
+  color: var(--ink);
+  text-decoration: none;
+}
+.brand:hover { color: var(--accent); }
+
+.masthead nav {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  gap: 1rem;
+  font-family: var(--mono);
+  font-size: var(--s-1);
+}
+
+.masthead nav a { color: var(--ink-2); text-decoration: none; }
+.masthead nav a:hover { color: var(--accent); text-decoration: underline; }
+
+.theme-toggle {
+  font: inherit;
+  font-family: var(--mono);
+  color: var(--ink-2);
+  background: none;
+  border: 1px solid var(--rule);
+  border-radius: 2px;
+  padding: 0.15rem 0.5rem;
+  cursor: pointer;
+}
+.theme-toggle:hover { color: var(--accent); border-color: currentColor; }
+
+/* ---------- layout ---------- */
+
+.layout {
+  display: grid;
+  grid-template-columns: 15rem minmax(0, 1fr) 13rem;
+  gap: clamp(1.5rem, 4vw, 3.5rem);
+  align-items: start;
+  max-width: 84rem;
+  margin: 0 auto;
+  padding: 2.5rem var(--gutter) 5rem;
+}
+
+main { min-width: 0; max-width: var(--measure); }
+
+.sidebar, .toc {
+  position: sticky;
+  top: 2rem;
+  font-size: var(--s-1);
+  line-height: 1.6;
+  max-height: calc(100vh - 4rem);
+  overflow-y: auto;
+}
+
+/* ---------- sidebar ---------- */
+
+.sidebar ul { list-style: none; margin: 0; padding: 0; }
+.sidebar > ul > li + li { margin-top: 1.5rem; }
+
+.sidebar ul ul {
+  margin-top: 0.4rem;
+  border-left: 1px solid var(--rule);
+}
+
+.sidebar a {
+  display: block;
+  padding: 0.2rem 0;
+  color: var(--ink-2);
+  text-decoration: none;
+}
+.sidebar ul ul a { padding-left: 0.85rem; margin-left: -1px; }
+.sidebar a:hover { color: var(--accent); }
+
+/* Section headings in the tree read as labels, not links-among-links. */
+.sidebar > ul > li > a {
+  font-family: var(--mono);
+  font-size: 0.75rem;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--ink-3);
+}
+
+.sidebar a[aria-current="page"] {
+  color: var(--ink);
+  font-weight: 600;
+}
+.sidebar ul ul a[aria-current="page"] {
+  border-left: 2px solid var(--accent);
+  padding-left: calc(0.85rem - 1px);
+}
+
+/* ---------- table of contents ---------- */
+
+.toc ul { list-style: none; margin: 0; padding: 0; }
+.toc > ul > li + li { margin-top: 0.15rem; }
+.toc ul ul { padding-left: 0.85rem; }
+.toc a { color: var(--ink-3); text-decoration: none; display: block; padding: 0.15rem 0; }
+.toc a:hover { color: var(--accent); }
+
+.toc::before {
+  content: attr(data-label);
+  display: block;
+  font-family: var(--mono);
+  font-size: 0.75rem;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--ink-3);
+  margin-bottom: 0.6rem;
+}
+
+/* ---------- title block and the +++ rule ---------- */
+
+/* Mono carries structure -- wordmark, headings, labels, code. The sans face is
+   reserved for prose. For a tool you drive from a terminal that split says
+   something; it also keeps headings visually adjacent to the commands they
+   introduce. Mono runs wide, so the scale is pulled in a step. */
+.title-block h1 {
+  font-family: var(--mono);
+  font-size: var(--s3);
+  font-weight: 600;
+  letter-spacing: -0.04em;
+  line-height: 1.15;
+  margin: 0;
+}
+
+.title-block .lede {
+  color: var(--ink-2);
+  font-size: var(--s1);
+  line-height: 1.55;
+  margin: 0.6rem 0 0;
+}
+
+/* The front matter fence. Ends the metadata, opens the content -- exactly
+   what +++ does in the source file this page was written in. */
+.fence {
+  display: flex;
+  align-items: center;
+  gap: 0.85rem;
+  font-family: var(--mono);
+  font-size: var(--s0);
+  font-weight: 500;
+  letter-spacing: 0.1em;
+  color: var(--ink-3);
+  margin: 1.6rem 0 2.25rem;
+  user-select: none;
+}
+.fence::after {
+  content: "";
+  flex: 1;
+  border-top: 1px solid var(--rule);
+}
+
+/* ---------- prose ---------- */
+
+.prose > :first-child { margin-top: 0; }
+
+.prose h2, .prose h3, .prose h4 {
+  font-family: var(--mono);
+  font-weight: 600;
+  letter-spacing: -0.03em;
+  line-height: 1.3;
+  scroll-margin-top: 2rem;
+}
+.prose h2 { font-size: var(--s2); margin: 2.9rem 0 0.9rem; }
+.prose h3 { font-size: var(--s1); margin: 2.25rem 0 0.7rem; }
+.prose h4 { font-size: var(--s0); margin: 1.75rem 0 0.5rem; }
+
+/* h2 opens a section; a hairline above it makes that legible at a glance
+   without adding another visual device. */
+.prose h2 {
+  border-top: 1px solid var(--rule);
+  padding-top: 2.9rem;
+}
+.prose h2:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
+
+.prose p, .prose ul, .prose ol { margin: 0 0 1.1rem; }
+.prose li + li { margin-top: 0.3rem; }
+
+/* The anchor is there for people who want to link to a section, not for
+   everyone else to look at. */
+.anchor {
+  margin-left: 0.4rem;
+  color: var(--ink-3);
+  text-decoration: none;
+  opacity: 0;
+  font-weight: 400;
+}
+h1:hover > .anchor, h2:hover > .anchor, h3:hover > .anchor,
+h4:hover > .anchor, h5:hover > .anchor, h6:hover > .anchor,
+.anchor:focus-visible { opacity: 1; }
+@media (hover: none) { .anchor { opacity: 1; } }
+
+/* ---------- code ---------- */
+
+.prose code {
+  font-family: var(--mono);
+  font-size: 0.875em;
+  background: var(--raised);
+  padding: 0.15em 0.35em;
+  border-radius: 3px;
+}
+
+.prose pre.highlight {
+  font-family: var(--mono);
+  font-size: 0.875rem;
+  line-height: 1.6;
+  background: var(--raised);
+  border: 1px solid var(--rule);
+  border-radius: 4px;
+  padding: 0.9rem 1.1rem;
+  margin: 0 0 1.3rem;
+  overflow-x: auto;
+}
+.prose pre.highlight code {
+  background: none;
+  padding: 0;
+  border-radius: 0;
+  font-size: inherit;
+}
+
+/* ---------- tables ---------- */
+
+.prose table {
+  border-collapse: collapse;
+  width: 100%;
+  font-size: var(--s-1);
+  margin: 0 0 1.3rem;
+  display: block;
+  overflow-x: auto;
+}
+.prose th, .prose td {
+  text-align: left;
+  padding: 0.5rem 0.9rem 0.5rem 0;
+  border-bottom: 1px solid var(--rule);
+  vertical-align: top;
+}
+.prose th {
+  font-family: var(--mono);
+  font-size: 0.75rem;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  color: var(--ink-3);
+  font-weight: 500;
+}
+
+/* ---------- admonitions (comrak alerts) ---------- */
+
+.prose .markdown-alert {
+  border-left: 2px solid var(--ink-3);
+  padding: 0.1rem 0 0.1rem 1rem;
+  margin: 0 0 1.3rem;
+  color: var(--ink-2);
+}
+.prose .markdown-alert-title {
+  font-family: var(--mono);
+  font-size: 0.75rem;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--ink-3);
+  margin: 0 0 0.35rem;
+}
+.prose .markdown-alert > p:last-child { margin-bottom: 0; }
+
+.prose blockquote {
+  margin: 0 0 1.3rem;
+  padding-left: 1rem;
+  border-left: 2px solid var(--rule);
+  color: var(--ink-2);
+}
+
+.prose hr { border: 0; border-top: 1px solid var(--rule); margin: 2.5rem 0; }
+
+/* ---------- section index ---------- */
+
+.children { list-style: none; margin: 0; padding: 0; }
+.children li { border-top: 1px solid var(--rule); }
+.children li:last-child { border-bottom: 1px solid var(--rule); }
+.children a {
+  display: block;
+  padding: 0.9rem 0;
+  text-decoration: none;
+  color: var(--ink);
+}
+.children a:hover { color: var(--accent); }
+.children .desc { display: block; color: var(--ink-3); font-size: var(--s-1); margin-top: 0.15rem; }
+
+/* ---------- footer ---------- */
+
+.colophon {
+  border-top: 1px solid var(--rule);
+  padding: 1.5rem var(--gutter);
+  font-family: var(--mono);
+  font-size: 0.75rem;
+  color: var(--ink-3);
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.4rem 1.5rem;
+}
+.colophon a { color: var(--ink-3); }
+
+/* ---------- responsive ---------- */
+
+@media (max-width: 68rem) {
+  .layout { grid-template-columns: 14rem minmax(0, 1fr); }
+  .toc { display: none; }
+}
+
+@media (max-width: 48rem) {
+  .layout {
+    grid-template-columns: minmax(0, 1fr);
+    padding-top: 1.5rem;
+  }
+  .sidebar {
+    position: static;
+    max-height: none;
+    padding-bottom: 1.5rem;
+    border-bottom: 1px solid var(--rule);
+  }
+  .title-block h1 { font-size: var(--s3); }
+}
+
+/* ---------- Korean ----------
+   Three Latin devices do not survive contact with Hangul:
+
+   letter-spacing pushes syllable blocks apart until a word stops reading as one
+   word; text-transform does nothing at all; and a monospace family is the worst
+   of them, because Hangul falls back to a proportional font while the *space*
+   still comes from the mono face, so every gap between 어절 renders double
+   width and the heading looks broken.
+
+   Structure still reads through size, weight and colour, which need no help. */
+html[lang="ko"] .title-block h1,
+html[lang="ko"] .prose h2,
+html[lang="ko"] .prose h3,
+html[lang="ko"] .prose h4,
+html[lang="ko"] .sidebar > ul > li > a,
+html[lang="ko"] .toc::before,
+html[lang="ko"] .prose th,
+html[lang="ko"] .prose .markdown-alert-title {
+  font-family: var(--sans);
+  letter-spacing: -0.01em;
+  text-transform: none;
+}
+html[lang="ko"] .title-block h1,
+html[lang="ko"] .prose h2,
+html[lang="ko"] .prose h3,
+html[lang="ko"] .prose h4 {
+  font-weight: 700;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *, *::before, *::after {
+    animation-duration: 0.01ms !important;
+    transition-duration: 0.01ms !important;
+  }
+}
diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -1,41 +1,86 @@
 {% from "partials/sidebar.html" import nav %}
 {% from "partials/toc.html" import toc_list %}
+{%- set ko = page.language == "ko" %}
+{%- set home = "/ko/" if ko else "/" %}
 <!doctype html>
 <html lang="{{ page.language }}">
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{% if page.url == "/" or page.url == "/" ~ page.language ~ "/" %}{{ site.title }}{% else %}{{ page.title }} · {{ site.title }}{% endif %}</title>
+<title>{% if page.url == home %}{{ site.title }} — {{ site.description }}{% else %}{{ page.title }} · {{ site.title }}{% endif %}</title>
 {% if page.description %}<meta name="description" content="{{ page.description }}">{% endif %}
 <link rel="canonical" href="{{ page.permalink }}">
-{%- if site.highlight_css %}
-<link rel="stylesheet" href="{{ site.highlight_css }}">
-{%- endif %}
 {%- for t in page.translations %}
 <link rel="alternate" hreflang="{{ t.code }}" href="{{ site.base_url }}{{ t.url }}">
 {%- endfor %}
+<link rel="stylesheet" href="{{ asset("css/main.css") }}">
+{%- if site.highlight_css %}
+<link rel="stylesheet" href="{{ site.highlight_css }}">
+{%- endif %}
+<script>
+/* Runs before first paint so a stored preference does not flash the other
+   theme. Inline on purpose: an external file would arrive too late. */
+(function(){try{var t=localStorage.getItem("theme");if(t)document.documentElement.dataset.theme=t}catch(e){}})();
+</script>
 </head>
 <body>
-<a href="#content" class="skip">Skip to content</a>
+<a class="skip" href="#content">{{ "본문으로 건너뛰기" if ko else "Skip to content" }}</a>
 
-{% include "partials/header.html" %}
+<header class="masthead">
+  <a class="brand" href="{{ home }}">{{ site.title }}</a>
+  <nav>
+    {%- for t in page.translations %}
+    <a href="{{ t.url }}" hreflang="{{ t.code }}">{{ t.name }}</a>
+    {%- endfor %}
+    <a href="https://github.com/sqzer-x/sqzass">GitHub</a>
+    {#- Text, not a glyph: ◐/☾ and friends are missing from enough fallback
+        stacks that they render as a box. #}
+    <button class="theme-toggle" type="button">{{ "테마" if ko else "Theme" }}</button>
+  </nav>
+</header>
 
 <div class="layout">
-  <aside class="sidebar" aria-label="Documentation">
+  <aside class="sidebar" aria-label="{{ "문서" if ko else "Documentation" }}">
     {{ nav(site.sections, page.url) }}
   </aside>
 
   <main id="content">
-{% block content %}{% endblock %}
+    <div class="title-block">
+      <h1>{{ page.title }}</h1>
+      {%- if page.description %}
+      <p class="lede">{{ page.description }}</p>
+      {%- endif %}
+    </div>
+
+    {#- The front matter fence: metadata ends, content begins. Same boundary
+        it marks in the source file. #}
+    <div class="fence" aria-hidden="true">+++</div>
+
+    {% block content %}{% endblock %}
   </main>
 
   {%- if page.toc and page.toc_entries %}
-  <aside class="toc" aria-label="On this page">
+  <aside class="toc" data-label="{{ "이 페이지" if ko else "On this page" }}" aria-label="{{ "이 페이지" if ko else "On this page" }}">
     {{ toc_list(page.toc_entries) }}
   </aside>
   {%- endif %}
 </div>
 
-{% include "partials/footer.html" %}
+<footer class="colophon">
+  <span>{{ "sqzass로 빌드됨" if ko else "Built with sqzass" }}</span>
+  <a href="https://github.com/sqzer-x/sqzass">Source</a>
+  <span>MIT</span>
+</footer>
+
+<script>
+document.querySelector(".theme-toggle").addEventListener("click", function () {
+  var root = document.documentElement;
+  var current = root.dataset.theme ||
+    (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
+  var next = current === "dark" ? "light" : "dark";
+  root.dataset.theme = next;
+  try { localStorage.setItem("theme", next); } catch (e) {}
+});
+</script>
 </body>
 </html>
diff --git a/docs/templates/page.html b/docs/templates/page.html
@@ -1,8 +1,7 @@
 {% extends "base.html" %}
 
 {% block content %}
-<article>
-  <h1>{{ page.title }}</h1>
-  {{ page.content | safe }}
-</article>
+<div class="prose">
+{{ page.content | safe }}
+</div>
 {% endblock %}
diff --git a/docs/templates/section.html b/docs/templates/section.html
@@ -1,9 +1,20 @@
 {% extends "base.html" %}
 
-{# 자식 페이지 목록은 섹션 트리가 들어오는 M1에서 붙는다. #}
 {% block content %}
-<article>
-  <h1>{{ page.title }}</h1>
-  {{ page.content | safe }}
-</article>
+<div class="prose">
+{{ page.content | safe }}
+</div>
+
+{%- if page.children %}
+<ul class="children">
+  {%- for c in page.children %}
+  <li>
+    <a href="{{ c.url }}">
+      {{ c.title }}
+      {%- if c.description %}<span class="desc">{{ c.description }}</span>{% endif %}
+    </a>
+  </li>
+  {%- endfor %}
+</ul>
+{%- endif %}
 {% endblock %}
diff --git a/src/lib.rs b/src/lib.rs
@@ -4,6 +4,7 @@ pub mod assets;
 pub mod config;
 pub mod content;
 pub mod highlight;
+pub mod links;
 pub mod markdown;
 pub mod render;
 pub mod serve;
@@ -71,7 +72,8 @@ pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
     // 에셋을 먼저 처리해야 템플릿이 해시가 붙은 최종 URL을 조회할 수 있다.
     let mut assets = assets::Assets::collect(root, &cfg.assets)?;
 
-    let mut md = markdown::Renderer::new(&cfg.markdown);
+    let mut md =
+        markdown::Renderer::new(&cfg.markdown).with_links(site.link_index(), &cfg.default_language);
     let highlight_css_url = if cfg.highlight.enabled {
         md = md.with_highlighter(highlight::Highlighter::new());
         // 하이라이트 스타일시트는 테마에서 생성한다. HTML은 클래스만 담고 있으므로
@@ -166,7 +168,15 @@ fn render_page(
         highlight_css: highlight_css.map(str::to_string),
     };
 
-    let rendered = md.render(&page.body);
+    let rendered = md.render_in(&page.language, &page.body);
+    if !rendered.unresolved.is_empty() {
+        anyhow::bail!(
+            "{}: 해석할 수 없는 내부 링크가 있습니다:\n  {}\n\
+             `@/` 는 content/ 기준 소스 경로를 가리켜야 합니다 (예: `@/start/installation.md`).",
+            page.source.display(),
+            rendered.unresolved.join("\n  ")
+        );
+    }
 
     let page_ctx = PageCtx {
         title: page.title.clone(),
@@ -183,6 +193,8 @@ fn render_page(
         // 번역이 실제로 있는 언어만 담긴다 — 템플릿은 이게 비었는지로
         // 언어 전환 UI를 보일지 결정하면 된다.
         translations: site.translations_of(page),
+        children: site.children_of(page),
+        is_section: page.is_section,
     };
 
     templates
diff --git a/src/links.rs b/src/links.rs
@@ -0,0 +1,194 @@
+//! 내부 링크 해석.
+//!
+//! 마크다운에서 `@/start/installation.md` 처럼 **소스 파일**을 가리키면, 렌더 시점에
+//! 그 페이지의 실제 URL로 바뀐다. 파일을 옮기거나 슬러그를 바꿔도 링크가 따라온다.
+//!
+//! 해석은 comrak의 `link_url_rewriter`를 통해 **AST 단계에서** 일어난다. 최종 HTML에
+//! 정규식을 돌리지 않는다 — 그 방식은 속성을 홑따옴표로 쓰거나 따옴표를 빼면 조용히
+//! 처리에서 빠져서, 링크가 깨졌는지조차 알 수 없게 된다.
+
+use comrak::options::URLRewriter;
+use std::collections::BTreeMap;
+use std::sync::Mutex;
+
+pub const PREFIX: &str = "@/";
+
+/// 언어별 URL 표. `translation_key` → (언어 → URL)
+pub type LinkIndex = BTreeMap<String, BTreeMap<String, String>>;
+
+pub struct Resolver {
+    index: LinkIndex,
+    language: String,
+    default_language: String,
+    /// 해석하지 못한 링크. 렌더가 끝난 뒤 빌드를 실패시키는 데 쓴다.
+    unresolved: Mutex<Vec<String>>,
+}
+
+impl Resolver {
+    pub fn new(index: LinkIndex, language: &str, default_language: &str) -> Self {
+        Self {
+            index,
+            language: language.to_string(),
+            default_language: default_language.to_string(),
+            unresolved: Mutex::new(Vec::new()),
+        }
+    }
+
+    /// 해석에 실패한 링크들. 비어 있지 않으면 빌드를 멈춰야 한다.
+    pub fn take_unresolved(&self) -> Vec<String> {
+        self.unresolved
+            .lock()
+            .unwrap_or_else(|e| e.into_inner())
+            .split_off(0)
+    }
+
+    fn resolve(&self, url: &str) -> Option<String> {
+        let (path, fragment) = match url.split_once('#') {
+            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)?;
+
+        // 지금 페이지와 같은 언어판을 먼저 찾는다. 한국어 문서가
+        // `@/start/installation.md` 라고 써도 한국어판으로 간다.
+        let target = by_lang
+            .get(&self.language)
+            .or_else(|| by_lang.get(&self.default_language))?;
+
+        Some(match fragment {
+            Some(f) => format!("{target}#{f}"),
+            None => target.clone(),
+        })
+    }
+}
+
+impl URLRewriter for Resolver {
+    fn to_html(&self, url: &str) -> String {
+        if !url.starts_with(PREFIX) {
+            return url.to_string();
+        }
+        match self.resolve(url) {
+            Some(resolved) => resolved,
+            None => {
+                self.unresolved
+                    .lock()
+                    .unwrap_or_else(|e| e.into_inner())
+                    .push(url.to_string());
+                // 원문을 그대로 둔다. 어차피 빌드가 실패할 것이고, 실패 메시지에
+                // 원래 쓴 문자열이 보이는 편이 찾기 쉽다.
+                url.to_string()
+            }
+        }
+    }
+}
+
+/// `start/installation.md`, `start/installation.ko.md` → `start/installation`
+///
+/// 언어 접미사를 떼는 이유: 같은 마크다운을 양쪽 언어판에서 그대로 쓰기 위해서다.
+/// 저자는 언어를 신경 쓰지 않고 소스 경로만 적으면 된다.
+fn translation_key(path: &str) -> String {
+    let path = path.trim_start_matches('/');
+    let stem = path.strip_suffix(".md").unwrap_or(path);
+    match stem.rsplit_once('.') {
+        // 마지막 조각이 짧으면 언어 코드로 본다. `my.file` 같은 이름을 언어로
+        // 오인하지 않도록 길이로 거른다(ISO 639-1/2는 2~3자).
+        Some((base, tail))
+            if (2..=3).contains(&tail.len()) && tail.chars().all(|c| c.is_ascii_alphabetic()) =>
+        {
+            base.to_string()
+        }
+        _ => stem.to_string(),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn index() -> LinkIndex {
+        let mut i = LinkIndex::new();
+        i.insert(
+            "start/installation".into(),
+            [
+                ("en".to_string(), "/start/installation/".to_string()),
+                ("ko".to_string(), "/ko/start/installation/".to_string()),
+            ]
+            .into(),
+        );
+        i.insert(
+            "start/_index".into(),
+            [("en".to_string(), "/start/".to_string())].into(),
+        );
+        i
+    }
+
+    #[test]
+    fn resolves_to_the_current_language() {
+        let r = Resolver::new(index(), "ko", "en");
+        assert_eq!(
+            r.to_html("@/start/installation.md"),
+            "/ko/start/installation/"
+        );
+        assert!(r.take_unresolved().is_empty());
+    }
+
+    #[test]
+    fn falls_back_to_the_default_language() {
+        // 한국어판이 없는 페이지를 한국어 문서에서 가리키면 영어판으로 간다.
+        // 링크를 깨뜨리는 것보다 낫다.
+        let r = Resolver::new(index(), "ko", "en");
+        assert_eq!(r.to_html("@/start/_index.md"), "/start/");
+    }
+
+    #[test]
+    fn keeps_the_fragment() {
+        let r = Resolver::new(index(), "en", "en");
+        assert_eq!(
+            r.to_html("@/start/installation.md#requirements"),
+            "/start/installation/#requirements"
+        );
+    }
+
+    #[test]
+    fn leaves_ordinary_urls_alone() {
+        let r = Resolver::new(index(), "en", "en");
+        for u in [
+            "https://example.com",
+            "/already/absolute/",
+            "./relative",
+            "#fragment",
+            "mailto:a@b.c",
+        ] {
+            assert_eq!(r.to_html(u), u);
+        }
+        assert!(r.take_unresolved().is_empty());
+    }
+
+    #[test]
+    fn records_unresolved_links_instead_of_shipping_them() {
+        let r = Resolver::new(index(), "en", "en");
+        r.to_html("@/does/not/exist.md");
+        assert_eq!(r.take_unresolved(), vec!["@/does/not/exist.md".to_string()]);
+    }
+
+    #[test]
+    fn language_suffixes_map_to_the_same_key() {
+        assert_eq!(
+            translation_key("start/installation.md"),
+            "start/installation"
+        );
+        assert_eq!(
+            translation_key("start/installation.ko.md"),
+            "start/installation"
+        );
+        assert_eq!(translation_key("/start/_index.md"), "start/_index");
+    }
+
+    #[test]
+    fn dotted_filenames_are_not_mistaken_for_languages() {
+        // `notes.backup.md`의 "backup"은 언어 코드가 아니다.
+        assert_eq!(translation_key("notes.backup.md"), "notes.backup");
+        assert_eq!(translation_key("v1.2.md"), "v1.2");
+    }
+}
diff --git a/src/markdown.rs b/src/markdown.rs
@@ -25,11 +25,16 @@ pub struct TocEntry {
 pub struct Rendered {
     pub html: String,
     pub toc: Vec<TocEntry>,
+    /// 해석하지 못한 `@/` 링크. 비어 있지 않으면 빌드를 멈춰야 한다 —
+    /// 깨진 링크를 조용히 배포하는 건 이 도구가 막겠다고 한 바로 그 부류다.
+    pub unresolved: Vec<String>,
 }
 
 pub struct Renderer {
     cfg: MarkdownConfig,
     highlighter: Option<crate::highlight::Highlighter>,
+    links: Option<crate::links::LinkIndex>,
+    default_language: String,
 }
 
 impl Renderer {
@@ -37,6 +42,8 @@ impl Renderer {
         Self {
             cfg: cfg.clone(),
             highlighter: None,
+            links: None,
+            default_language: "en".into(),
         }
     }
 
@@ -46,6 +53,13 @@ impl Renderer {
         self
     }
 
+    /// `@/path.md` 내부 링크 해석을 켠다.
+    pub fn with_links(mut self, index: crate::links::LinkIndex, default_language: &str) -> Self {
+        self.links = Some(index);
+        self.default_language = default_language.to_string();
+        self
+    }
+
     fn options<'p>(&self) -> Options<'p> {
         let mut o = Options::default();
         let c = &self.cfg;
@@ -68,11 +82,28 @@ impl Renderer {
     }
 
     pub fn render(&self, body: &str) -> Rendered {
+        self.render_in("en", body)
+    }
+
+    /// `language`는 `@/` 링크를 어느 언어판으로 보낼지 정한다.
+    pub fn render_in(&self, language: &str, body: &str) -> Rendered {
         // 어댑터는 `Options`가 아니라 `Plugins`에 붙는다. 페이지마다 새 collector를
         // 쓰는 게 중요하다 — heading id는 사이트가 아니라 **페이지 안에서** 유일해야
         // 하므로 anchorizer의 dedupe 카운터도 페이지마다 초기화되어야 한다.
         let headings = HeadingCollector::new(self.cfg.heading_anchors);
-        let options = self.options();
+        let mut options = self.options();
+
+        let resolver = self.links.as_ref().map(|index| {
+            std::sync::Arc::new(crate::links::Resolver::new(
+                index.clone(),
+                language,
+                &self.default_language,
+            ))
+        });
+        if let Some(r) = &resolver {
+            options.extension.link_url_rewriter = Some(r.clone());
+            options.extension.image_url_rewriter = Some(r.clone());
+        }
 
         let html = {
             let mut plugins = Plugins::default();
@@ -83,7 +114,13 @@ impl Renderer {
             markdown_to_html_with_plugins(body, &options, &plugins)
         };
 
+        let unresolved = resolver
+            .as_ref()
+            .map(|r| r.take_unresolved())
+            .unwrap_or_default();
+
         Rendered {
+            unresolved,
             html,
             toc: headings.into_toc(),
         }
diff --git a/src/render.rs b/src/render.rs
@@ -208,6 +208,10 @@ pub struct PageCtx {
     pub language: String,
     /// 다른 언어판. **번역이 실제로 있을 때만** 채워진다.
     pub translations: Vec<crate::site::LanguageLink>,
+    /// 섹션 페이지의 직속 자식. 일반 페이지에서는 비어 있다.
+    pub children: Vec<crate::site::PageRefCtx>,
+    /// 섹션 인덱스인지. 템플릿이 목록을 그릴지 판단한다.
+    pub is_section: bool,
 }
 
 #[cfg(test)]
diff --git a/src/site.rs b/src/site.rs
@@ -87,6 +87,72 @@ impl<'a> Site<'a> {
             .as_deref()
     }
 
+    /// `@/` 링크 해석용 표. translation_key → (언어 → URL)
+    pub fn link_index(&self) -> crate::links::LinkIndex {
+        self.translations
+            .iter()
+            .map(|(key, by_lang)| {
+                let urls = by_lang
+                    .iter()
+                    .map(|(lang, &i)| (lang.clone(), self.pages[i].url.clone()))
+                    .collect();
+                (key.clone(), urls)
+            })
+            .collect()
+    }
+
+    /// 섹션 인덱스 페이지의 직속 자식들. 섹션 페이지가 자기 하위 목록을 보여줄 때 쓴다.
+    pub fn children_of(&self, page: &Page) -> Vec<PageRefCtx> {
+        if !page.is_section {
+            return Vec::new();
+        }
+        let dirs: Vec<String> = page
+            .rel
+            .parent()
+            .map(|d| {
+                d.components()
+                    .map(|c| c.as_os_str().to_string_lossy().into_owned())
+                    .collect()
+            })
+            .unwrap_or_default();
+
+        let sections = self.sections(&page.language);
+        // 루트 `_index.md`의 자식은 최상위 섹션들이다.
+        let Some(node) = (if dirs.is_empty() {
+            None
+        } else {
+            find_section(sections, &dirs)
+        }) else {
+            return sections
+                .iter()
+                .map(|s| PageRefCtx {
+                    title: s.title.clone(),
+                    description: s.description.clone(),
+                    url: s.url.clone(),
+                    weight: s.weight,
+                })
+                .collect();
+        };
+
+        let mut out: Vec<PageRefCtx> = node
+            .pages
+            .iter()
+            .map(|&i| PageRefCtx {
+                title: self.pages[i].title.clone(),
+                description: self.pages[i].front.description.clone(),
+                url: self.pages[i].url.clone(),
+                weight: self.pages[i].front.weight,
+            })
+            .collect();
+        out.extend(node.subsections.iter().map(|s| PageRefCtx {
+            title: s.title.clone(),
+            description: s.description.clone(),
+            url: s.url.clone(),
+            weight: s.weight,
+        }));
+        out
+    }
+
     /// 이 페이지의 다른 언어판. **번역이 실제로 있는 것만** 담는다.
     pub fn translations_of(&self, page: &Page) -> Vec<LanguageLink> {
         let Some(by_lang) = self.translations.get(&page.translation_key) else {