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