sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit ad38e2ef2022e57f1f43e5d52c5f16158fbcb056
parent 7fc540119b8f115a365518eff731759fca56d741
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 22:27:47 +0900

Make an unknown config or front matter key an error

A key sqzass does not read is a key that does nothing. Accepting it silently is
the same failure as accepting a broken link: you asked for something, the tool
agreed, and nothing happened. Someone who writes `theme_ligth` has changed their
theme as far as they know, and will spend the afternoon looking for the reason
it did not take.

There was already a check, and it had three holes. It warned rather than failed,
so the build succeeded with the setting ignored. It only looked at top-level
keys, so `[highlight] theme_ligth` sailed through. And it printed twice, because
the build loaded the config twice — which reads as two problems in your file
rather than one.

serde's `deny_unknown_fields` covers all of it and deletes the hand-maintained
list of valid names along the way. That list was a copy of the struct fields
kept in sync by hand; it would have drifted the first time someone added a key
and forgotten. The generated message is also better than what it replaced —
line, column, the offending key and the candidates, where the old one had no
position at all.

Front matter is held to the same rule, and its errors now point at the line in
your file rather than one above it. Counting from the top of the file is the
reason the fence is split by hand in the first place, and that had never been
wired through to the parse error.

The new check immediately found `[highlight] line_numbers` in this repository's
own docs/sqzass.toml — a key removed from the code some time ago and left
behind in the configuration, doing nothing, exactly as predicted.

Diffstat:
Adocs/content/start/configuration.ko.md | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/start/configuration.md | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/sqzass.toml | 1-
Msrc/config.rs | 120+++++++++++++++++++++++++++----------------------------------------------------
Msrc/content.rs | 32++++++++++++++++++++++++++++++--
Msrc/lib.rs | 19+++++++++++++++----
6 files changed, 321 insertion(+), 86 deletion(-)

diff --git a/docs/content/start/configuration.ko.md b/docs/content/start/configuration.ko.md
@@ -0,0 +1,117 @@
++++
+title = "설정"
+description = "sqzass.toml의 모든 키와, 오타를 냈을 때 벌어지는 일"
+weight = 30
+toc = true
++++
+
+`sqzass.toml`은 사이트 루트에 둡니다. 반드시 있어야 하는 키는 둘이고, 나머지는
+전부 기본값이 있어 적지 않으면 그 기본값으로 동작합니다.
+
+```toml
+title    = "내 사이트"
+base_url = "https://example.com"
+```
+
+## 오타는 에러입니다
+
+```
+error: sqzass.toml 파싱 실패: TOML parse error at line 8, column 1
+  |
+8 | theme_ligth = "InspiredGitHub"
+  | ^^^^^^^^^^^
+unknown field `theme_ligth`, expected one of `enabled`, `theme_light`, `theme_dark`
+```
+
+sqzass가 읽지 않는 키는 아무 일도 하지 않는 키이고, 조용히 아무 일도 하지 않는
+설정은 깨진 링크와 같은 종류의 실패입니다. 무언가를 요청했고, 도구는 알겠다고 했고,
+아무 일도 일어나지 않았습니다. 테마가 왜 안 바뀌는지 찾느라 오후를 쓰게 됩니다.
+
+front matter도 마찬가지이고, 에러가 가리키는 줄 번호는 front matter 블록 기준이
+아니라 **파일 기준**입니다.
+
+## 사이트
+
+| 키 | 기본값 | |
+|---|---|---|
+| `title` | — | **필수.** |
+| `base_url` | — | **필수.** canonical URL, sitemap, `robots.txt`가 씁니다. |
+| `description` | `""` | |
+| `default_language` | `"en"` | 이 언어가 루트를 쓰고, 나머지는 URL 접두사를 갖습니다. |
+
+## `[languages.<코드>]`
+
+```toml
+[languages.en]
+name   = "English"
+weight = 1
+
+[languages.ko]
+name   = "한국어"
+weight = 2
+```
+
+`name`은 언어 전환 UI에 보이는 이름이고, `weight`는 순서입니다. 언어를 아예
+선언하지 않아도 됩니다. 그러면 `default_language` 하나로 도는 단일 언어 사이트가
+됩니다. [언어](@/content/languages.md)를 참고하세요.
+
+## `[build]`
+
+| 키 | 기본값 | |
+|---|---|---|
+| `output_dir` | `"public"` | 사이트 루트 기준. |
+| `drafts` | `false` | 명령줄의 `--drafts`가 이걸 켭니다. |
+
+## `[markdown]`
+
+| 키 | 기본값 | |
+|---|---|---|
+| `footnotes` | `true` | |
+| `tables` | `true` | |
+| `tasklist` | `true` | |
+| `strikethrough` | `true` | |
+| `autolink` | `true` | |
+| `alerts` | `true` | GitHub의 `> [!NOTE]` 콜아웃. |
+| `cjk_friendly_emphasis` | `true` | **한국어라면 켜 두세요.** |
+| `heading_anchors` | `"right"` | `none`, `left`, `right`. |
+
+`cjk_friendly_emphasis`가 `**강조**한다`를 강조로 파싱되게 하는 키입니다.
+CommonMark의 flanking 규칙은 단어 사이에 공백을 두는 언어를 전제로 하고, 이게
+없으면 굵게 표시한 부분 바로 뒤에 조사가 붙은 형태는 강조가 아예 아닙니다. 끄면
+한국어 본문이 "내 마크다운이 틀렸나" 싶은 모양으로 깨집니다.
+[마크다운](@/content/markdown.md)을 참고하세요.
+
+## `[highlight]`
+
+| 키 | 기본값 | |
+|---|---|---|
+| `enabled` | `true` | |
+| `theme_light` | `"InspiredGitHub"` | |
+| `theme_dark` | `"base16-ocean.dark"` | |
+
+없는 테마 이름을 쓰면 에러이고, 메시지에 쓸 수 있는 이름들이 같이 나옵니다.
+[구문 강조](@/features/highlighting.md)를 참고하세요.
+
+## `[assets]`
+
+| 키 | 기본값 | |
+|---|---|---|
+| `source_dir` | `"static"` | |
+| `fingerprint` | `true` | CSS와 JS의 파일명에 콘텐츠 해시를 붙입니다. |
+
+[정적 에셋](@/templates/assets.md)을 참고하세요.
+
+## `[nav]`
+
+| 키 | 기본값 | |
+|---|---|---|
+| `sort_by` | `"weight"` | `weight` 또는 `title`. 섹션이 각자 덮어쓸 수 있습니다. |
+
+## 명령줄에서 덮어쓰기
+
+`--base-url`과 `--drafts`는 파일보다 우선합니다. 설정 하나로 프리뷰 배포와
+프로덕션을 함께 다룰 수 있는 이유입니다.
+
+```bash
+sqzass build -i mysite --base-url https://preview.example.com --drafts
+```
diff --git a/docs/content/start/configuration.md b/docs/content/start/configuration.md
@@ -0,0 +1,118 @@
++++
+title = "Configuration"
+description = "Every key in sqzass.toml, and what happens when you misspell one"
+weight = 30
+toc = true
++++
+
+`sqzass.toml` sits at the site root. Two keys are required; everything else has
+a default and behaves as if you had written the default.
+
+```toml
+title    = "My site"
+base_url = "https://example.com"
+```
+
+## A typo is an error
+
+```
+error: sqzass.toml 파싱 실패: TOML parse error at line 8, column 1
+  |
+8 | theme_ligth = "InspiredGitHub"
+  | ^^^^^^^^^^^
+unknown field `theme_ligth`, expected one of `enabled`, `theme_light`, `theme_dark`
+```
+
+A key sqzass does not read is a key that does nothing, and a setting that
+silently does nothing is the same failure as a broken link: you asked for
+something, the tool agreed, and nothing happened. You would spend the afternoon
+wondering why your theme did not change.
+
+The same applies in front matter, and the line number points into your file
+rather than into the front matter block.
+
+## Site
+
+| Key | Default | |
+|---|---|---|
+| `title` | — | **Required.** |
+| `base_url` | — | **Required.** Used for canonical URLs, the sitemap and `robots.txt`. |
+| `description` | `""` | |
+| `default_language` | `"en"` | This language lives at the root; others get a URL prefix. |
+
+## `[languages.<code>]`
+
+```toml
+[languages.en]
+name   = "English"
+weight = 1
+
+[languages.ko]
+name   = "한국어"
+weight = 2
+```
+
+`name` is what a language switcher shows. `weight` orders them. Declaring no
+languages at all is fine — the site is then single-language under
+`default_language`. See [Languages](@/content/languages.md).
+
+## `[build]`
+
+| Key | Default | |
+|---|---|---|
+| `output_dir` | `"public"` | Relative to the site root. |
+| `drafts` | `false` | `--drafts` sets this from the command line. |
+
+## `[markdown]`
+
+| Key | Default | |
+|---|---|---|
+| `footnotes` | `true` | |
+| `tables` | `true` | |
+| `tasklist` | `true` | |
+| `strikethrough` | `true` | |
+| `autolink` | `true` | |
+| `alerts` | `true` | GitHub's `> [!NOTE]` callouts. |
+| `cjk_friendly_emphasis` | `true` | **Leave this on for Korean.** |
+| `heading_anchors` | `"right"` | `none`, `left` or `right`. |
+
+`cjk_friendly_emphasis` is what makes `**강조**한다` parse as emphasis.
+CommonMark's flanking rules assume spaces around words, and without this a bold
+run followed immediately by a Korean particle is not emphasis at all. Turning it
+off breaks Korean text in a way that reads like your markdown is wrong. See
+[Markdown](@/content/markdown.md).
+
+## `[highlight]`
+
+| Key | Default | |
+|---|---|---|
+| `enabled` | `true` | |
+| `theme_light` | `"InspiredGitHub"` | |
+| `theme_dark` | `"base16-ocean.dark"` | |
+
+A theme name that does not exist is an error, and the message lists the ones
+that do. See [Syntax highlighting](@/features/highlighting.md).
+
+## `[assets]`
+
+| Key | Default | |
+|---|---|---|
+| `source_dir` | `"static"` | |
+| `fingerprint` | `true` | Content hash in the filename, for CSS and JS. |
+
+See [Static assets](@/templates/assets.md).
+
+## `[nav]`
+
+| Key | Default | |
+|---|---|---|
+| `sort_by` | `"weight"` | `weight` or `title`. A section can override it. |
+
+## Overriding from the command line
+
+`--base-url` and `--drafts` win over the file, which is what makes one
+configuration serve both a preview deployment and production.
+
+```bash
+sqzass build -i mysite --base-url https://preview.example.com --drafts
+```
diff --git a/docs/sqzass.toml b/docs/sqzass.toml
@@ -28,7 +28,6 @@ heading_anchors       = "right"
 [highlight]
 theme_light  = "InspiredGitHub"
 theme_dark   = "base16-ocean.dark"
-line_numbers = false
 
 [assets]
 source_dir  = "static"
diff --git a/src/config.rs b/src/config.rs
@@ -10,20 +10,9 @@ use std::path::Path;
 pub const CONFIG_FILE: &str = "sqzass.toml";
 
 /// `sqzass.toml`이 가질 수 있는 최상위 키. 오타 감지에 쓴다.
-const KNOWN_TOP_LEVEL: &[&str] = &[
-    "title",
-    "description",
-    "base_url",
-    "default_language",
-    "languages",
-    "build",
-    "markdown",
-    "highlight",
-    "assets",
-    "nav",
-];
 
 #[derive(Debug, Clone, Deserialize)]
+#[serde(deny_unknown_fields)]
 pub struct Config {
     pub title: String,
     #[serde(default)]
@@ -47,6 +36,7 @@ pub struct Config {
 }
 
 #[derive(Debug, Clone, Deserialize)]
+#[serde(deny_unknown_fields)]
 pub struct Language {
     pub name: String,
     #[serde(default)]
@@ -54,7 +44,7 @@ pub struct Language {
 }
 
 #[derive(Debug, Clone, Deserialize)]
-#[serde(default)]
+#[serde(default, deny_unknown_fields)]
 pub struct Build {
     pub output_dir: String,
     pub drafts: bool,
@@ -70,7 +60,7 @@ impl Default for Build {
 }
 
 #[derive(Debug, Clone, Deserialize)]
-#[serde(default)]
+#[serde(default, deny_unknown_fields)]
 pub struct Markdown {
     pub footnotes: bool,
     pub tables: bool,
@@ -109,7 +99,7 @@ pub enum HeadingAnchors {
 }
 
 #[derive(Debug, Clone, Deserialize)]
-#[serde(default)]
+#[serde(default, deny_unknown_fields)]
 pub struct Highlight {
     pub enabled: bool,
     pub theme_light: String,
@@ -127,7 +117,7 @@ impl Default for Highlight {
 }
 
 #[derive(Debug, Clone, Deserialize)]
-#[serde(default)]
+#[serde(default, deny_unknown_fields)]
 pub struct Assets {
     pub source_dir: String,
     pub fingerprint: bool,
@@ -143,7 +133,7 @@ impl Default for Assets {
 }
 
 #[derive(Debug, Clone, Deserialize)]
-#[serde(default)]
+#[serde(default, deny_unknown_fields)]
 pub struct Nav {
     /// `weight` | `title`
     pub sort_by: SortBy,
@@ -171,16 +161,17 @@ fn default_language() -> String {
 impl Config {
     /// 사이트 루트에서 `sqzass.toml`을 읽는다.
     ///
-    /// 알 수 없는 최상위 키는 **경고**하고 계속 진행한다 — 오타 하나로 빌드를 막는 것보다
-    /// "이 키는 읽히지 않는다"고 알려주는 쪽이 낫다. 설정 오타는 모든 SSG의 1순위
-    /// 지원 부담이고, 고치는 비용이 거의 0이다.
+    /// 모르는 키는 **에러**다. 조용히 무시되는 설정은 "깨진 참조는 빌드를 멈춘다"는
+    /// 원칙의 정확한 위반이다 — `theme_ligth`라고 쓴 사람은 테마를 바꾼 줄 알고 있고,
+    /// 바뀌지 않은 이유를 찾느라 시간을 쓴다.
+    ///
+    /// 후보 목록과 줄 번호는 serde와 toml이 만들어 준다. 유효한 키 목록을 손으로
+    /// 관리하면 필드를 추가할 때마다 같이 고쳐야 하고, 언젠가 잊는다.
     pub fn load(root: &Path) -> Result<Self> {
         let path = root.join(CONFIG_FILE);
         let raw = std::fs::read_to_string(&path)
             .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
 
-        warn_unknown_keys(&raw, &path);
-
         let cfg: Config =
             toml::from_str(&raw).with_context(|| format!("{} 파싱 실패", path.display()))?;
         cfg.validate()?;
@@ -204,71 +195,42 @@ impl Config {
     }
 }
 
-fn warn_unknown_keys(raw: &str, path: &Path) {
-    let Ok(table) = raw.parse::<toml::Table>() else {
-        return; // 진짜 파싱 에러는 아래에서 제대로 보고된다
-    };
-    for key in table.keys() {
-        if KNOWN_TOP_LEVEL.contains(&key.as_str()) {
-            continue;
-        }
-        let hint = closest(key, KNOWN_TOP_LEVEL)
-            .map(|s| format!(" '{s}'을(를) 의도하셨나요?"))
-            .unwrap_or_default();
-        eprintln!(
-            "warning: {}의 '{}' 키는 sqzass가 읽지 않습니다.{}",
-            path.display(),
-            key,
-            hint
-        );
-    }
-}
-
-/// 편집 거리가 충분히 가까운 후보를 고른다.
-fn closest<'a>(needle: &str, haystack: &[&'a str]) -> Option<&'a str> {
-    let max = (needle.len() / 3).max(1);
-    haystack
-        .iter()
-        .map(|c| (*c, levenshtein(needle, c)))
-        .filter(|(_, d)| *d <= max)
-        .min_by_key(|(_, d)| *d)
-        .map(|(c, _)| c)
-}
-
-fn levenshtein(a: &str, b: &str) -> usize {
-    let b: Vec<char> = b.chars().collect();
-    let mut prev: Vec<usize> = (0..=b.len()).collect();
-    let mut cur = vec![0usize; b.len() + 1];
-
-    for (i, ca) in a.chars().enumerate() {
-        cur[0] = i + 1;
-        for (j, cb) in b.iter().enumerate() {
-            let cost = usize::from(ca != *cb);
-            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
-        }
-        std::mem::swap(&mut prev, &mut cur);
-    }
-    prev[b.len()]
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
 
+    /// 조용히 무시되는 설정은 "깨진 참조는 빌드를 멈춘다"의 정확한 위반이다.
     #[test]
-    fn levenshtein_basics() {
-        assert_eq!(levenshtein("", ""), 0);
-        assert_eq!(levenshtein("markdown", "markdown"), 0);
-        assert_eq!(levenshtein("markdonw", "markdown"), 2);
-        assert_eq!(levenshtein("title", "titel"), 2);
+    fn unknown_keys_are_an_error() {
+        let err = toml::from_str::<Config>(
+            r#"
+            title = "t"
+            base_url = "https://example.com"
+            [highlight]
+            theme_ligth = "InspiredGitHub"
+            "#,
+        )
+        .unwrap_err()
+        .to_string();
+        assert!(err.contains("theme_ligth"), "실제: {err}");
+        // 후보 목록은 serde가 만들어 준다. 손으로 관리하는 목록은 언젠가 어긋난다.
+        assert!(err.contains("theme_light"), "실제: {err}");
     }
 
     #[test]
-    fn suggests_close_key() {
-        assert_eq!(closest("markdonw", KNOWN_TOP_LEVEL), Some("markdown"));
-        assert_eq!(closest("titl", KNOWN_TOP_LEVEL), Some("title"));
-        // 전혀 다른 키에는 엉뚱한 제안을 하지 않는다
-        assert_eq!(closest("completely_unrelated", KNOWN_TOP_LEVEL), None);
+    fn unknown_top_level_keys_are_an_error() {
+        let err = toml::from_str::<Config>(
+            r#"
+            title = "t"
+            base_url = "https://example.com"
+            [markdwon]
+            tables = false
+            "#,
+        )
+        .unwrap_err()
+        .to_string();
+        assert!(err.contains("markdwon"), "실제: {err}");
+        assert!(err.contains("markdown"), "실제: {err}");
     }
 
     #[test]
diff --git a/src/content.rs b/src/content.rs
@@ -13,7 +13,7 @@ pub const CONTENT_DIR: &str = "content";
 const FENCE: &str = "+++";
 
 #[derive(Debug, Clone, Default, Deserialize)]
-#[serde(default)]
+#[serde(default, deny_unknown_fields)]
 pub struct FrontMatter {
     /// 유일한 필수 필드. 없으면 좋은 에러를 내기 위해 Option으로 받는다.
     pub title: Option<String>,
@@ -95,7 +95,10 @@ fn parse_page(path: &Path, content_root: &Path, cfg: &Config) -> Result<Page> {
 
     let (fm_text, body, body_line_offset) = split_front_matter(&raw, path)?;
 
-    let front: FrontMatter = toml::from_str(&fm_text)
+    // 빈 줄 하나를 앞에 붙여 toml이 세는 줄 번호를 원본 파일의 줄 번호에 맞춘다.
+    // 여는 `+++`가 항상 1번 줄이므로 front matter 본문은 2번 줄부터 시작한다.
+    // 이 한 줄이 없으면 에러가 가리키는 줄이 항상 하나씩 위를 가리킨다.
+    let front: FrontMatter = toml::from_str(&format!("\n{fm_text}"))
         .with_context(|| format!("{}: front matter 파싱 실패", path.display()))?;
 
     let title = front.title.clone().ok_or_else(|| {
@@ -322,6 +325,31 @@ mod tests {
         .unwrap()
     }
 
+    /// front matter 오타도 조용히 무시하지 않는다. 그리고 에러가 가리키는 줄은
+    /// **원본 파일의** 줄이어야 한다 — 펜스를 손으로 자른 이유가 그것이다.
+    #[test]
+    fn unknown_front_matter_key_errors_at_the_real_line() {
+        let dir = std::env::temp_dir().join(format!("sqzass-fm-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&dir);
+        std::fs::create_dir_all(dir.join("content")).unwrap();
+        let path = dir.join("content/_index.md");
+        std::fs::write(&path, "+++\ntitle = \"x\"\nweigth = 10\n+++\n\nbody\n").unwrap();
+
+        let cfg: Config =
+            toml::from_str("title = \"t\"\nbase_url = \"https://example.com\"\n").unwrap();
+        // anyhow는 `{}`에 최상위 컨텍스트만 낸다. 원인 사슬은 `{:#}`이라야 보인다.
+        let err = format!(
+            "{:#}",
+            parse_page(&path, &dir.join("content"), &cfg).unwrap_err()
+        );
+        let _ = std::fs::remove_dir_all(&dir);
+
+        assert!(err.contains("weigth"), "실제: {err}");
+        assert!(err.contains("weight"), "후보를 제시해야 한다: {err}");
+        // `weigth`는 파일의 3번 줄이다. 펜스를 세지 않으면 2번 줄이라고 말한다.
+        assert!(err.contains("line 3"), "원본 파일 줄 번호가 아니다: {err}");
+    }
+
     #[test]
     fn splits_front_matter_and_tracks_line_offset() {
         let raw = "+++\ntitle = \"Hi\"\n+++\n\n# Body\n";
diff --git a/src/lib.rs b/src/lib.rs
@@ -61,12 +61,23 @@ impl BuildOutput {
 
 /// 사이트를 빌드해 메모리에 담는다. 디스크는 건드리지 않는다.
 pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
-    let root = &opts.input;
-    let mut cfg = Config::load(root)?;
+    render_site(opts, load_config(opts)?)
+}
+
+/// 설정을 읽고 명령줄 덮어쓰기를 적용한다.
+///
+/// 빌드 경로가 이걸 두 번 부르지 않도록 따로 뺐다. 두 번 부르면 설정 진단이 두 번
+/// 찍히고, 사용자는 자기 파일에 문제가 두 개 있다고 읽는다.
+fn load_config(opts: &BuildOptions) -> Result<Config> {
+    let mut cfg = Config::load(&opts.input)?;
     if let Some(base) = &opts.base_url {
         cfg.base_url = base.clone();
     }
+    Ok(cfg)
+}
 
+fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
+    let root = &opts.input;
     let drafts = opts.drafts || cfg.build.drafts;
 
     let pages = content::discover(root, &cfg, drafts)?;
@@ -138,9 +149,9 @@ pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
 
 /// 사이트를 빌드해 디스크에 쓴다.
 pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
-    let cfg = Config::load(&opts.input)?;
+    let cfg = load_config(opts)?;
     let out_dir = resolve_output_dir(opts, &cfg);
-    let output = build_to_memory(opts)?;
+    let output = render_site(opts, cfg)?;
 
     // 출력 디렉터리를 매번 새로 만든다. 지운 페이지가 유령으로 남는 걸 막는다.
     if out_dir.exists() {