sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit 5cec51af7e9eaf20547d8d12dda92ecde366d370
Author: sqzer <110329244+devastator-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 16:46:48 +0900

Initial commit: markdown to HTML build pipeline

Builds a site from TOML front matter markdown through comrak and minijinja,
emitting path/index.html so pretty URLs work on hosts that have no rewrite
rules, plus a .nojekyll marker for GitHub Pages.

- config: sqzass.toml with defaults and unknown-key detection that suggests
the intended key rather than failing the build
- content: discovery, TOML front matter with real line offsets for error
reporting, filename-suffix i18n (page.ko.md), URL derivation, hard error
on slug collisions instead of a filesystem-order-dependent overwrite
- markdown: comrak with cjk_friendly_emphasis enabled, which Korean needs --
without it emphasis attached to a particle does not parse
- render: minijinja with a snapshot loader so a rebuild triggered mid-save
cannot read a half-written partial; autoescape on, SemiStrict undefined

comrak is pinned with default-features = false. Its defaults are
["cli", "syntect-onig", "bon"], which drags in comrak's own CLI dependencies
and pulls syntect via Oniguruma, a C binding that breaks musl static builds
and cross-compilation. The adapter traits we need are not feature-gated.

Two builds of the same input are byte-identical. 26 tests, clippy clean.

Diffstat:
A.cargo/config.toml | 6++++++
A.gitignore | 7+++++++
ACargo.lock | 604+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACargo.toml | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ALICENSE | 21+++++++++++++++++++++
AREADME.md | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/_index.ko.md | 25+++++++++++++++++++++++++
Adocs/content/_index.md | 25+++++++++++++++++++++++++
Adocs/content/start/_index.ko.md | 8++++++++
Adocs/content/start/_index.md | 8++++++++
Adocs/content/start/installation.ko.md | 35+++++++++++++++++++++++++++++++++++
Adocs/content/start/installation.md | 35+++++++++++++++++++++++++++++++++++
Adocs/sqzass.toml | 38++++++++++++++++++++++++++++++++++++++
Adocs/templates/base.html | 17+++++++++++++++++
Adocs/templates/page.html | 8++++++++
Adocs/templates/partials/footer.html | 3+++
Adocs/templates/partials/header.html | 3+++
Adocs/templates/section.html | 9+++++++++
Asrc/config.rs | 304+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/content.rs | 407+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/markdown.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/render.rs | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
24 files changed, 2321 insertion(+), 0 deletion(-)

diff --git a/.cargo/config.toml b/.cargo/config.toml
@@ -0,0 +1,6 @@
+# mold 링커 — 링크 시간을 크게 줄인다. (mold 2.41.0)
+#
+# gcc는 `-fuse-ld=mold`를 쓴다 (gcc 12.1+). `--ld-path=`는 clang 전용 플래그라
+# gcc에서는 "unrecognized command-line option"으로 실패한다.
+[target.x86_64-unknown-linux-gnu]
+rustflags = ["-C", "link-arg=-fuse-ld=mold"]
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,7 @@
+/target
+/docs/public
+**/*.rs.bk
+*.pdb
+
+# insta 스냅샷 리뷰 대기 파일
+**/*.snap.new
diff --git a/Cargo.lock b/Cargo.lock
@@ -0,0 +1,604 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "caseless"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
+dependencies = [
+ "unicode-normalization",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "comrak"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d5910408554659ed848ff469e67ec83b30f179e72cec286cfdae64d1616f466"
+dependencies = [
+ "caseless",
+ "entities",
+ "finl_unicode",
+ "jetscii",
+ "phf",
+ "phf_codegen",
+ "rustc-hash",
+ "smallvec",
+ "typed-arena",
+]
+
+[[package]]
+name = "console"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
+name = "entities"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "finl_unicode"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "insta"
+version = "1.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
+dependencies = [
+ "console",
+ "once_cell",
+ "similar",
+ "tempfile",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "jetscii"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "memo-map"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
+
+[[package]]
+name = "minijinja"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
+dependencies = [
+ "memo-map",
+ "serde",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "sqzass"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "comrak",
+ "insta",
+ "minijinja",
+ "serde",
+ "toml",
+ "walkdir",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "toml"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
+name = "typed-arena"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,70 @@
+[package]
+name = "sqzass"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.97"
+license = "MIT"
+description = "A static site generator written in Rust"
+repository = "https://github.com/devastator-x/sqzass"
+homepage = "https://sqzass.sqzer.com"
+readme = "README.md"
+keywords = ["static-site-generator", "ssg", "markdown", "blog", "cli"]
+categories = ["command-line-utilities", "web-programming"]
+
+[lib]
+name = "sqzass"
+path = "src/lib.rs"
+
+[[bin]]
+name = "sqzass"
+path = "src/main.rs"
+
+[dependencies]
+# --- M0 ---
+anyhow    = "1"
+clap      = { version = "4.6", features = ["derive"] }
+# ⚠️ comrak의 default = ["cli", "syntect-onig", "bon"] 이다. 그대로 쓰면
+#    (1) comrak 자체 CLI 의존성(clap/shell-words/xdg/fmt2io)이 딸려오고
+#    (2) syntect가 **Oniguruma(C 바인딩)**로 들어와 musl 정적 빌드/크로스컴파일이 깨진다.
+#    SyntaxHighlighterAdapter / HeadingAdapter 트레이트는 cfg 게이트가 없어 피처 없이도
+#    쓸 수 있고, 피처 뒤에 있는 건 comrak 내장 SyntectAdapter뿐인데 그건 우리가 직접
+#    구현하므로(업스트림 #330이 재작업 대상으로 지적) 필요 없다.
+comrak    = { version = "0.54", default-features = false }
+minijinja = "2.21"
+serde     = { version = "1", features = ["derive"] }
+toml      = "1.1"
+walkdir   = "2"
+
+# --- M1 (아래는 해당 기능 착수할 때 주석 해제) ---
+# syntect 기본 피처는 regex-onig(C 바인딩)이라 musl 정적 빌드/크로스컴파일이 깨진다.
+# default-fancy = 순수 Rust. 빌드 타임 작업이라 속도 차이는 무의미하다.
+# syntect  = { version = "5.3", default-features = false, features = ["default-fancy"] }
+# two-face는 syntect 기본 세트에 없는 TOML/TypeScript/Dockerfile을 채운다(문서 사이트 필수).
+# 피처를 안 맞추면 onig가 다시 딸려오므로 syntect-fancy를 반드시 명시할 것.
+# two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] }
+# blake3   = "1"                                    # 에셋 콘텐츠 해시
+# axum     = "0.8"                                  # 개발 서버
+# tower-http = { version = "0.7", features = ["fs"] }
+# tokio    = { version = "1", features = ["rt-multi-thread", "macros"] }
+# notify   = "8.2"                                  # 9.0은 6개월째 RC. 8.2 + debouncer 0.7은
+# notify-debouncer-full = "0.7"                     #   버전 락 쌍이라 섞지 말 것
+
+# --- M3 ---
+# pagefind 기본 피처 serve가 actix-web + actix-files를 끌고 온다. 반드시 끌 것.
+# pagefind = { version = "1.5", default-features = false, features = ["extended"], optional = true }
+
+# --- M3 이후 ---
+# rayon = "1"   # 병렬 렌더. 문서 사이트 ~60페이지엔 불필요하고, 출력 URL 충돌 시
+#               # 비결정성 문제를 같이 떠안게 된다. 페이지가 수백 개가 되면 그때.
+
+[dev-dependencies]
+insta = "1"
+
+[features]
+default = []
+# search = ["dep:pagefind"]
+
+[profile.release]
+lto = "thin"
+codegen-units = 1
+strip = true
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 devastator
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
@@ -0,0 +1,67 @@
+# sqzass
+
+A static site generator written in Rust.
+
+Most generators lean on their host for pretty URLs, redirects and cache headers.
+sqzass does that work itself, so the same output is correct on GitHub Pages,
+Vercel, Cloudflare Pages, or a plain directory served over HTTP.
+
+> **Status: early.** The build pipeline works end to end. Syntax highlighting,
+> table of contents, the asset pipeline, search and the dev server are not built
+> yet. Not usable for a real site.
+
+## Design
+
+- **Markdown on the AST, not on the output.** Link rewriting, heading anchors and
+  the table of contents are tree operations via [comrak]. Running regexes over the
+  final HTML — the common shortcut — silently skips any element whose attributes
+  are single-quoted or unquoted.
+- **Templates resolved from a snapshot.** `templates/` is read into memory once per
+  build and [minijinja]'s loader serves `include`/`extends` from that snapshot, so a
+  rebuild triggered mid-save can never ingest a half-written partial.
+- **Autoescaped by default.** `| safe` is opt-in, and undefined variable access is
+  an error rather than an empty string.
+- **Reproducible.** Two builds of the same input are byte-identical. Two pages
+  claiming the same URL is a hard error, not a race.
+- **Bilingual from the start.** `page.md` and `page.ko.md` sit side by side; the
+  default language lives at the root and others under `/<code>/`.
+- **One binary, no runtime.** No Node, no Python, no system libraries.
+
+## Usage
+
+```bash
+sqzass build -i <site-dir>
+```
+
+A site is a directory containing `sqzass.toml`, `content/` and `templates/`.
+Front matter is TOML, fenced with `+++`:
+
+```markdown
++++
+title = "Installation"
+weight = 10
++++
+
+Body goes here.
+```
+
+Output goes to `public/` as `path/index.html`, plus a `.nojekyll` marker.
+
+## Building
+
+```bash
+cargo build --release
+```
+
+Requires Rust 1.97 or newer. Nothing else.
+
+## Documentation
+
+<https://sqzass.sqzer.com> — built with sqzass, from `docs/` in this repository.
+
+## License
+
+MIT
+
+[comrak]: https://github.com/kivikakk/comrak
+[minijinja]: https://github.com/mitsuhiko/minijinja
diff --git a/docs/content/_index.ko.md b/docs/content/_index.ko.md
@@ -0,0 +1,25 @@
++++
+title = "sqzass"
+description = "Rust로 만든 정적 사이트 생성기"
+weight = 0
++++
+
+**sqzass**는 Rust로 만든 정적 사이트 생성기다. 이 사이트가 sqzass로 만들어졌다.
+
+## 왜 또 만드나
+
+대부분의 생성기는 예쁜 URL·리다이렉트·캐시 헤더를 호스트에 맡긴다.
+sqzass는 그걸 직접 한다. 그래서 같은 출력물이 GitHub Pages든 Vercel이든
+Cloudflare Pages든, 그냥 디렉터리를 HTTP로 서빙하든 똑같이 동작한다.
+
+- 마크다운은 [comrak](https://github.com/kivikakk/comrak). 진짜 AST를 쓴다 —
+  링크 재작성과 heading anchor를 트리에서 처리하지, 최종 HTML에 정규식을
+  돌리지 않는다.
+- 템플릿은 [minijinja](https://github.com/mitsuhiko/minijinja). 기본으로
+  이스케이프하고, 스냅샷에서 해석하므로 저장 도중 리빌드가 걸려도 반쯤 쓰인
+  파셜을 읽는 일이 없다.
+- 처음부터 이중 언어.
+
+## 상태
+
+초기 단계. [시작하기](@/start/_index.md)를 보라.
diff --git a/docs/content/_index.md b/docs/content/_index.md
@@ -0,0 +1,25 @@
++++
+title = "sqzass"
+description = "A static site generator written in Rust"
+weight = 0
++++
+
+**sqzass** is a static site generator written in Rust. This site is built with it.
+
+## Why another one?
+
+Most generators lean on their host for pretty URLs, redirects and cache headers.
+sqzass does that work itself, so the same output is correct on GitHub Pages,
+Vercel, Cloudflare Pages or a plain directory served over HTTP.
+
+- Markdown via [comrak](https://github.com/kivikakk/comrak), with a real AST — link
+  rewriting and heading anchors happen on the tree, never as a regex pass over the
+  final HTML.
+- Templates via [minijinja](https://github.com/mitsuhiko/minijinja), autoescaped by
+  default, resolved from a snapshot so a rebuild mid-save can never read a
+  half-written partial.
+- Bilingual from the start.
+
+## Status
+
+Early. See [Getting started](@/start/_index.md).
diff --git a/docs/content/start/_index.ko.md b/docs/content/start/_index.ko.md
@@ -0,0 +1,8 @@
++++
+title = "시작하기"
+description = "sqzass를 설치하고 첫 사이트를 빌드한다"
+weight = 10
+sort_by = "weight"
++++
+
+사이트를 화면에 띄우는 데 필요한 것들.
diff --git a/docs/content/start/_index.md b/docs/content/start/_index.md
@@ -0,0 +1,8 @@
++++
+title = "Getting started"
+description = "Install sqzass and build your first site"
+weight = 10
+sort_by = "weight"
++++
+
+Everything you need to get a site on screen.
diff --git a/docs/content/start/installation.ko.md b/docs/content/start/installation.ko.md
@@ -0,0 +1,35 @@
++++
+title = "설치"
+description = "소스에서 sqzass를 빌드한다"
+weight = 10
+toc = true
++++
+
+sqzass는 런타임 의존성이 없는 단일 바이너리다. Node도, Python도, 시스템
+라이브러리도 필요 없다.
+
+## 소스에서 빌드
+
+```bash
+git clone https://github.com/devastator-x/sqzass
+cd sqzass
+cargo build --release
+```
+
+바이너리는 `target/release/sqzass`에 생긴다.
+
+## 요구사항
+
+| | |
+|---|---|
+| Rust | 1.97 이상 |
+| 그 외 | 없음 |
+
+> [!NOTE]
+> 미리 빌드된 바이너리와 AUR 패키지는 계획에 있지만 아직 배포하지 않았다.
+
+## 설치 확인
+
+```bash
+sqzass --version
+```
diff --git a/docs/content/start/installation.md b/docs/content/start/installation.md
@@ -0,0 +1,35 @@
++++
+title = "Installation"
+description = "Build sqzass from source"
+weight = 10
+toc = true
++++
+
+sqzass is a single binary with no runtime dependencies. No Node, no Python, no
+system libraries.
+
+## From source
+
+```bash
+git clone https://github.com/devastator-x/sqzass
+cd sqzass
+cargo build --release
+```
+
+The binary lands at `target/release/sqzass`.
+
+## Requirements
+
+| | |
+|---|---|
+| Rust | 1.97 or newer |
+| Everything else | nothing |
+
+> [!NOTE]
+> Prebuilt binaries and an AUR package are planned but not published yet.
+
+## Checking the install
+
+```bash
+sqzass --version
+```
diff --git a/docs/sqzass.toml b/docs/sqzass.toml
@@ -0,0 +1,38 @@
+title            = "sqzass"
+description      = "A static site generator written in Rust"
+base_url         = "https://sqzass.sqzer.com"
+default_language = "en"
+
+[languages.en]
+name   = "English"
+weight = 1
+
+[languages.ko]
+name   = "한국어"
+weight = 2
+
+[build]
+output_dir = "public"
+drafts     = false
+
+[markdown]
+footnotes             = true
+tables                = true
+tasklist              = true
+strikethrough         = true
+autolink              = true
+alerts                = true
+cjk_friendly_emphasis = true
+heading_anchors       = "right"
+
+[highlight]
+theme_light  = "InspiredGitHub"
+theme_dark   = "base16-ocean.dark"
+line_numbers = false
+
+[assets]
+source_dir  = "static"
+fingerprint = true
+
+[nav]
+sort_by = "weight"
diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -0,0 +1,17 @@
+<!doctype html>
+<html lang="{{ page.language }}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{% if page.url == "/" %}{{ site.title }}{% else %}{{ page.title }} · {{ site.title }}{% endif %}</title>
+{% if page.description %}<meta name="description" content="{{ page.description }}">{% endif %}
+<link rel="canonical" href="{{ page.permalink }}">
+</head>
+<body>
+{% include "partials/header.html" %}
+<main>
+{% block content %}{% endblock %}
+</main>
+{% include "partials/footer.html" %}
+</body>
+</html>
diff --git a/docs/templates/page.html b/docs/templates/page.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+
+{% block content %}
+<article>
+  <h1>{{ page.title }}</h1>
+  {{ page.content | safe }}
+</article>
+{% endblock %}
diff --git a/docs/templates/partials/footer.html b/docs/templates/partials/footer.html
@@ -0,0 +1,3 @@
+<footer>
+  <p>{{ site.description }}</p>
+</footer>
diff --git a/docs/templates/partials/header.html b/docs/templates/partials/header.html
@@ -0,0 +1,3 @@
+<header>
+  <a href="{{ site.base_url }}/">{{ site.title }}</a>
+</header>
diff --git a/docs/templates/section.html b/docs/templates/section.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+
+{# 자식 페이지 목록은 섹션 트리가 들어오는 M1에서 붙는다. #}
+{% block content %}
+<article>
+  <h1>{{ page.title }}</h1>
+  {{ page.content | safe }}
+</article>
+{% endblock %}
diff --git a/src/config.rs b/src/config.rs
@@ -0,0 +1,304 @@
+//! `sqzass.toml` 파싱.
+//!
+//! 설계 원칙: 작고 직교하게. 아무 일도 하지 않는 설정 키는 넣지 않는다.
+
+use anyhow::{Context, Result};
+use serde::Deserialize;
+use std::collections::BTreeMap;
+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)]
+pub struct Config {
+    pub title: String,
+    #[serde(default)]
+    pub description: String,
+    pub base_url: String,
+    #[serde(default = "default_language")]
+    pub default_language: String,
+
+    #[serde(default)]
+    pub languages: BTreeMap<String, Language>,
+    #[serde(default)]
+    pub build: Build,
+    #[serde(default)]
+    pub markdown: Markdown,
+    #[serde(default)]
+    pub highlight: Highlight,
+    #[serde(default)]
+    pub assets: Assets,
+    #[serde(default)]
+    pub nav: Nav,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct Language {
+    pub name: String,
+    #[serde(default)]
+    pub weight: i64,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct Build {
+    pub output_dir: String,
+    pub drafts: bool,
+}
+
+impl Default for Build {
+    fn default() -> Self {
+        Self {
+            output_dir: "public".into(),
+            drafts: false,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct Markdown {
+    pub footnotes: bool,
+    pub tables: bool,
+    pub tasklist: bool,
+    pub strikethrough: bool,
+    pub autolink: bool,
+    /// GitHub `> [!NOTE]` 스타일 admonition.
+    pub alerts: bool,
+    /// ⚠️ 한국어에 필수. 끄면 `**강조**한다` 같은 구성이 파싱되지 않는다.
+    pub cjk_friendly_emphasis: bool,
+    /// `none` | `left` | `right`
+    pub heading_anchors: HeadingAnchors,
+}
+
+impl Default for Markdown {
+    fn default() -> Self {
+        Self {
+            footnotes: true,
+            tables: true,
+            tasklist: true,
+            strikethrough: true,
+            autolink: true,
+            alerts: true,
+            cjk_friendly_emphasis: true,
+            heading_anchors: HeadingAnchors::Right,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum HeadingAnchors {
+    None,
+    Left,
+    Right,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct Highlight {
+    pub theme_light: String,
+    pub theme_dark: String,
+    pub line_numbers: bool,
+}
+
+impl Default for Highlight {
+    fn default() -> Self {
+        Self {
+            theme_light: "InspiredGitHub".into(),
+            theme_dark: "base16-ocean.dark".into(),
+            line_numbers: false,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct Assets {
+    pub source_dir: String,
+    pub fingerprint: bool,
+}
+
+impl Default for Assets {
+    fn default() -> Self {
+        Self {
+            source_dir: "static".into(),
+            fingerprint: true,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct Nav {
+    /// `weight` | `title`
+    pub sort_by: SortBy,
+}
+
+impl Default for Nav {
+    fn default() -> Self {
+        Self {
+            sort_by: SortBy::Weight,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SortBy {
+    Weight,
+    Title,
+}
+
+fn default_language() -> String {
+    "en".into()
+}
+
+impl Config {
+    /// 사이트 루트에서 `sqzass.toml`을 읽는다.
+    ///
+    /// 알 수 없는 최상위 키는 **경고**하고 계속 진행한다 — 오타 하나로 빌드를 막는 것보다
+    /// "이 키는 읽히지 않는다"고 알려주는 쪽이 낫다. 설정 오타는 모든 SSG의 1순위
+    /// 지원 부담이고, 고치는 비용이 거의 0이다.
+    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()?;
+        Ok(cfg)
+    }
+
+    fn validate(&self) -> Result<()> {
+        if !self.languages.is_empty() && !self.languages.contains_key(&self.default_language) {
+            anyhow::bail!(
+                "default_language = \"{}\" 인데 [languages.{}] 가 정의되어 있지 않습니다",
+                self.default_language,
+                self.default_language
+            );
+        }
+        Ok(())
+    }
+
+    /// `base_url`에서 뒤쪽 슬래시를 제거한 형태. URL 조립에 쓴다.
+    pub fn base_url_trimmed(&self) -> &str {
+        self.base_url.trim_end_matches('/')
+    }
+}
+
+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);
+    }
+
+    #[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);
+    }
+
+    #[test]
+    fn defaults_apply() {
+        let cfg: Config = toml::from_str(
+            r#"
+            title = "t"
+            base_url = "https://example.com"
+            "#,
+        )
+        .unwrap();
+        assert_eq!(cfg.default_language, "en");
+        assert_eq!(cfg.build.output_dir, "public");
+        // 한국어 필수 항목이 기본으로 켜져 있어야 한다
+        assert!(cfg.markdown.cjk_friendly_emphasis);
+        assert_eq!(cfg.markdown.heading_anchors, HeadingAnchors::Right);
+    }
+
+    #[test]
+    fn rejects_default_language_without_definition() {
+        let cfg: Result<Config> = toml::from_str::<Config>(
+            r#"
+            title = "t"
+            base_url = "https://example.com"
+            default_language = "en"
+            [languages.ko]
+            name = "한국어"
+            "#,
+        )
+        .map_err(Into::into);
+        assert!(cfg.unwrap().validate().is_err());
+    }
+}
diff --git a/src/content.rs b/src/content.rs
@@ -0,0 +1,407 @@
+//! 콘텐츠 발견과 front matter 파싱.
+//!
+//! front matter는 TOML(`+++`)이 정본이다. `serde_yaml`이 아카이브된 상태라
+//! YAML을 정본으로 삼으면 살아있는 파서를 고르는 문제부터 떠안게 된다.
+
+use crate::config::Config;
+use anyhow::{Context, Result, bail};
+use serde::Deserialize;
+use std::path::{Path, PathBuf};
+use walkdir::WalkDir;
+
+pub const CONTENT_DIR: &str = "content";
+const FENCE: &str = "+++";
+
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(default)]
+pub struct FrontMatter {
+    /// 유일한 필수 필드. 없으면 좋은 에러를 내기 위해 Option으로 받는다.
+    pub title: Option<String>,
+    pub description: String,
+    pub weight: i64,
+    pub draft: bool,
+    /// 기본은 파일명 stem.
+    pub slug: Option<String>,
+    pub template: Option<String>,
+    pub toc: bool,
+    /// 기본은 파일명 stem 추론.
+    pub translation_key: Option<String>,
+
+    // --- 섹션(`_index.md`) 전용 ---
+    pub sort_by: Option<crate::config::SortBy>,
+    pub page_template: Option<String>,
+
+    #[serde(default)]
+    pub extra: toml::Table,
+}
+
+#[derive(Debug, Clone)]
+pub struct Page {
+    /// 소스 파일 절대 경로.
+    pub source: PathBuf,
+    /// `content/` 기준 상대 경로.
+    pub rel: PathBuf,
+    pub front: FrontMatter,
+    pub title: String,
+    /// front matter를 제거한 마크다운 본문.
+    pub body: String,
+    /// `body`의 첫 줄이 원본 파일에서 몇 번째 줄인지(1-based). 에러 보고에 쓴다.
+    pub body_line_offset: usize,
+    pub language: String,
+    /// 번역 연결 키. 기본은 파일명 stem 추론.
+    pub translation_key: String,
+    /// `/`, `/about/`, `/ko/start/installation/`
+    pub url: String,
+    /// 출력 디렉터리 기준 상대 경로. `index.html`, `about/index.html`
+    pub out_path: PathBuf,
+    /// `_index.md` 여부 (섹션 인덱스).
+    pub is_section: bool,
+}
+
+/// `content/` 아래의 모든 마크다운을 읽어 `Page`로 만든다.
+///
+/// 드래프트는 `drafts`가 false면 제외한다.
+pub fn discover(root: &Path, cfg: &Config, drafts: bool) -> Result<Vec<Page>> {
+    let content_root = root.join(CONTENT_DIR);
+    if !content_root.is_dir() {
+        bail!("{} 디렉터리가 없습니다", content_root.display());
+    }
+
+    let mut pages = Vec::new();
+    for entry in WalkDir::new(&content_root).sort_by_file_name() {
+        let entry = entry.context("content/ 순회 실패")?;
+        if !entry.file_type().is_file() {
+            continue;
+        }
+        let path = entry.path();
+        if path.extension().and_then(|e| e.to_str()) != Some("md") {
+            continue;
+        }
+
+        let page = parse_page(path, &content_root, cfg)?;
+        if page.front.draft && !drafts {
+            continue;
+        }
+        pages.push(page);
+    }
+
+    check_url_collisions(&pages)?;
+    Ok(pages)
+}
+
+fn parse_page(path: &Path, content_root: &Path, cfg: &Config) -> Result<Page> {
+    let raw = std::fs::read_to_string(path)
+        .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
+
+    let (fm_text, body, body_line_offset) = split_front_matter(&raw, path)?;
+
+    let front: FrontMatter = toml::from_str(&fm_text)
+        .with_context(|| format!("{}: front matter 파싱 실패", path.display()))?;
+
+    let title = front.title.clone().ok_or_else(|| {
+        anyhow::anyhow!(
+            "{}: front matter에 필수 필드 `title`이 없습니다",
+            path.display()
+        )
+    })?;
+
+    let rel = path
+        .strip_prefix(content_root)
+        .unwrap_or(path)
+        .to_path_buf();
+
+    let name = NameParts::parse(&rel, cfg);
+    let slug = front.slug.clone().unwrap_or_else(|| name.stem.clone());
+    let translation_key = front
+        .translation_key
+        .clone()
+        .unwrap_or_else(|| name.translation_key.clone());
+
+    let (url, out_path) = build_url(&name, &slug, cfg);
+
+    Ok(Page {
+        source: path.to_path_buf(),
+        rel,
+        front,
+        title,
+        body,
+        body_line_offset,
+        language: name.language,
+        translation_key,
+        url,
+        out_path,
+        is_section: name.is_section,
+    })
+}
+
+/// 파일 경로에서 stem / 언어 / 섹션 여부를 뽑는다.
+///
+/// `start/installation.md`    → stem "installation", lang = default
+/// `start/installation.ko.md` → stem "installation", lang = "ko"
+/// `start/_index.md`          → 섹션, stem "start"
+struct NameParts {
+    /// URL의 마지막 세그먼트가 될 이름.
+    stem: String,
+    /// `content/` 기준 부모 디렉터리 세그먼트들.
+    dirs: Vec<String>,
+    language: String,
+    is_section: bool,
+    translation_key: String,
+}
+
+impl NameParts {
+    fn parse(rel: &Path, cfg: &Config) -> Self {
+        let dirs: Vec<String> = rel
+            .parent()
+            .map(|p| {
+                p.components()
+                    .map(|c| c.as_os_str().to_string_lossy().into_owned())
+                    .collect()
+            })
+            .unwrap_or_default();
+
+        // `foo.ko.md` → file_stem은 "foo.ko" 이므로 한 번 더 벗긴다.
+        let full_stem = rel
+            .file_stem()
+            .map(|s| s.to_string_lossy().into_owned())
+            .unwrap_or_default();
+
+        let (base, language) = match full_stem.rsplit_once('.') {
+            // 마지막 조각이 설정에 선언된 언어 코드일 때만 언어로 인정한다.
+            // 그렇지 않으면 `my.file.md` 같은 이름이 언어로 오인된다.
+            Some((base, maybe_lang)) if cfg.languages.contains_key(maybe_lang) => {
+                (base.to_string(), maybe_lang.to_string())
+            }
+            _ => (full_stem.clone(), cfg.default_language.clone()),
+        };
+
+        let is_section = base == "_index";
+        // 섹션의 이름은 자기 디렉터리 이름이다. 루트 섹션이면 빈 문자열.
+        let stem = if is_section {
+            dirs.last().cloned().unwrap_or_default()
+        } else {
+            base.clone()
+        };
+
+        // 번역 키는 언어를 뗀 경로. `start/installation`, `start/_index`
+        let mut key_parts = dirs.clone();
+        key_parts.push(base);
+        let translation_key = key_parts.join("/");
+
+        Self {
+            stem,
+            dirs,
+            language,
+            is_section,
+            translation_key,
+        }
+    }
+}
+
+/// URL과 출력 경로를 만든다. 항상 `path/index.html` 형태로 뽑는다 —
+/// GitHub Pages처럼 rewrite 규칙이 없는 호스트에서 예쁜 URL을 내는 유일한 방법이다.
+fn build_url(name: &NameParts, slug: &str, cfg: &Config) -> (String, PathBuf) {
+    let mut segments: Vec<String> = Vec::new();
+
+    // 기본 언어는 루트, 나머지는 `/<code>/` 아래.
+    if name.language != cfg.default_language {
+        segments.push(name.language.clone());
+    }
+
+    if name.is_section {
+        // 섹션의 URL은 자기 디렉터리 경로 그 자체다.
+        segments.extend(name.dirs.iter().cloned());
+    } else {
+        segments.extend(name.dirs.iter().cloned());
+        segments.push(slug.to_string());
+    }
+
+    let url = if segments.is_empty() {
+        "/".to_string()
+    } else {
+        format!("/{}/", segments.join("/"))
+    };
+
+    let mut out = PathBuf::new();
+    for s in &segments {
+        out.push(s);
+    }
+    out.push("index.html");
+
+    (url, out)
+}
+
+/// 두 페이지가 같은 URL을 주장하면 하드 에러. 조용히 덮어쓰면 어느 쪽이 남는지가
+/// 파일 순회 순서에 의존하게 되고, 그건 재현 불가능한 버그가 된다.
+fn check_url_collisions(pages: &[Page]) -> Result<()> {
+    use std::collections::HashMap;
+    let mut seen: HashMap<&str, &Page> = HashMap::new();
+    for page in pages {
+        if let Some(prev) = seen.insert(&page.url, page) {
+            bail!(
+                "URL 충돌: '{}' 을(를) 두 파일이 주장합니다\n  {}\n  {}\n\
+                 front matter의 `slug`로 한쪽을 바꾸세요.",
+                page.url,
+                prev.source.display(),
+                page.source.display()
+            );
+        }
+    }
+    Ok(())
+}
+
+/// `+++` 펜스로 front matter를 분리한다.
+///
+/// 직접 자르는 이유: 파싱 에러를 **원본 파일의 실제 줄 번호**로 보고하려면 front matter가
+/// 몇 줄을 먹었는지 알아야 하는데, 대부분의 크레이트가 그 정보를 잃는다.
+fn split_front_matter(raw: &str, path: &Path) -> Result<(String, String, usize)> {
+    let text = raw.strip_prefix('\u{feff}').unwrap_or(raw);
+    let mut lines = text.lines();
+
+    let Some(first) = lines.next() else {
+        bail!("{}: 파일이 비어 있습니다", path.display());
+    };
+
+    if first.trim_end() != FENCE {
+        if first.trim_end() == "---" {
+            bail!(
+                "{}: front matter가 `---`로 시작합니다. sqzass는 TOML front matter를 쓰므로 \
+                 `+++`로 감싸주세요.",
+                path.display()
+            );
+        }
+        bail!(
+            "{}: 파일이 `{}` front matter로 시작해야 합니다",
+            path.display(),
+            FENCE
+        );
+    }
+
+    let mut fm = String::new();
+    let mut closed_at = None;
+    // 1번 줄은 여는 펜스이므로 본문 후보는 2번 줄부터.
+    for (idx, line) in lines.enumerate() {
+        if line.trim_end() == FENCE {
+            closed_at = Some(idx + 2); // 닫는 펜스의 1-based 줄 번호
+            break;
+        }
+        fm.push_str(line);
+        fm.push('\n');
+    }
+
+    let Some(closed_at) = closed_at else {
+        bail!(
+            "{}: front matter를 닫는 `{}`를 찾지 못했습니다",
+            path.display(),
+            FENCE
+        );
+    };
+
+    // 닫는 펜스 다음 줄부터가 본문.
+    let body: String = text.lines().skip(closed_at).collect::<Vec<_>>().join("\n");
+
+    Ok((fm, body, closed_at + 1))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn cfg() -> Config {
+        toml::from_str(
+            r#"
+            title = "t"
+            base_url = "https://example.com"
+            default_language = "en"
+            [languages.en]
+            name = "English"
+            [languages.ko]
+            name = "한국어"
+            "#,
+        )
+        .unwrap()
+    }
+
+    #[test]
+    fn splits_front_matter_and_tracks_line_offset() {
+        let raw = "+++\ntitle = \"Hi\"\n+++\n\n# Body\n";
+        let (fm, body, offset) = split_front_matter(raw, Path::new("x.md")).unwrap();
+        assert_eq!(fm.trim(), "title = \"Hi\"");
+        assert_eq!(body, "\n# Body");
+        // `+++`(1) `title`(2) `+++`(3) → 본문은 4번째 줄부터
+        assert_eq!(offset, 4);
+    }
+
+    #[test]
+    fn rejects_yaml_fence_with_a_useful_message() {
+        let raw = "---\ntitle: Hi\n---\n";
+        let err = split_front_matter(raw, Path::new("x.md"))
+            .unwrap_err()
+            .to_string();
+        assert!(err.contains("+++"), "실제 메시지: {err}");
+    }
+
+    #[test]
+    fn rejects_unterminated_front_matter() {
+        let raw = "+++\ntitle = \"Hi\"\n\n# Body\n";
+        assert!(split_front_matter(raw, Path::new("x.md")).is_err());
+    }
+
+    #[test]
+    fn url_for_root_index() {
+        let cfg = cfg();
+        let parts = NameParts::parse(Path::new("_index.md"), &cfg);
+        let (url, out) = build_url(&parts, &parts.stem, &cfg);
+        assert_eq!(url, "/");
+        assert_eq!(out, PathBuf::from("index.html"));
+    }
+
+    #[test]
+    fn url_for_nested_page() {
+        let cfg = cfg();
+        let parts = NameParts::parse(Path::new("start/installation.md"), &cfg);
+        let (url, out) = build_url(&parts, &parts.stem, &cfg);
+        assert_eq!(url, "/start/installation/");
+        assert_eq!(out, PathBuf::from("start/installation/index.html"));
+    }
+
+    #[test]
+    fn url_for_section_index() {
+        let cfg = cfg();
+        let parts = NameParts::parse(Path::new("start/_index.md"), &cfg);
+        assert!(parts.is_section);
+        let (url, out) = build_url(&parts, &parts.stem, &cfg);
+        assert_eq!(url, "/start/");
+        assert_eq!(out, PathBuf::from("start/index.html"));
+    }
+
+    #[test]
+    fn korean_pages_go_under_language_prefix() {
+        let cfg = cfg();
+        let parts = NameParts::parse(Path::new("start/installation.ko.md"), &cfg);
+        assert_eq!(parts.language, "ko");
+        assert_eq!(parts.stem, "installation");
+        // 번역 키는 언어를 뗀 경로여서 영어판과 짝이 맞는다
+        assert_eq!(parts.translation_key, "start/installation");
+        let (url, out) = build_url(&parts, &parts.stem, &cfg);
+        assert_eq!(url, "/ko/start/installation/");
+        assert_eq!(out, PathBuf::from("ko/start/installation/index.html"));
+    }
+
+    #[test]
+    fn translation_keys_match_across_languages() {
+        let cfg = cfg();
+        let en = NameParts::parse(Path::new("start/installation.md"), &cfg);
+        let ko = NameParts::parse(Path::new("start/installation.ko.md"), &cfg);
+        assert_eq!(en.translation_key, ko.translation_key);
+    }
+
+    #[test]
+    fn unknown_dotted_suffix_is_not_treated_as_a_language() {
+        let cfg = cfg();
+        // "backup"은 선언된 언어가 아니므로 파일명 일부로 남아야 한다
+        let parts = NameParts::parse(Path::new("notes.backup.md"), &cfg);
+        assert_eq!(parts.language, "en");
+        assert_eq!(parts.stem, "notes.backup");
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -0,0 +1,165 @@
+//! sqzass — Rust로 만든 정적 사이트 생성기.
+
+pub mod config;
+pub mod content;
+pub mod markdown;
+pub mod render;
+
+use anyhow::{Context, Result};
+use minijinja::context;
+use std::path::{Path, PathBuf};
+
+use config::Config;
+use content::Page;
+use render::{PageCtx, SiteCtx, Templates};
+
+#[derive(Debug, Clone)]
+pub struct BuildOptions {
+    /// 사이트 루트 (`sqzass.toml`이 있는 디렉터리).
+    pub input: PathBuf,
+    /// 출력 디렉터리. `None`이면 `<input>/<build.output_dir>`.
+    pub output: Option<PathBuf>,
+    pub drafts: bool,
+    /// 설정의 `base_url`을 덮어쓴다.
+    pub base_url: Option<String>,
+}
+
+#[derive(Debug, Default)]
+pub struct BuildStats {
+    pub pages_written: usize,
+    pub output_dir: PathBuf,
+}
+
+pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
+    let root = &opts.input;
+    let mut cfg = Config::load(root)?;
+    if let Some(base) = &opts.base_url {
+        cfg.base_url = base.clone();
+    }
+
+    let drafts = opts.drafts || cfg.build.drafts;
+    let out_dir = resolve_output_dir(opts, &cfg);
+
+    let pages = content::discover(root, &cfg, drafts)?;
+    let templates = Templates::load(root)?;
+    let md = markdown::Renderer::new(&cfg.markdown);
+
+    // 출력 디렉터리를 매번 새로 만든다. 지운 페이지가 유령으로 남는 걸 막는다.
+    if out_dir.exists() {
+        std::fs::remove_dir_all(&out_dir)
+            .with_context(|| format!("{}을(를) 비울 수 없습니다", out_dir.display()))?;
+    }
+
+    let mut written = 0usize;
+    for page in &pages {
+        let html = render_page(page, &cfg, &templates, &md)?;
+        let dest = out_dir.join(&page.out_path);
+        if let Some(parent) = dest.parent() {
+            std::fs::create_dir_all(parent)
+                .with_context(|| format!("{}을(를) 만들 수 없습니다", parent.display()))?;
+        }
+        std::fs::write(&dest, html)
+            .with_context(|| format!("{}을(를) 쓸 수 없습니다", dest.display()))?;
+        written += 1;
+    }
+
+    // GitHub Pages는 이 파일이 없으면 출력을 Jekyll로 한 번 더 굴려서
+    // `_`로 시작하는 디렉터리를 통째로 삼킨다.
+    std::fs::write(out_dir.join(".nojekyll"), "")
+        .with_context(|| format!("{}에 .nojekyll을 쓸 수 없습니다", out_dir.display()))?;
+
+    Ok(BuildStats {
+        pages_written: written,
+        output_dir: out_dir,
+    })
+}
+
+/// `-o`가 없으면 `<input>/<output_dir>`, 있으면 **셸의 CWD 기준**으로 해석한다.
+fn resolve_output_dir(opts: &BuildOptions, cfg: &Config) -> PathBuf {
+    match &opts.output {
+        Some(o) => o.clone(),
+        None => opts.input.join(&cfg.build.output_dir),
+    }
+}
+
+fn render_page(
+    page: &Page,
+    cfg: &Config,
+    templates: &Templates,
+    md: &markdown::Renderer,
+) -> Result<String> {
+    let template = select_template(page, templates)?;
+
+    let site = SiteCtx {
+        title: cfg.title.clone(),
+        description: cfg.description.clone(),
+        base_url: cfg.base_url_trimmed().to_string(),
+        language: page.language.clone(),
+    };
+
+    let page_ctx = PageCtx {
+        title: page.title.clone(),
+        description: page.front.description.clone(),
+        url: page.url.clone(),
+        permalink: format!("{}{}", cfg.base_url_trimmed(), page.url),
+        content: md.to_html(&page.body),
+        weight: page.front.weight,
+        draft: page.front.draft,
+        toc: page.front.toc,
+        language: page.language.clone(),
+    };
+
+    templates
+        .render(&template, context! { site => site, page => page_ctx })
+        .with_context(|| format!("{} 렌더 중", page.source.display()))
+}
+
+/// 템플릿 선택은 **명시적**이다: front matter `template` → 섹션이면 `section.html`
+/// → `page.html`. Hugo의 20단계 lookup order 캐스케이드는 채택하지 않는다 —
+/// 사용자가 가장 많이 헤매는 지점이기 때문이다.
+///
+/// (부모 섹션의 `page_template`을 보는 단계는 섹션 트리가 생기는 M1에서 들어간다.)
+fn select_template(page: &Page, templates: &Templates) -> Result<String> {
+    if let Some(explicit) = &page.front.template {
+        if templates.has(explicit) {
+            return Ok(explicit.clone());
+        }
+        anyhow::bail!(
+            "{}: front matter가 template = \"{}\"를 가리키는데 그런 템플릿이 없습니다.\n\
+             사용 가능한 템플릿: {}",
+            page.source.display(),
+            explicit,
+            templates.names().join(", ")
+        );
+    }
+
+    let candidate = if page.is_section {
+        "section.html"
+    } else {
+        "page.html"
+    };
+    if templates.has(candidate) {
+        return Ok(candidate.into());
+    }
+    if templates.has("page.html") {
+        return Ok("page.html".into());
+    }
+
+    anyhow::bail!(
+        "{}: 쓸 템플릿이 없습니다 ('{}'도 'page.html'도 없음).\n\
+         사용 가능한 템플릿: {}",
+        page.source.display(),
+        candidate,
+        templates.names().join(", ")
+    )
+}
+
+/// 경로를 사람이 읽기 좋게 줄인다 (로그용).
+pub fn display_path(path: &Path) -> String {
+    std::env::current_dir()
+        .ok()
+        .and_then(|cwd| path.strip_prefix(&cwd).ok().map(|p| p.to_path_buf()))
+        .unwrap_or_else(|| path.to_path_buf())
+        .display()
+        .to_string()
+}
diff --git a/src/main.rs b/src/main.rs
@@ -0,0 +1,70 @@
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+use std::path::PathBuf;
+
+use sqzass::{BuildOptions, build, display_path};
+
+#[derive(Parser)]
+#[command(
+    name = "sqzass",
+    version,
+    about = "A static site generator written in Rust",
+    disable_help_subcommand = true
+)]
+struct Cli {
+    #[command(subcommand)]
+    command: Command,
+}
+
+#[derive(Subcommand)]
+enum Command {
+    /// 사이트를 빌드한다
+    Build(BuildArgs),
+}
+
+#[derive(clap::Args)]
+struct BuildArgs {
+    /// 사이트 루트 (sqzass.toml이 있는 디렉터리)
+    #[arg(short, long, default_value = ".", value_name = "DIR")]
+    input: PathBuf,
+
+    /// 출력 디렉터리. 생략하면 <input>/public.
+    /// 지정하면 셸의 현재 디렉터리 기준으로 해석한다.
+    #[arg(short, long, value_name = "DIR")]
+    output: Option<PathBuf>,
+
+    /// 드래프트 페이지도 포함한다
+    #[arg(long)]
+    drafts: bool,
+
+    /// 설정의 base_url을 덮어쓴다
+    #[arg(long, value_name = "URL")]
+    base_url: Option<String>,
+}
+
+fn main() {
+    if let Err(err) = run() {
+        eprintln!("error: {err:#}");
+        std::process::exit(1);
+    }
+}
+
+fn run() -> Result<()> {
+    let cli = Cli::parse();
+    match cli.command {
+        Command::Build(args) => {
+            let stats = build(&BuildOptions {
+                input: args.input,
+                output: args.output,
+                drafts: args.drafts,
+                base_url: args.base_url,
+            })?;
+            println!(
+                "{} pages → {}",
+                stats.pages_written,
+                display_path(&stats.output_dir)
+            );
+            Ok(())
+        }
+    }
+}
diff --git a/src/markdown.rs b/src/markdown.rs
@@ -0,0 +1,88 @@
+//! comrak 기반 마크다운 → HTML 변환.
+//!
+//! M1에서 syntect 하이라이터와 heading anchor는 `Options.render`의
+//! `codefence_syntax_highlighter` / `heading_adapter` 자리에 붙는다.
+//! 최종 HTML에 정규식을 돌리는 방식은 쓰지 않는다 — 속성을 홑따옴표로 쓰거나
+//! 따옴표를 빼면 조용히 처리에서 빠지는, 버그를 양산하는 구조다.
+
+use crate::config::Markdown as MarkdownConfig;
+use comrak::{Options, markdown_to_html};
+
+pub struct Renderer<'a> {
+    options: Options<'a>,
+}
+
+impl<'a> Renderer<'a> {
+    pub fn new(cfg: &MarkdownConfig) -> Self {
+        let mut o = Options::default();
+
+        o.extension.footnotes = cfg.footnotes;
+        o.extension.table = cfg.tables;
+        o.extension.tasklist = cfg.tasklist;
+        o.extension.strikethrough = cfg.strikethrough;
+        o.extension.autolink = cfg.autolink;
+        o.extension.alerts = cfg.alerts;
+        // ⚠️ 한국어에 필수. 끄면 `**강조**한다` 같은 구성이 강조로 파싱되지 않는다.
+        o.extension.cjk_friendly_emphasis = cfg.cjk_friendly_emphasis;
+
+        // 우리 콘텐츠는 git에 들어있는 신뢰된 소스이므로 raw HTML을 허용한다.
+        // (`<details>`, 임베드 등이 문서 사이트에 필요하다.)
+        // 신뢰되지 않은 소스를 다루게 되면 소스별 신뢰 등급으로 나눌 것.
+        o.render.r#unsafe = true;
+        // false여야 `<code class="language-rust">`가 나온다. true면 `<pre lang="rust">`다.
+        // 전자가 Prism/highlight.js/Pagefind가 기대하는 관례이고, M1에서 syntect가
+        // 붙기 전까지의 기본 출력도 이쪽이어야 CSS를 다시 안 쓴다.
+        o.render.github_pre_lang = false;
+
+        Self { options: o }
+    }
+
+    pub fn to_html(&self, body: &str) -> String {
+        markdown_to_html(body, &self.options)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn renderer() -> Renderer<'static> {
+        Renderer::new(&MarkdownConfig::default())
+    }
+
+    #[test]
+    fn renders_basic_markdown() {
+        let html = renderer().to_html("# Hello\n\nWorld.");
+        assert!(html.contains("<h1>Hello</h1>"), "실제: {html}");
+        assert!(html.contains("<p>World.</p>"), "실제: {html}");
+    }
+
+    #[test]
+    fn korean_emphasis_attached_to_particle_parses() {
+        // cjk_friendly_emphasis가 꺼져 있으면 이게 <em>으로 파싱되지 않는다.
+        // 한국어 문서에서 매우 흔한 형태라 회귀하면 바로 알아야 한다.
+        let html = renderer().to_html("**정적 사이트 생성기**를 만들었다.");
+        assert!(
+            html.contains("<strong>정적 사이트 생성기</strong>"),
+            "실제: {html}"
+        );
+    }
+
+    #[test]
+    fn code_fence_carries_language_class() {
+        let html = renderer().to_html("```rust\nfn main() {}\n```");
+        assert!(html.contains("language-rust"), "실제: {html}");
+    }
+
+    #[test]
+    fn gfm_table_renders() {
+        let html = renderer().to_html("| a | b |\n|---|---|\n| 1 | 2 |");
+        assert!(html.contains("<table>"), "실제: {html}");
+    }
+
+    #[test]
+    fn raw_html_passes_through() {
+        let html = renderer().to_html("<details><summary>x</summary>y</details>");
+        assert!(html.contains("<details>"), "실제: {html}");
+    }
+}
diff --git a/src/render.rs b/src/render.rs
@@ -0,0 +1,298 @@
+//! minijinja 템플릿 환경과 렌더 컨텍스트.
+
+use anyhow::{Context, Result, bail};
+use minijinja::{Environment, UndefinedBehavior, Value};
+use serde::Serialize;
+use std::collections::HashMap;
+use std::path::Path;
+use std::sync::Arc;
+use walkdir::WalkDir;
+
+pub const TEMPLATE_DIR: &str = "templates";
+const TEMPLATE_EXTENSIONS: &[&str] = &["html", "xml", "txt", "json", "jinja", "j2"];
+
+pub struct Templates {
+    env: Environment<'static>,
+    names: Vec<String>,
+}
+
+impl Templates {
+    /// `templates/` 전체를 **메모리로 한 번에 읽어** 그 스냅샷에서만 템플릿을 해석한다.
+    ///
+    /// 이게 중요한 이유: minijinja의 로더는 `{% include %}` / `{% extends %}`를
+    /// VM 레벨에서 가로챈다. 로더가 디스크를 직접 읽게 두면, 파일을 저장하는 도중에
+    /// 리빌드가 걸렸을 때 **반쯤 쓰인 파셜**을 읽어들일 수 있다. 스냅샷에서 해석하면
+    /// 한 번의 빌드가 항상 하나의 일관된 템플릿 상태를 본다.
+    pub fn load(root: &Path) -> Result<Self> {
+        let dir = root.join(TEMPLATE_DIR);
+        if !dir.is_dir() {
+            bail!("{} 디렉터리가 없습니다", dir.display());
+        }
+
+        let mut snapshot: HashMap<String, String> = HashMap::new();
+        for entry in WalkDir::new(&dir).sort_by_file_name() {
+            let entry = entry.context("templates/ 순회 실패")?;
+            if !entry.file_type().is_file() {
+                continue;
+            }
+            let path = entry.path();
+            let is_template = path
+                .extension()
+                .and_then(|e| e.to_str())
+                .is_some_and(|e| TEMPLATE_EXTENSIONS.contains(&e));
+            if !is_template {
+                continue;
+            }
+
+            // 템플릿 이름은 확장자를 포함한 상대 경로다: `base.html`, `partials/nav.html`.
+            // Jinja 관례를 그대로 따르므로 `{% extends "base.html" %}`가 자연스럽다.
+            let name = path
+                .strip_prefix(&dir)
+                .unwrap_or(path)
+                .to_string_lossy()
+                .replace('\\', "/");
+            let source = std::fs::read_to_string(path)
+                .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
+            snapshot.insert(name, source);
+        }
+
+        if snapshot.is_empty() {
+            bail!("{}에 템플릿이 없습니다", dir.display());
+        }
+
+        let mut names: Vec<String> = snapshot.keys().cloned().collect();
+        names.sort();
+
+        let snapshot = Arc::new(snapshot);
+        let mut env = Environment::new();
+
+        {
+            let snapshot = Arc::clone(&snapshot);
+            env.set_loader(move |name| Ok(snapshot.get(name).cloned()));
+        }
+
+        // autoescape는 minijinja의 확장자 기반 기본 동작을 그대로 쓴다.
+        // html/htm/xml → Html, json → Json, txt → None.
+        //
+        // "확장자가 없으면 AutoEscape::None이 되어 조용히 이스케이프가 꺼진다"는
+        // 함정이 있지만, 우리 로더가 TEMPLATE_EXTENSIONS에 있는 파일만 받으므로
+        // 확장자 없는 템플릿은 애초에 존재할 수 없다. 이름 없는 참조는 스냅샷에서
+        // 못 찾아 명확한 에러가 된다 — 조용한 실패가 아니다.
+        //
+        // 여기서 None을 Html로 끌어올리면 안 된다. 우리 목록에서 None이 되는 건
+        // `.txt`뿐인데, robots.txt / llms.txt를 HTML 이스케이프하면 `&`가 `&amp;`가
+        // 되어 파일이 망가진다.
+        env.set_auto_escape_callback(minijinja::default_auto_escape_callback);
+
+        // Strict가 아니라 SemiStrict. Strict는 `{% if optional_field %}`까지 에러로
+        // 만드는데, front matter의 선택적 필드를 존재 검사하는 게 SSG 템플릿의
+        // 일상이라 쓸 수 없다. SemiStrict는 undefined의 출력/순회/속성 접근만 막는다.
+        env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
+
+        Ok(Self { env, names })
+    }
+
+    pub fn has(&self, name: &str) -> bool {
+        self.names.iter().any(|n| n == name)
+    }
+
+    pub fn names(&self) -> &[String] {
+        &self.names
+    }
+
+    pub fn render(&self, name: &str, ctx: Value) -> Result<String> {
+        let tmpl = self
+            .env
+            .get_template(name)
+            .with_context(|| format!("템플릿 '{name}'을(를) 찾을 수 없습니다"))?;
+        tmpl.render(ctx)
+            .with_context(|| format!("템플릿 '{name}' 렌더 실패"))
+    }
+}
+
+// ⚠️ 알려진 결함 (M1에서 수정): minijinja의 HTML 이스케이퍼는 `/`를 `&#x2f;`로 바꾼다
+// (utils.rs의 `b'/' => "&#x2f;"`, OWASP식 보수적 이스케이핑). `v_htmlescape` 피처로
+// 바꿔봤지만 그쪽도 `/`를 이스케이프하므로 우회가 안 된다.
+//
+// 결과: 모든 URL이 `href="https:&#x2f;&#x2f;..."` 로 나간다. 유효한 HTML이고 브라우저와
+// 제대로 된 파서는 디코드하지만, 출력물이 지저분하다.
+//
+// 제대로 된 해법은 URL을 일반 텍스트 이스케이퍼에 태우지 않는 것이다. URL 속성값에
+// 필요한 이스케이프는 `& " < >` 넷뿐이다. 컨텍스트의 URL 필드를 String이 아니라
+// `Value::from_safe_string(직접_이스케이프한_값)`으로 넘기면 된다 — 다만 그러려면
+// 컨텍스트를 Serialize 구조체가 아니라 Value로 조립해야 하므로, 내부 링크를 루트
+// 절대화하는 M1 작업과 함께 처리한다.
+#[derive(Debug, Clone, Serialize)]
+pub struct SiteCtx {
+    pub title: String,
+    pub description: String,
+    pub base_url: String,
+    pub language: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct PageCtx {
+    pub title: String,
+    pub description: String,
+    pub url: String,
+    pub permalink: String,
+    /// 렌더된 HTML. autoescape가 켜져 있으므로 템플릿에서 `| safe`가 필요하다.
+    pub content: String,
+    pub weight: i64,
+    pub draft: bool,
+    pub toc: bool,
+    pub language: String,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use minijinja::context;
+
+    /// 테스트가 병렬로 돌아도 겹치지 않도록 이름으로 구분한 임시 사이트 루트를 만든다.
+    /// 반환값을 드롭할 때 디렉터리를 지운다.
+    struct Site(std::path::PathBuf);
+
+    impl Site {
+        fn new(name: &str, files: &[(&str, &str)]) -> Self {
+            let root =
+                std::env::temp_dir().join(format!("sqzass-test-{}-{name}", std::process::id()));
+            let tdir = root.join(TEMPLATE_DIR);
+            std::fs::create_dir_all(&tdir).unwrap();
+            for (file, body) in files {
+                let path = tdir.join(file);
+                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+                std::fs::write(path, body).unwrap();
+            }
+            Self(root)
+        }
+
+        fn templates(&self) -> Templates {
+            Templates::load(&self.0).unwrap()
+        }
+    }
+
+    impl Drop for Site {
+        fn drop(&mut self) {
+            std::fs::remove_dir_all(&self.0).ok();
+        }
+    }
+
+    #[test]
+    fn resolves_extends_through_the_snapshot() {
+        let site = Site::new(
+            "extends",
+            &[
+                ("base.html", "<main>{% block body %}{% endblock %}</main>"),
+                (
+                    "page.html",
+                    "{% extends \"base.html\" %}{% block body %}{{ page.title }}{% endblock %}",
+                ),
+            ],
+        );
+        let out = site
+            .templates()
+            .render("page.html", context! { page => context!{ title => "Hi" } })
+            .unwrap();
+        assert_eq!(out, "<main>Hi</main>");
+    }
+
+    #[test]
+    fn resolves_include_from_a_subdirectory() {
+        let site = Site::new(
+            "include",
+            &[
+                ("partials/nav.html", "<nav>{{ site.title }}</nav>"),
+                ("page.html", "{% include \"partials/nav.html\" %}"),
+            ],
+        );
+        let out = site
+            .templates()
+            .render(
+                "page.html",
+                context! { site => context!{ title => "sqzass" } },
+            )
+            .unwrap();
+        assert_eq!(out, "<nav>sqzass</nav>");
+    }
+
+    #[test]
+    fn autoescapes_html_by_default() {
+        let site = Site::new("escape", &[("page.html", "{{ page.title }}")]);
+        let out = site
+            .templates()
+            .render(
+                "page.html",
+                context! { page => context!{ title => "<script>" } },
+            )
+            .unwrap();
+        assert!(!out.contains("<script>"), "이스케이프되지 않았다: {out}");
+    }
+
+    #[test]
+    fn plain_text_templates_are_not_html_escaped() {
+        // robots.txt / llms.txt 같은 평문 출력은 이스케이프하면 안 된다.
+        // `&`가 `&amp;`가 되면 파일이 망가진다.
+        let site = Site::new("txt", &[("robots.txt", "{{ page.title }}")]);
+        let out = site
+            .templates()
+            .render(
+                "robots.txt",
+                context! { page => context!{ title => "a & b" } },
+            )
+            .unwrap();
+        assert_eq!(out, "a & b");
+    }
+
+    #[test]
+    fn extensionless_templates_are_never_loaded() {
+        // autoescape가 확장자 기반이라 확장자 없는 템플릿은 이스케이프가 꺼진다.
+        // 로더가 애초에 안 받으므로 그 상황이 생길 수 없다는 걸 고정해둔다.
+        let site = Site::new(
+            "noext",
+            &[("page.html", "ok"), ("bare", "{{ page.title }}")],
+        );
+        let t = site.templates();
+        assert!(t.has("page.html"));
+        assert!(!t.has("bare"), "확장자 없는 파일이 템플릿으로 로드됐다");
+    }
+
+    #[test]
+    fn safe_filter_lets_rendered_html_through() {
+        let site = Site::new("safe", &[("page.html", "{{ page.content | safe }}")]);
+        let out = site
+            .templates()
+            .render(
+                "page.html",
+                context! { page => context!{ content => "<p>hi</p>" } },
+            )
+            .unwrap();
+        assert_eq!(out, "<p>hi</p>");
+    }
+
+    #[test]
+    fn undefined_attribute_access_is_an_error() {
+        let site = Site::new("undef", &[("page.html", "{{ page.nope.deeper }}")]);
+        let err = site
+            .templates()
+            .render("page.html", context! { page => context!{} });
+        assert!(err.is_err(), "undefined 접근이 통과했다");
+    }
+
+    #[test]
+    fn optional_field_existence_check_still_works() {
+        // SemiStrict를 고른 이유. Strict였다면 이게 에러가 된다.
+        let site = Site::new(
+            "optional",
+            &[(
+                "page.html",
+                "{% if page.missing %}yes{% else %}no{% endif %}",
+            )],
+        );
+        let out = site
+            .templates()
+            .render("page.html", context! { page => context!{} })
+            .unwrap();
+        assert_eq!(out, "no");
+    }
+}