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 {
|