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 { "&": "&", "<": "<", ">": ">", '"': """ }[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)
|