sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit abcd30f05d6b951363d00228ed1be42c983497f8
parent c7a5d7c0f6d39fb50cd0cb4bc59ab5b9b2a5baa3
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 21:30:01 +0900

Search the documentation by substring, and build the shell to search from

Korean is the reason this is not Pagefind. Pagefind indexes words and matches
a prefix on the last one, which covers the ordinary Korean query — 생성기 is a
prefix of 생성기는 — and covers nothing else. It cannot find 최적화 inside
검색엔진최적화, because Korean writes compounds without spaces, and 존성 inside
의존성이 is invisible to it for the same reason. Segmenting the index with
ko-dic makes that worse rather than better: the dictionary does not know
loanwords, so 템플릿은 becomes 템플/릿/은 and a technical document stops being
searchable in its own vocabulary. Measured on a 2000-page corpus, recall for
템플릿 went from 1018 pages to 27.

Nor can the browser be taught to segment. Intl.Segmenter("ko") returns 어절
whole — ICU has dictionary breakers for zh/ja/th and none for Korean — so an
index of morphemes could only ever disagree with the query typed against it.

So the index is the page text and the client scans it for substrings. That is
the whole design, and it is what makes 존성, 이너리 and release (from inside a
fenced block) all return the page they are on. It costs the body text of every
page, which at documentation scale is 2.5 KB per language, fetched once when
the palette first opens, and no Rust dependency at all — the binary stays 7 MB
instead of the 15 to 84 MB the Pagefind variants measured.

The text comes off the AST, from the same parse that produces the HTML, so
nothing strips tags out of finished markup. Block starts emit a separator:
without it a heading ran into the code block under it and the index grew
`빌드git`, a word in no document, which in a substring index is a false hit
waiting to happen.

The palette is a <dialog>. Escape, the backdrop, the focus trap and restoring
focus to the trigger are the element's behaviour, not ours.

The page around it changed to match. The section list moved into the masthead
so the sidebar is no longer the only way across the documentation, pages carry
a breadcrumb, and the theme went dark-first with a single warm accent that
marks what you can go to and what you are on. That accent is warm on purpose:
it has to sit beside a syntax theme without arguing with it.

The theme now comes from [data-theme] written into <html> by the template
rather than from prefers-color-scheme. The generated highlight stylesheet
already keyed off that attribute, and a page that went dark by media query
while the stylesheet stayed light would have served light code on a dark page
to every reader whose OS is set to light — including with JavaScript off.

The index filename is not content-hashed, unlike every other generated asset.
Its contents come from the rendered pages and templates resolve asset URLs
before rendering, so hashing it would close a loop. It is data a script
fetches, and a stale copy costs a few search hits for ten minutes.

Diffstat:
Mdocs/static/css/main.css | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Adocs/static/js/search.js | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/templates/base.html | 47+++++++++++++++++++++++++++++++++++++++++------
Msrc/lib.rs | 42++++++++++++++++++++++++++++++++++++++----
Msrc/markdown.rs | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/render.rs | 2++
Asrc/search.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/site.rs | 29++++++++++++++++++++++-------
8 files changed, 663 insertion(+), 58 deletion(-)

diff --git a/docs/static/css/main.css b/docs/static/css/main.css
@@ -1,26 +1,36 @@
 /* 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.
+ * Dark first, and one accent. The page is otherwise achromatic, because the
+ * colour that matters is in the code: highlighting is the feature this tool
+ * does properly, and a second palette competing with it would be noise. The
+ * accent is warm on purpose — it sits away from the blues and greens of the
+ * syntax theme instead of arguing with them.
+ *
+ * The accent carries meaning, never decoration: links, the focus ring, the
+ * current item, and the rule under the page title. If it is orange, it is
+ * something you can go to or something you are on.
  *
  * 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.
+ *
+ * The theme is chosen by [data-theme], written into the document by the
+ * template and overridden from localStorage — not by prefers-color-scheme.
+ * The generated highlight stylesheet keys off the same attribute, so the two
+ * cannot disagree, including with JavaScript off.
  */
 
 :root {
-  color-scheme: light dark;
+  color-scheme: dark;
 
-  --paper:   #fbfbfc;
-  --raised:  #f1f3f5;
-  --ink:     #14171a;
-  --ink-2:   #4a5058;
-  --ink-3:   #767d86;
-  --rule:    #e2e5e9;
-  --accent:  #1946c9;
+  --paper:   #0e1013;
+  --raised:  #171a1e;
+  --ink:     #e8eaec;
+  --ink-2:   #a7adb4;
+  --ink-3:   #6f767e;
+  --rule:    #242930;
+  --accent:  #f0a15e;
 
   --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR",
           "Apple SD Gothic Neo", "Malgun Gothic", Roboto, sans-serif;
@@ -38,25 +48,18 @@
   --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;
+:root[data-theme="light"] {
+  color-scheme: light;
+
+  --paper:   #fbfbfc;
+  --raised:  #f1f3f5;
+  --ink:     #14171a;
+  --ink-2:   #4a5058;
+  --ink-3:   #767d86;
+  --rule:    #e2e5e9;
+  /* Darker than the dark theme's accent: the same orange that reads clearly on
+     near-black fails contrast on near-white. */
+  --accent:  #9a4e05;
 }
 
 *, *::before, *::after { box-sizing: border-box; }
@@ -99,7 +102,7 @@ a:hover { text-decoration-thickness: 2px; }
 .masthead {
   display: flex;
   align-items: baseline;
-  gap: 1rem;
+  gap: clamp(1rem, 3vw, 2.25rem);
   padding: 1.15rem var(--gutter);
   border-bottom: 1px solid var(--rule);
 }
@@ -114,7 +117,24 @@ a:hover { text-decoration-thickness: 2px; }
 }
 .brand:hover { color: var(--accent); }
 
-.masthead nav {
+/* The section list. Same data as the sidebar, one level up, so a reader can
+   cross between parts of the documentation without returning to the index. */
+.sections {
+  display: flex;
+  flex-wrap: wrap;
+  gap: clamp(0.75rem, 2vw, 1.5rem);
+  font-size: var(--s-1);
+}
+.sections a {
+  color: var(--ink-2);
+  text-decoration: none;
+  padding-bottom: 0.15rem;
+  border-bottom: 2px solid transparent;
+}
+.sections a:hover { color: var(--ink); }
+.sections a[aria-current] { color: var(--ink); border-bottom-color: var(--accent); }
+
+.tools {
   margin-left: auto;
   display: flex;
   align-items: center;
@@ -123,20 +143,53 @@ a:hover { text-decoration-thickness: 2px; }
   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; }
+.tools a { color: var(--ink-2); text-decoration: none; }
+.tools a:hover { color: var(--accent); text-decoration: underline; }
 
-.theme-toggle {
+.theme-toggle, .search-trigger {
   font: inherit;
   font-family: var(--mono);
   color: var(--ink-2);
   background: none;
   border: 1px solid var(--rule);
-  border-radius: 2px;
+  border-radius: 3px;
   padding: 0.15rem 0.5rem;
   cursor: pointer;
 }
-.theme-toggle:hover { color: var(--accent); border-color: currentColor; }
+.theme-toggle:hover, .search-trigger:hover { color: var(--accent); border-color: currentColor; }
+
+.search-trigger {
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+  min-width: 11rem;
+  text-align: left;
+}
+.search-trigger kbd {
+  margin-left: auto;
+  font: inherit;
+  font-size: 0.7rem;
+  color: var(--ink-3);
+  border: 1px solid var(--rule);
+  border-radius: 3px;
+  padding: 0 0.3rem;
+}
+
+/* ---------- breadcrumb ---------- */
+
+.crumbs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+  font-family: var(--mono);
+  font-size: 0.75rem;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  margin-bottom: 0.9rem;
+}
+.crumbs a { color: var(--ink-3); text-decoration: none; }
+.crumbs a:hover { color: var(--accent); }
+.crumbs a + a::before { content: "/"; margin-right: 0.5rem; color: var(--rule); }
 
 /* ---------- layout ---------- */
 
@@ -232,6 +285,17 @@ main { min-width: 0; max-width: var(--measure); }
   margin: 0;
 }
 
+/* A short accent rule under the title. It is the one place the page states
+   "this is where you are" in colour rather than in words. */
+.title-block h1::after {
+  content: "";
+  display: block;
+  width: 2.5rem;
+  height: 2px;
+  margin-top: 0.7rem;
+  background: var(--accent);
+}
+
 .title-block .lede {
   color: var(--ink-2);
   font-size: var(--s1);
@@ -445,6 +509,8 @@ 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"] .crumbs,
+html[lang="ko"] .search-crumb,
 html[lang="ko"] .prose .markdown-alert-title {
   font-family: var(--sans);
   letter-spacing: -0.01em;
@@ -457,6 +523,111 @@ html[lang="ko"] .prose h4 {
   font-weight: 700;
 }
 
+/* ---------- search palette ----------
+   A <dialog>. Escape, the backdrop, the focus trap and returning focus to the
+   trigger are all the element's own behaviour; none of it is written here. */
+
+.search-dialog {
+  border: none;
+  background: none;
+  padding: 0;
+  margin: 0 auto;
+  width: min(40rem, calc(100vw - 2rem));
+  max-width: none;
+  max-height: 100dvh;
+  margin-top: 12vh;
+}
+.search-dialog::backdrop { background: rgb(0 0 0 / 0.55); }
+
+.search-panel {
+  background: var(--paper);
+  border: 1px solid var(--rule);
+  border-radius: 8px;
+  box-shadow: 0 1.5rem 3rem rgb(0 0 0 / 0.35);
+  overflow: hidden;
+}
+
+.search-panel input {
+  width: 100%;
+  font: var(--s1)/1.4 var(--sans);
+  color: var(--ink);
+  background: none;
+  border: none;
+  border-bottom: 1px solid var(--rule);
+  padding: 1rem 1.15rem;
+}
+.search-panel input:focus { outline: none; }
+.search-panel input::placeholder { color: var(--ink-3); }
+/* The UA's clear button for type=search is a heavy glyph that sits at a
+   different weight from everything else here. Escape already clears. */
+.search-panel input::-webkit-search-cancel-button { display: none; }
+
+.search-results {
+  list-style: none;
+  margin: 0;
+  padding: 0.35rem;
+  max-height: min(26rem, 55dvh);
+  overflow-y: auto;
+}
+.search-results:empty { display: none; }
+
+.search-results a {
+  display: block;
+  padding: 0.6rem 0.8rem;
+  border-radius: 5px;
+  color: inherit;
+  text-decoration: none;
+}
+.search-results a[aria-selected] { background: var(--raised); }
+.search-results a[aria-selected] .search-title { color: var(--accent); }
+
+.search-crumb {
+  display: block;
+  font-family: var(--mono);
+  font-size: 0.7rem;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--ink-3);
+}
+.search-title {
+  display: block;
+  font-weight: 600;
+  line-height: 1.35;
+}
+.search-snippet {
+  display: block;
+  font-size: var(--s-1);
+  line-height: 1.5;
+  color: var(--ink-2);
+  /* Two lines is enough to show why a page matched. More turns the list into
+     a wall and hides the results below it. */
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+.search-results mark {
+  background: none;
+  color: var(--accent);
+  font-weight: 600;
+}
+
+.search-foot {
+  display: flex;
+  justify-content: space-between;
+  gap: 1rem;
+  border-top: 1px solid var(--rule);
+  padding: 0.5rem 1.15rem;
+  font-family: var(--mono);
+  font-size: 0.72rem;
+  color: var(--ink-3);
+}
+
+@media (max-width: 40rem) {
+  .search-trigger span { display: none; }
+  .search-trigger { min-width: 0; }
+}
+
 @media (prefers-reduced-motion: reduce) {
   *, *::before, *::after {
     animation-duration: 0.01ms !important;
diff --git a/docs/static/js/search.js b/docs/static/js/search.js
@@ -0,0 +1,206 @@
+/* sqzass documentation search.
+ *
+ * Substring matching over the page text, not a word index. Korean attaches
+ * particles to nouns and writes compounds without spaces, so a word index
+ * cannot find 최적화 inside 검색엔진최적화. Running a morphological analyser
+ * instead trades that for a worse problem: the dictionary does not know
+ * loanwords, and technical documentation is made of them.
+ *
+ * The dialog is a <dialog>. Escape, the backdrop and the focus trap are the
+ * platform's job, not ours.
+ */
+(function () {
+  var lang = (document.documentElement.lang || "en").split("-")[0];
+
+  var STRINGS = {
+    en: {
+      placeholder: "Search the documentation",
+      empty: "Type to search",
+      none: "No results for",
+      loading: "Loading index…",
+      count: function (n) { return n + (n === 1 ? " result" : " results"); },
+      /* Arrows survive every fallback stack; ↵ does not, and renders as a box
+         in the mono face this footer uses. Same reason the theme button says
+         "Theme" instead of a moon. */
+      hint: "↑↓ move · enter open · esc close",
+    },
+    ko: {
+      placeholder: "문서 검색",
+      empty: "검색어를 입력하세요",
+      none: "결과가 없습니다:",
+      loading: "색인을 불러오는 중…",
+      count: function (n) { return n + "건"; },
+      hint: "↑↓ 이동 · enter 열기 · esc 닫기",
+    },
+  };
+  var T = STRINGS[lang] || STRINGS.en;
+
+  var dialog = document.getElementById("search-dialog");
+  var trigger = document.getElementById("search-trigger");
+  if (!dialog || !trigger) return;
+
+  var input = dialog.querySelector("input");
+  var list = dialog.querySelector(".search-results");
+  var status = dialog.querySelector(".search-status");
+  input.placeholder = T.placeholder;
+  dialog.querySelector(".search-hint").textContent = T.hint;
+
+  var rows = null;
+  var pending = false;
+  var active = 0;
+
+  /* The index is the whole body text of every page in this language. It is
+     only worth downloading once someone actually opens the palette. */
+  function load() {
+    if (rows || pending) return;
+    pending = true;
+    status.textContent = T.loading;
+    fetch(trigger.dataset.index)
+      .then(function (r) { return r.json(); })
+      .then(function (data) { rows = data; pending = false; render(); })
+      .catch(function () { pending = false; status.textContent = "!"; });
+  }
+
+  function esc(s) {
+    return s.replace(/[&<>"]/g, function (c) {
+      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
+    });
+  }
+
+  /* Every term has to appear somewhere in the row — an AND, not an OR. Two
+     words that each match a different page are not a match. */
+  function score(row, terms) {
+    var title = row.t.toLowerCase();
+    var desc = (row.d || "").toLowerCase();
+    var body = row.c.toLowerCase();
+    var total = 0;
+    for (var i = 0; i < terms.length; i++) {
+      var q = terms[i];
+      var inTitle = title.indexOf(q);
+      var inDesc = desc.indexOf(q);
+      var inBody = body.indexOf(q);
+      if (inTitle < 0 && inDesc < 0 && inBody < 0) return 0;
+      if (inTitle === 0) total += 200;
+      else if (inTitle > 0) total += 120;
+      if (inDesc >= 0) total += 40;
+      if (inBody >= 0) total += 20;
+    }
+    return total;
+  }
+
+  /* A window of body text around the first hit, so the result shows why it
+     matched rather than just that it did. */
+  function snippet(text, term) {
+    var at = text.toLowerCase().indexOf(term);
+    if (at < 0) return "";
+    var from = Math.max(0, at - 60);
+    var to = Math.min(text.length, at + term.length + 90);
+    var cut = text.slice(from, to);
+    return (from > 0 ? "…" : "") + cut + (to < text.length ? "…" : "");
+  }
+
+  function mark(text, terms) {
+    var out = esc(text);
+    for (var i = 0; i < terms.length; i++) {
+      var q = terms[i];
+      if (!q) continue;
+      var lower = out.toLowerCase();
+      var built = "";
+      var at;
+      var rest = out;
+      var restLower = lower;
+      while ((at = restLower.indexOf(q)) >= 0) {
+        built += rest.slice(0, at) + "<mark>" + rest.slice(at, at + q.length) + "</mark>";
+        rest = rest.slice(at + q.length);
+        restLower = rest.toLowerCase();
+      }
+      out = built + rest;
+    }
+    return out;
+  }
+
+  function render() {
+    var raw = input.value.trim();
+    if (!raw) {
+      list.innerHTML = "";
+      status.textContent = T.empty;
+      return;
+    }
+    if (!rows) { load(); return; }
+
+    var terms = raw.toLowerCase().split(/\s+/);
+    var hits = [];
+    for (var i = 0; i < rows.length; i++) {
+      var s = score(rows[i], terms);
+      if (s > 0) hits.push({ row: rows[i], score: s });
+    }
+    hits.sort(function (a, b) { return b.score - a.score; });
+    hits = hits.slice(0, 12);
+
+    status.textContent = hits.length ? T.count(hits.length) : T.none + ' "' + raw + '"';
+    active = 0;
+    list.innerHTML = hits
+      .map(function (h, i) {
+        var r = h.row;
+        var body = snippet(r.c, terms[0]) || r.d || "";
+        return (
+          '<li><a href="' + esc(r.u) + '"' + (i === 0 ? ' aria-selected="true"' : "") + ">" +
+          (r.s ? '<span class="search-crumb">' + esc(r.s) + "</span>" : "") +
+          '<span class="search-title">' + mark(r.t, terms) + "</span>" +
+          '<span class="search-snippet">' + mark(body, terms) + "</span>" +
+          "</a></li>"
+        );
+      })
+      .join("");
+  }
+
+  function move(delta) {
+    var items = list.querySelectorAll("a");
+    if (!items.length) return;
+    items[active].removeAttribute("aria-selected");
+    active = (active + delta + items.length) % items.length;
+    items[active].setAttribute("aria-selected", "true");
+    items[active].scrollIntoView({ block: "nearest" });
+  }
+
+  function open() {
+    if (dialog.open) return;
+    dialog.showModal();
+    input.value = "";
+    render();
+    load();
+    input.focus();
+  }
+
+  trigger.addEventListener("click", open);
+
+  document.addEventListener("keydown", function (e) {
+    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
+      e.preventDefault();
+      dialog.open ? dialog.close() : open();
+    }
+  });
+
+  input.addEventListener("input", render);
+
+  dialog.addEventListener("keydown", function (e) {
+    if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
+    else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
+    else if (e.key === "Enter") {
+      var items = list.querySelectorAll("a");
+      if (items.length) { e.preventDefault(); location.href = items[active].href; }
+    }
+  });
+
+  /* Clicking the backdrop closes. A <dialog>'s backdrop is part of the dialog
+     element, so the target being the dialog itself means the click missed the
+     panel inside it. */
+  dialog.addEventListener("click", function (e) {
+    if (e.target === dialog) dialog.close();
+  });
+
+  /* The shortcut hint is written here, not in the template, because it is a
+     property of the reader's keyboard rather than of the page. */
+  var mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
+  trigger.querySelector("kbd").textContent = mac ? "⌘K" : "Ctrl K";
+})();
diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -3,7 +3,11 @@
 {%- set ko = page.language == "ko" %}
 {%- set home = "/ko/" if ko else "/" %}
 <!doctype html>
-<html lang="{{ page.language }}">
+{#- The default is written here rather than left to `prefers-color-scheme`, so
+    a reader with a light OS still gets the dark page the design assumes — and,
+    more importantly, so the generated highlight stylesheet's `[data-theme]`
+    rules match without JavaScript. The script below only overrides it. #}
+<html lang="{{ page.language }}" data-theme="dark">
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -21,6 +25,7 @@
 /* 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>
@@ -28,7 +33,21 @@
 
 <header class="masthead">
   <a class="brand" href="{{ home }}">{{ site.title }}</a>
-  <nav>
+
+  {#- The top nav is the section list. It is the same data the sidebar draws,
+      one level up, so a reader can move between parts of the documentation
+      without going back to the index. #}
+  <nav class="sections" aria-label="{{ "문서 갈래" if ko else "Sections" }}">
+    {%- for s in site.sections %}
+    <a href="{{ s.url }}"{% if page.section and page.section.url == s.url or page.url == s.url %} aria-current="true"{% endif %}>{{ s.title }}</a>
+    {%- endfor %}
+  </nav>
+
+  <div class="tools">
+    <button id="search-trigger" class="search-trigger" type="button"
+            data-index="/search-{{ page.language }}.json">
+      <span>{{ "검색" if ko else "Search" }}</span><kbd>Ctrl K</kbd>
+    </button>
     {%- for t in page.translations %}
     <a href="{{ t.url }}" hreflang="{{ t.code }}">{{ t.name }}</a>
     {%- endfor %}
@@ -36,7 +55,7 @@
     {#- 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>
+  </div>
 </header>
 
 <div class="layout">
@@ -46,6 +65,12 @@
 
   <main id="content">
     <div class="title-block">
+      {%- if page.section %}
+      <nav class="crumbs" aria-label="{{ "현재 위치" if ko else "Breadcrumb" }}">
+        <a href="{{ home }}">{{ "홈" if ko else "Home" }}</a>
+        <a href="{{ page.section.url }}">{{ page.section.title }}</a>
+      </nav>
+      {%- endif %}
       <h1>{{ page.title }}</h1>
       {%- if page.description %}
       <p class="lede">{{ page.description }}</p>
@@ -72,15 +97,25 @@
   <span>MIT</span>
 </footer>
 
+<dialog id="search-dialog" class="search-dialog" aria-label="{{ "문서 검색" if ko else "Search the documentation" }}">
+  <div class="search-panel">
+    <input type="search" autocomplete="off" spellcheck="false" aria-label="{{ "문서 검색" if ko else "Search the documentation" }}">
+    <ul class="search-results"></ul>
+    <div class="search-foot">
+      <span class="search-status"></span>
+      <span class="search-hint"></span>
+    </div>
+  </div>
+</dialog>
+
 <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";
+  var next = root.dataset.theme === "light" ? "dark" : "light";
   root.dataset.theme = next;
   try { localStorage.setItem("theme", next); } catch (e) {}
 });
 </script>
+<script src="{{ asset("js/search.js") }}" defer></script>
 </body>
 </html>
diff --git a/src/lib.rs b/src/lib.rs
@@ -7,6 +7,7 @@ pub mod highlight;
 pub mod links;
 pub mod markdown;
 pub mod render;
+pub mod search;
 pub mod serve;
 pub mod site;
 
@@ -91,8 +92,11 @@ pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
     let mut out = BuildOutput::default();
     out.files.extend(assets.files);
 
+    // 언어 코드 → 그 언어의 검색 색인 행들.
+    let mut index: BTreeMap<String, Vec<search::Entry>> = BTreeMap::new();
+
     for page in &pages {
-        let html = render_page(
+        let (html, entry) = render_page(
             page,
             &site,
             &cfg,
@@ -104,6 +108,13 @@ pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
             page.out_path.to_string_lossy().replace('\\', "/"),
             html.into_bytes(),
         );
+        index.entry(page.language.clone()).or_default().push(entry);
+    }
+
+    for (language, entries) in index {
+        let json = serde_json::to_vec(&entries)
+            .with_context(|| format!("{language} 검색 색인을 만들 수 없습니다"))?;
+        out.files.insert(search::path(&language), json);
     }
 
     // GitHub Pages는 이 파일이 없으면 출력을 Jekyll로 한 번 더 굴려서
@@ -149,6 +160,8 @@ fn resolve_output_dir(opts: &BuildOptions, cfg: &Config) -> PathBuf {
     }
 }
 
+/// 페이지 HTML과 그 페이지의 검색 색인 행을 함께 낸다. 본문 평문은 렌더 과정에서
+/// 이미 나오므로, 색인을 위해 문서를 다시 파싱하지 않는다.
 fn render_page(
     page: &Page,
     site: &Site,
@@ -156,7 +169,7 @@ fn render_page(
     templates: &Templates,
     md: &markdown::Renderer,
     highlight_css: Option<&str>,
-) -> Result<String> {
+) -> Result<(String, search::Entry)> {
     let template = select_template(page, site, templates)?;
 
     let site_ctx = SiteCtx {
@@ -178,6 +191,24 @@ fn render_page(
         );
     }
 
+    // 섹션 인덱스는 자기 자신이 섹션이라 라벨과 제목이 겹친다. 그때는 비워 둔다.
+    let section = if page.is_section {
+        None
+    } else {
+        site.section_ref_of(page)
+    };
+
+    let entry = search::Entry {
+        t: page.title.clone(),
+        d: page.front.description.clone(),
+        u: page.url.clone(),
+        s: section
+            .as_ref()
+            .map(|s| s.title.clone())
+            .unwrap_or_default(),
+        c: rendered.text.clone(),
+    };
+
     let page_ctx = PageCtx {
         title: page.title.clone(),
         description: page.front.description.clone(),
@@ -194,12 +225,15 @@ fn render_page(
         // 언어 전환 UI를 보일지 결정하면 된다.
         translations: site.translations_of(page),
         children: site.children_of(page),
+        section,
         is_section: page.is_section,
     };
 
-    templates
+    let html = templates
         .render(&template, context! { site => site_ctx, page => page_ctx })
-        .with_context(|| format!("{} 렌더 중", page.source.display()))
+        .with_context(|| format!("{} 렌더 중", page.source.display()))?;
+
+    Ok((html, entry))
 }
 
 /// 템플릿 선택은 **명시적**이다. 순서는 딱 넷:
diff --git a/src/markdown.rs b/src/markdown.rs
@@ -6,9 +6,9 @@
 
 use crate::config::{HeadingAnchors, Markdown as MarkdownConfig};
 use comrak::adapters::{HeadingAdapter, HeadingMeta};
-use comrak::nodes::Sourcepos;
+use comrak::nodes::{AstNode, NodeValue, Sourcepos};
 use comrak::options::Plugins;
-use comrak::{Anchorizer, Options, markdown_to_html_with_plugins};
+use comrak::{Anchorizer, Arena, Options, format_html_with_plugins, parse_document};
 use serde::Serialize;
 use std::fmt;
 use std::sync::Mutex;
@@ -25,6 +25,9 @@ pub struct TocEntry {
 pub struct Rendered {
     pub html: String,
     pub toc: Vec<TocEntry>,
+    /// 검색 색인이 먹을 본문 평문. HTML을 벗겨 만드는 게 아니라 AST에서 모은다 —
+    /// 완성된 HTML에 정규식을 돌리지 않는다는 원칙은 여기에도 적용된다.
+    pub text: String,
     /// 해석하지 못한 `@/` 링크. 비어 있지 않으면 빌드를 멈춰야 한다 —
     /// 깨진 링크를 조용히 배포하는 건 이 도구가 막겠다고 한 바로 그 부류다.
     pub unresolved: Vec<String>,
@@ -105,13 +108,22 @@ impl Renderer {
             options.extension.image_url_rewriter = Some(r.clone());
         }
 
+        // 한 번만 파싱해서 HTML과 평문을 같은 트리에서 뽑는다. 링크 재작성기는
+        // 렌더 단계에서 도므로 평문 수집이 그 결과에 영향을 받지 않는다.
+        let arena = Arena::new();
+        let root = parse_document(&arena, body, &options);
+        let text = plain_text(root);
+
         let html = {
             let mut plugins = Plugins::default();
             plugins.render.heading_adapter = Some(&headings);
             if let Some(h) = &self.highlighter {
                 plugins.render.codefence_syntax_highlighter = Some(h);
             }
-            markdown_to_html_with_plugins(body, &options, &plugins)
+            let mut out = String::new();
+            format_html_with_plugins(root, &options, &mut out, &plugins)
+                .expect("String에 쓰는 건 실패하지 않는다");
+            out
         };
 
         let unresolved = resolver
@@ -122,11 +134,42 @@ impl Renderer {
         Rendered {
             unresolved,
             html,
+            text,
             toc: headings.into_toc(),
         }
     }
 }
 
+/// AST에서 검색용 평문을 모은다.
+///
+/// 코드 블록은 넣는다 — 문서 사이트에서 사람들은 명령어 이름으로 검색한다.
+/// front matter와 raw HTML은 뺀다: 전자는 메타데이터고 후자는 태그라 본문이 아니다.
+fn plain_text<'a>(root: &'a AstNode<'a>) -> String {
+    let mut out = String::new();
+    for node in root.descendants() {
+        match &node.data.borrow().value {
+            NodeValue::Text(t) => out.push_str(t),
+            // 인라인 코드는 앞뒤 Text가 이미 띄어쓰기를 갖고 있으므로 그대로 붙인다.
+            NodeValue::Code(c) => out.push_str(&c.literal),
+            NodeValue::CodeBlock(c) => {
+                out.push(' ');
+                out.push_str(&c.literal);
+            }
+            // 블록이 시작하는 자리마다 공백을 넣는다. 없으면 앞 블록의 끝과 다음
+            // 블록의 시작이 붙어 `빌드git` 같은, 문서에 없는 단어가 색인에 생긴다.
+            NodeValue::Paragraph
+            | NodeValue::Heading(_)
+            | NodeValue::Item(_)
+            | NodeValue::TableCell
+            | NodeValue::SoftBreak
+            | NodeValue::LineBreak => out.push(' '),
+            _ => {}
+        }
+    }
+    // 공백을 하나로 접는다. 인덱스가 커질 이유가 없다.
+    out.split_whitespace().collect::<Vec<_>>().join(" ")
+}
+
 /// heading에 id를 붙이고 anchor 링크를 그리면서, 동시에 목차를 모은다.
 ///
 /// **Anchorizer 인스턴스를 하나만 쓰는 게 핵심이다.** 같은 제목이 두 번 나오면
@@ -369,6 +412,39 @@ mod tests {
         assert_eq!(r.toc[0].title, "This is bold");
     }
 
+    #[test]
+    fn plain_text_separates_blocks() {
+        // 블록 경계에 공백이 없으면 `빌드git` 같은, 문서 어디에도 없는 단어가
+        // 검색 색인에 생긴다. 부분 문자열로 찾는 색인에서는 그게 곧 오검색이다.
+        let r = renderer().render("## 소스에서 빌드\n\n```bash\ngit clone x\n```\n\n다음 단락.");
+        assert!(!r.text.contains("빌드git"), "블록이 붙었다: {}", r.text);
+        assert!(
+            r.text.contains("소스에서 빌드 git clone x"),
+            "실제: {}",
+            r.text
+        );
+        assert!(r.text.contains("다음 단락."), "실제: {}", r.text);
+    }
+
+    #[test]
+    fn plain_text_keeps_inline_code_attached() {
+        // 인라인 코드는 앞뒤 Text가 띄어쓰기를 갖고 있다. 여기에 공백을 더 넣으면
+        // 한국어 조사가 코드에서 떨어져 나가 `sqzass 에`가 된다.
+        let r = renderer().render("바이너리는 `target/release/sqzass`에 생긴다.");
+        assert!(
+            r.text.contains("target/release/sqzass에"),
+            "실제: {}",
+            r.text
+        );
+    }
+
+    #[test]
+    fn plain_text_has_no_markup() {
+        let r = renderer().render("## Title\n\nSome **bold** and a [link](https://example.com).");
+        assert!(!r.text.contains('<'), "태그가 남았다: {}", r.text);
+        assert!(r.text.contains("Some bold and a link."), "실제: {}", r.text);
+    }
+
     #[test]
     fn anchors_can_be_disabled_but_ids_remain() {
         let cfg = MarkdownConfig {
diff --git a/src/render.rs b/src/render.rs
@@ -210,6 +210,8 @@ pub struct PageCtx {
     pub translations: Vec<crate::site::LanguageLink>,
     /// 섹션 페이지의 직속 자식. 일반 페이지에서는 비어 있다.
     pub children: Vec<crate::site::PageRefCtx>,
+    /// 이 페이지가 속한 섹션. 최상위 페이지에는 없다 — 브레드크럼이 쓴다.
+    pub section: Option<crate::site::PageRefCtx>,
     /// 섹션 인덱스인지. 템플릿이 목록을 그릴지 판단한다.
     pub is_section: bool,
 }
diff --git a/src/search.rs b/src/search.rs
@@ -0,0 +1,66 @@
+//! 검색 색인 — 언어마다 JSON 한 벌.
+//!
+//! **부분 문자열로 찾는다. 형태소 분석도, 단어 색인도 쓰지 않는다.**
+//! 한국어는 명사에 조사가 붙어 어절이 되고 합성어를 붙여 쓰기 때문에, 단어 단위로
+//! 쪼갠 색인은 `검색엔진최적화` 안의 `최적화`를 영영 못 찾는다. 형태소 분석기를
+//! 붙이면 이번엔 사전에 없는 외래어가 뭉개진다(`템플릿` → `템플`+`릿`) — 기술 문서엔
+//! 치명적이다. 원문을 그대로 두고 부분 문자열로 훑으면 둘 다 걸린다.
+//!
+//! 대가는 색인이 본문 전체라는 것이다. 문서 사이트 규모에서 이건 문제가 아니고,
+//! 브라우저는 첫 검색 때 한 번만 받는다.
+//!
+//! 파일명에 해시를 붙이지 않는다. 색인은 `<link>`나 `<script>`가 아니라 JS가 런타임에
+//! 가져가는 데이터고, 게다가 색인 내용은 렌더된 페이지에서 나오는데 템플릿은 렌더
+//! 전에 에셋 URL을 조회한다 — 해시를 붙이면 순환이 된다. 잠깐 옛 색인이 뜨는 건
+//! 검색 결과 몇 줄의 문제라 그 대가를 치를 이유가 없다.
+
+use serde::Serialize;
+
+/// 색인 한 행. 키를 한 글자로 줄인 건 취향이 아니라 크기다 — 행마다 반복되므로
+/// `"title"` 대신 `"t"`를 쓰면 수백 페이지에서 수십 KB가 빠진다.
+#[derive(Debug, Clone, Serialize)]
+pub struct Entry {
+    /// 제목
+    pub t: String,
+    /// 설명. 없으면 통째로 뺀다.
+    #[serde(skip_serializing_if = "String::is_empty")]
+    pub d: String,
+    /// URL
+    pub u: String,
+    /// 소속 섹션 제목. 최상위 페이지엔 없다.
+    #[serde(skip_serializing_if = "String::is_empty")]
+    pub s: String,
+    /// 본문 평문
+    pub c: String,
+}
+
+/// 해당 언어 색인의 출력 경로. 클라이언트가 `<html lang>`으로 같은 이름을 만든다.
+pub fn path(language: &str) -> String {
+    format!("search-{language}.json")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn path_is_per_language() {
+        assert_eq!(path("ko"), "search-ko.json");
+        assert_eq!(path("en"), "search-en.json");
+    }
+
+    #[test]
+    fn empty_optional_fields_are_omitted() {
+        let e = Entry {
+            t: "설치".into(),
+            d: String::new(),
+            u: "/ko/start/installation/".into(),
+            s: String::new(),
+            c: "소스에서 빌드합니다".into(),
+        };
+        let json = serde_json::to_string(&e).unwrap();
+        assert!(!json.contains("\"d\""), "빈 설명이 나갔다: {json}");
+        assert!(!json.contains("\"s\""), "빈 섹션이 나갔다: {json}");
+        assert!(json.contains("\"t\":\"설치\""), "실제: {json}");
+    }
+}
diff --git a/src/site.rs b/src/site.rs
@@ -72,19 +72,34 @@ impl<'a> Site<'a> {
             .unwrap_or(&[])
     }
 
-    /// 페이지가 속한 섹션의 `page_template`. 템플릿 선택 단계에서 쓴다.
-    pub fn page_template_for(&self, page: &Page) -> Option<&str> {
-        let dirs = page.rel.parent()?;
-        let dirs: Vec<String> = dirs
+    /// 페이지가 속한 섹션. 최상위 페이지는 소속 섹션이 없다.
+    fn section_of(&self, page: &Page) -> Option<&Section> {
+        let dirs: Vec<String> = page
+            .rel
+            .parent()?
             .components()
             .map(|c| c.as_os_str().to_string_lossy().into_owned())
             .collect();
         if dirs.is_empty() {
             return None;
         }
-        find_section(self.sections(&page.language), &dirs)?
-            .page_template
-            .as_deref()
+        find_section(self.sections(&page.language), &dirs)
+    }
+
+    /// 페이지가 속한 섹션의 `page_template`. 템플릿 선택 단계에서 쓴다.
+    pub fn page_template_for(&self, page: &Page) -> Option<&str> {
+        self.section_of(page)?.page_template.as_deref()
+    }
+
+    /// 페이지가 속한 섹션을 참조 형태로. 브레드크럼과 검색 결과의 갈래 라벨이 쓴다.
+    pub fn section_ref_of(&self, page: &Page) -> Option<PageRefCtx> {
+        let s = self.section_of(page)?;
+        Some(PageRefCtx {
+            title: s.title.clone(),
+            description: s.description.clone(),
+            url: s.url.clone(),
+            weight: s.weight,
+        })
     }
 
     /// `@/` 링크 해석용 표. translation_key → (언어 → URL)