sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit 086207ad4f9c3f1cfaeab3db3d46a64747d88d11
parent 7c51577c5e909d55ab9cbacb960952d8cd5917b4
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 17:56:16 +0900

Highlight code at build time, as CSS classes

syntect emits semantic class names rather than inline style attributes. The
convenience path (highlighted_html_for_string) writes colours straight into the
markup, which pins one theme into every document forever: dark mode becomes
impossible without rebuilding, every page carries duplicated colour strings,
and a strict style-src CSP is off the table. With classes, the same HTML works
under both themes and switching is a stylesheet swap.

The stylesheet is generated from two themes and carries the dark rules twice:
once inside prefers-color-scheme for the OS setting, once under
[data-theme="dark"] for a manual toggle. Native CSS nesting wraps syntect's
output as-is, so no CSS parsing is involved.

Classes are prefixed with hl-. Without a prefix syntect emits bare words like
"source", "keyword" and "string", which collide with ordinary site CSS.

two-face supplies TOML, TypeScript and Dockerfile, none of which are in
syntect's bundled set and all of which a developer-tool's documentation needs.
Both crates are pinned to fancy-regex: the default is Oniguruma, a C binding
that breaks musl static builds and cross-compilation, and two-face silently
pulls it back in unless its feature is matched. There are now zero references
to onig in the dependency tree.

Diffstat:
MCargo.lock | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 2++
Mdocs/templates/base.html | 3+++
Msrc/config.rs | 4++--
Asrc/highlight.rs | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 38++++++++++++++++++++++++++++++++++++--
Msrc/markdown.rs | 44+++++++++++++++++++++++++++++++++++---------
Msrc/render.rs | 12++++++++++--
Msrc/site.rs | 55++++++++++++++++++++++++++++++++++++++++++-------------
9 files changed, 656 insertion(+), 28 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock
@@ -2,6 +2,21 @@
 # It is not intended for manual editing.
 version = 4
 
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "anstream"
 version = "1.0.0"
@@ -58,6 +73,36 @@ version = "1.0.104"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
 
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
 [[package]]
 name = "bitflags"
 version = "2.13.1"
@@ -153,6 +198,21 @@ dependencies = [
  "windows-sys",
 ]
 
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
 [[package]]
 name = "encode_unicode"
 version = "1.0.0"
@@ -181,6 +241,17 @@ dependencies = [
  "windows-sys",
 ]
 
+[[package]]
+name = "fancy-regex"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
+dependencies = [
+ "bit-set",
+ "regex-automata",
+ "regex-syntax",
+]
+
 [[package]]
 name = "fastrand"
 version = "2.5.0"
@@ -193,6 +264,22 @@ version = "1.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
 
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
 [[package]]
 name = "getrandom"
 version = "0.4.3"
@@ -244,6 +331,12 @@ version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
 
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
 [[package]]
 name = "jetscii"
 version = "0.5.3"
@@ -256,12 +349,24 @@ version = "0.2.189"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
 
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
 [[package]]
 name = "linux-raw-sys"
 version = "0.12.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
 
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
 [[package]]
 name = "memo-map"
 version = "0.3.3"
@@ -278,6 +383,22 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
 [[package]]
 name = "once_cell"
 version = "1.21.4"
@@ -329,6 +450,25 @@ dependencies = [
  "siphasher",
 ]
 
+[[package]]
+name = "plist"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
+dependencies = [
+ "base64",
+ "indexmap",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
 [[package]]
 name = "proc-macro2"
 version = "1.0.107"
@@ -338,6 +478,15 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "quote"
 version = "1.0.47"
@@ -353,6 +502,23 @@ version = "6.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
 
+[[package]]
+name = "regex-automata"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
 [[package]]
 name = "rustc-hash"
 version = "2.1.3"
@@ -411,6 +577,19 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
 [[package]]
 name = "serde_spanned"
 version = "1.1.1"
@@ -420,6 +599,12 @@ dependencies = [
  "serde_core",
 ]
 
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
 [[package]]
 name = "similar"
 version = "2.7.0"
@@ -448,7 +633,9 @@ dependencies = [
  "insta",
  "minijinja",
  "serde",
+ "syntect",
  "toml",
+ "two-face",
  "walkdir",
 ]
 
@@ -469,6 +656,27 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "syntect"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
+dependencies = [
+ "bincode",
+ "fancy-regex",
+ "flate2",
+ "fnv",
+ "once_cell",
+ "plist",
+ "regex-syntax",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror",
+ "walkdir",
+ "yaml-rust",
+]
+
 [[package]]
 name = "tempfile"
 version = "3.27.0"
@@ -482,6 +690,56 @@ dependencies = [
  "windows-sys",
 ]
 
+[[package]]
+name = "thiserror"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.3.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
 [[package]]
 name = "tinyvec"
 version = "1.12.0"
@@ -536,6 +794,17 @@ version = "1.1.2+spec-1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
 
+[[package]]
+name = "two-face"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b285c51f8a6ade109ed4566d33ac4fb289fb5d6cf87ed70908a5eaf65e948e34"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "syntect",
+]
+
 [[package]]
 name = "typed-arena"
 version = "2.0.2"
@@ -602,3 +871,18 @@ name = "winnow"
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
@@ -32,7 +32,9 @@ clap      = { version = "4.6", features = ["derive"] }
 comrak    = { version = "0.54", default-features = false }
 minijinja = "2.21"
 serde     = { version = "1", features = ["derive"] }
+syntect = { version = "5.3", default-features = false, features = ["default-fancy"] }
 toml      = "1.1"
+two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] }
 walkdir   = "2"
 
 # --- M1 (아래는 해당 기능 착수할 때 주석 해제) ---
diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -8,6 +8,9 @@
 <title>{% if page.url == "/" or page.url == "/" ~ page.language ~ "/" %}{{ 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 }}">
+{%- if site.highlight_css %}
+<link rel="stylesheet" href="{{ site.highlight_css }}">
+{%- endif %}
 {%- for t in page.translations %}
 <link rel="alternate" hreflang="{{ t.code }}" href="{{ site.base_url }}{{ t.url }}">
 {%- endfor %}
diff --git a/src/config.rs b/src/config.rs
@@ -111,17 +111,17 @@ pub enum HeadingAnchors {
 #[derive(Debug, Clone, Deserialize)]
 #[serde(default)]
 pub struct Highlight {
+    pub enabled: bool,
     pub theme_light: String,
     pub theme_dark: String,
-    pub line_numbers: bool,
 }
 
 impl Default for Highlight {
     fn default() -> Self {
         Self {
+            enabled: true,
             theme_light: "InspiredGitHub".into(),
             theme_dark: "base16-ocean.dark".into(),
-            line_numbers: false,
         }
     }
 }
diff --git a/src/highlight.rs b/src/highlight.rs
@@ -0,0 +1,242 @@
+//! 빌드 타임 구문 강조.
+//!
+//! **인라인 `style=`을 쓰지 않는다.** syntect의 편의 함수(`highlighted_html_for_string`)는
+//! 색을 HTML에 직접 박는데, 그러면 테마 하나가 문서에 영구히 고정되어 다크 모드가
+//! 불가능해지고, 페이지마다 색 문자열이 중복되며, CSP의 `style-src` 강화도 막힌다.
+//! 대신 의미론적 CSS 클래스를 뽑고 테마는 스타일시트로 분리한다 — 그래서 같은 HTML이
+//! 라이트/다크 양쪽에서 동작한다.
+
+use crate::config::Highlight as HighlightConfig;
+use anyhow::{Context, Result};
+use comrak::adapters::SyntaxHighlighterAdapter;
+use std::borrow::Cow;
+use std::collections::HashMap;
+use std::fmt;
+use syntect::highlighting::ThemeSet;
+use syntect::html::{ClassStyle, ClassedHTMLGenerator, css_for_theme_with_class_style};
+use syntect::parsing::SyntaxSet;
+use syntect::util::LinesWithEndings;
+
+/// 클래스 접두사. 접두사가 없으면 syntect가 `source`, `keyword`, `string` 같은
+/// 일반적인 단어를 그대로 클래스로 쓰기 때문에 사이트 CSS와 충돌한다.
+/// syntect API가 `&'static str`을 요구하므로 상수여야 한다.
+const CLASS_PREFIX: &str = "hl-";
+const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed {
+    prefix: CLASS_PREFIX,
+};
+
+pub struct Highlighter {
+    syntaxes: SyntaxSet,
+}
+
+impl Highlighter {
+    pub fn new() -> Self {
+        // `extra_newlines` 여야 한다. ClassedHTMLGenerator는 줄 끝의 개행을 요구하고,
+        // no-newlines 덤프와 섞으면 조용히 잘못된 결과가 나온다.
+        Self {
+            syntaxes: two_face::syntax::extra_newlines(),
+        }
+    }
+}
+
+impl Default for Highlighter {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl SyntaxHighlighterAdapter for Highlighter {
+    fn write_highlighted(
+        &self,
+        out: &mut dyn fmt::Write,
+        lang: Option<&str>,
+        code: &str,
+    ) -> fmt::Result {
+        let syntax = lang
+            .and_then(|l| {
+                let l = l.trim();
+                self.syntaxes
+                    .find_syntax_by_token(l)
+                    .or_else(|| self.syntaxes.find_syntax_by_extension(l))
+            })
+            .unwrap_or_else(|| self.syntaxes.find_syntax_plain_text());
+
+        let mut generator =
+            ClassedHTMLGenerator::new_with_class_style(syntax, &self.syntaxes, CLASS_STYLE);
+        for line in LinesWithEndings::from(code) {
+            // 강조에 실패해도 문서 빌드를 통째로 죽이지 않는다. 이 줄만 포기하고
+            // 이스케이프된 원문을 내보낸다.
+            if generator
+                .parse_html_for_line_which_includes_newline(line)
+                .is_err()
+            {
+                return write_plain(out, code);
+            }
+        }
+        out.write_str(&generator.finalize())
+    }
+
+    fn write_pre_tag(
+        &self,
+        out: &mut dyn fmt::Write,
+        _attributes: HashMap<&'static str, Cow<'_, str>>,
+    ) -> fmt::Result {
+        out.write_str("<pre class=\"highlight\">")
+    }
+
+    fn write_code_tag(
+        &self,
+        out: &mut dyn fmt::Write,
+        attributes: HashMap<&'static str, Cow<'_, str>>,
+    ) -> fmt::Result {
+        // comrak이 `class="language-rust"`를 넘겨준다. 언어를 data 속성으로도 복제해
+        // 복사 버튼이나 라벨이 클래스 문자열을 파싱하지 않아도 되게 한다.
+        let class = attributes.get("class").map(|c| c.as_ref()).unwrap_or("");
+        let lang = class.strip_prefix("language-").unwrap_or("");
+        if lang.is_empty() {
+            out.write_str("<code>")
+        } else {
+            write!(
+                out,
+                "<code class=\"{}\" data-lang=\"{}\">",
+                escape_attr(class),
+                escape_attr(lang)
+            )
+        }
+    }
+}
+
+fn write_plain(out: &mut dyn fmt::Write, code: &str) -> fmt::Result {
+    for ch in code.chars() {
+        match ch {
+            '&' => out.write_str("&amp;")?,
+            '<' => out.write_str("&lt;")?,
+            '>' => out.write_str("&gt;")?,
+            _ => out.write_char(ch)?,
+        }
+    }
+    Ok(())
+}
+
+fn escape_attr(s: &str) -> String {
+    s.replace('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;")
+        .replace('"', "&quot;")
+}
+
+/// 라이트/다크 두 테마의 CSS를 하나의 스타일시트로 합친다.
+///
+/// 다크 규칙을 두 번 내보낸다: OS 설정을 따르는 `prefers-color-scheme` 안에 한 번,
+/// 수동 토글용 `[data-theme="dark"]`에 한 번. 네이티브 CSS 중첩을 써서 생성된
+/// 규칙을 문자열로 파싱하지 않고 그대로 감싼다.
+pub fn stylesheet(cfg: &HighlightConfig) -> Result<String> {
+    let themes = ThemeSet::load_defaults();
+
+    let get = |name: &str| -> Result<String> {
+        let theme = themes.themes.get(name).with_context(|| {
+            format!(
+                "'{name}' 테마를 찾을 수 없습니다. 사용 가능: {}",
+                themes.themes.keys().cloned().collect::<Vec<_>>().join(", ")
+            )
+        })?;
+        css_for_theme_with_class_style(theme, CLASS_STYLE)
+            .with_context(|| format!("'{name}' 테마의 CSS 생성 실패"))
+    };
+
+    let light = get(&cfg.theme_light)?;
+    let dark = get(&cfg.theme_dark)?;
+
+    Ok(format!(
+        "/* generated by sqzass — do not edit */\n\
+         /* light: {} */\n{}\n\
+         /* dark: {} */\n\
+         @media (prefers-color-scheme: dark) {{\n  :root:not([data-theme=\"light\"]) {{\n{}\n  }}\n}}\n\
+         :root[data-theme=\"dark\"] {{\n{}\n}}\n",
+        cfg.theme_light, light, cfg.theme_dark, dark, dark
+    ))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::Markdown as MarkdownConfig;
+    use crate::markdown::Renderer;
+
+    fn render(md: &str) -> String {
+        Renderer::new(&MarkdownConfig::default())
+            .with_highlighter(Highlighter::new())
+            .render(md)
+            .html
+    }
+
+    #[test]
+    fn emits_css_classes_not_inline_styles() {
+        let html = render("```rust\nfn main() { let x = 1; }\n```");
+        assert!(
+            !html.contains("style=\""),
+            "인라인 style이 나왔다 — 다크모드가 불가능해진다: {html}"
+        );
+        assert!(html.contains("hl-"), "하이라이트 클래스가 없다: {html}");
+    }
+
+    #[test]
+    fn tags_carry_language_metadata() {
+        let html = render("```rust\nfn main() {}\n```");
+        assert!(html.contains(r#"class="highlight""#), "실제: {html}");
+        assert!(html.contains(r#"data-lang="rust""#), "실제: {html}");
+    }
+
+    #[test]
+    fn unknown_language_falls_back_to_plain_text() {
+        let html = render("```notalanguage\nhello\n```");
+        assert!(html.contains("hello"), "본문이 사라졌다: {html}");
+    }
+
+    #[test]
+    fn fence_without_language_still_renders() {
+        let html = render("```\nplain code\n```");
+        assert!(html.contains("plain code"), "실제: {html}");
+    }
+
+    #[test]
+    fn code_is_escaped_not_executed() {
+        let html = render("```html\n<script>alert(1)</script>\n```");
+        assert!(
+            !html.contains("<script>alert"),
+            "코드 블록 안의 스크립트가 이스케이프되지 않았다: {html}"
+        );
+    }
+
+    #[test]
+    fn two_face_provides_syntaxes_syntect_lacks() {
+        // two-face를 넣은 이유가 이것이다 — 개발 도구 문서에 반드시 필요한 언어들이
+        // syntect 기본 세트에 없다.
+        let h = Highlighter::new();
+        for lang in ["toml", "typescript", "dockerfile"] {
+            assert!(
+                h.syntaxes.find_syntax_by_token(lang).is_some(),
+                "'{lang}' 문법이 없다"
+            );
+        }
+    }
+
+    #[test]
+    fn stylesheet_contains_both_themes_and_a_manual_toggle() {
+        let css = stylesheet(&HighlightConfig::default()).unwrap();
+        assert!(
+            css.contains("prefers-color-scheme: dark"),
+            "OS 다크 대응이 없다"
+        );
+        assert!(css.contains("[data-theme=\"dark\"]"), "수동 토글 훅이 없다");
+        assert!(css.contains(".hl-"), "접두사 붙은 클래스가 없다");
+    }
+
+    #[test]
+    fn stylesheet_reports_unknown_theme_names() {
+        let mut cfg = HighlightConfig::default();
+        cfg.theme_dark = "NoSuchTheme".into();
+        let err = stylesheet(&cfg).unwrap_err().to_string();
+        assert!(err.contains("NoSuchTheme"), "실제: {err}");
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -2,6 +2,7 @@
 
 pub mod config;
 pub mod content;
+pub mod highlight;
 pub mod markdown;
 pub mod render;
 pub mod site;
@@ -26,6 +27,10 @@ pub struct BuildOptions {
     pub base_url: Option<String>,
 }
 
+/// 생성된 하이라이트 스타일시트의 출력 경로. 에셋 파이프라인이 들어오면
+/// 콘텐츠 해시가 붙은 이름으로 바뀐다.
+const HIGHLIGHT_CSS_URL: &str = "/assets/highlight.css";
+
 #[derive(Debug, Default)]
 pub struct BuildStats {
     pub pages_written: usize,
@@ -45,7 +50,14 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
     let pages = content::discover(root, &cfg, drafts)?;
     let site = Site::build(&pages, &cfg);
     let templates = Templates::load(root)?;
-    let md = markdown::Renderer::new(&cfg.markdown);
+
+    let mut md = markdown::Renderer::new(&cfg.markdown);
+    let highlight_css_url = if cfg.highlight.enabled {
+        md = md.with_highlighter(highlight::Highlighter::new());
+        Some(HIGHLIGHT_CSS_URL.to_string())
+    } else {
+        None
+    };
 
     // 출력 디렉터리를 매번 새로 만든다. 지운 페이지가 유령으로 남는 걸 막는다.
     if out_dir.exists() {
@@ -53,9 +65,29 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
             .with_context(|| format!("{}을(를) 비울 수 없습니다", out_dir.display()))?;
     }
 
+    // 하이라이트 스타일시트는 테마에서 생성한다. HTML은 클래스만 담고 있으므로
+    // 이 파일 하나를 바꾸면 사이트 전체의 코드 색이 바뀐다.
+    if cfg.highlight.enabled {
+        let css = highlight::stylesheet(&cfg.highlight)?;
+        let dest = out_dir.join(HIGHLIGHT_CSS_URL.trim_start_matches('/'));
+        if let Some(parent) = dest.parent() {
+            std::fs::create_dir_all(parent)
+                .with_context(|| format!("{}을(를) 만들 수 없습니다", parent.display()))?;
+        }
+        std::fs::write(&dest, css)
+            .with_context(|| format!("{}을(를) 쓸 수 없습니다", dest.display()))?;
+    }
+
     let mut written = 0usize;
     for page in &pages {
-        let html = render_page(page, &site, &cfg, &templates, &md)?;
+        let html = render_page(
+            page,
+            &site,
+            &cfg,
+            &templates,
+            &md,
+            highlight_css_url.as_deref(),
+        )?;
         let dest = out_dir.join(&page.out_path);
         if let Some(parent) = dest.parent() {
             std::fs::create_dir_all(parent)
@@ -91,6 +123,7 @@ fn render_page(
     cfg: &Config,
     templates: &Templates,
     md: &markdown::Renderer,
+    highlight_css: Option<&str>,
 ) -> Result<String> {
     let template = select_template(page, site, templates)?;
 
@@ -100,6 +133,7 @@ fn render_page(
         base_url: cfg.base_url_trimmed().to_string(),
         language: page.language.clone(),
         sections: site::section_ctx(site.sections(&page.language), site.pages),
+        highlight_css: highlight_css.map(str::to_string),
     };
 
     let rendered = md.render(&page.body);
diff --git a/src/markdown.rs b/src/markdown.rs
@@ -29,11 +29,21 @@ pub struct Rendered {
 
 pub struct Renderer {
     cfg: MarkdownConfig,
+    highlighter: Option<crate::highlight::Highlighter>,
 }
 
 impl Renderer {
     pub fn new(cfg: &MarkdownConfig) -> Self {
-        Self { cfg: cfg.clone() }
+        Self {
+            cfg: cfg.clone(),
+            highlighter: None,
+        }
+    }
+
+    /// 구문 강조를 켠다. 없으면 코드 블록은 `<code class="language-x">`로만 나간다.
+    pub fn with_highlighter(mut self, h: crate::highlight::Highlighter) -> Self {
+        self.highlighter = Some(h);
+        self
     }
 
     fn options<'p>(&self) -> Options<'p> {
@@ -67,10 +77,16 @@ impl Renderer {
         let html = {
             let mut plugins = Plugins::default();
             plugins.render.heading_adapter = Some(&headings);
+            if let Some(h) = &self.highlighter {
+                plugins.render.codefence_syntax_highlighter = Some(h);
+            }
             markdown_to_html_with_plugins(body, &options, &plugins)
         };
 
-        Rendered { html, toc: headings.into_toc() }
+        Rendered {
+            html,
+            toc: headings.into_toc(),
+        }
     }
 }
 
@@ -95,11 +111,18 @@ struct CollectorState {
 
 impl HeadingCollector {
     fn new(anchors: HeadingAnchors) -> Self {
-        Self { anchors, state: Mutex::new(CollectorState::default()) }
+        Self {
+            anchors,
+            state: Mutex::new(CollectorState::default()),
+        }
     }
 
     fn into_toc(self) -> Vec<TocEntry> {
-        let flat = self.state.into_inner().unwrap_or_else(|e| e.into_inner()).flat;
+        let flat = self
+            .state
+            .into_inner()
+            .unwrap_or_else(|e| e.into_inner())
+            .flat;
         nest(&flat)
     }
 }
@@ -114,7 +137,8 @@ impl HeadingAdapter for HeadingCollector {
         let id = {
             let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
             let id = st.anchorizer.anchorize(&heading.content);
-            st.flat.push((heading.level, id.clone(), heading.content.clone()));
+            st.flat
+                .push((heading.level, id.clone(), heading.content.clone()));
             st.current_id = Some(id.clone());
             id
         };
@@ -257,7 +281,11 @@ mod tests {
             "실제: {}",
             r.html
         );
-        assert!(r.html.contains(r##"href="#getting-started""##), "실제: {}", r.html);
+        assert!(
+            r.html.contains(r##"href="#getting-started""##),
+            "실제: {}",
+            r.html
+        );
     }
 
     #[test]
@@ -279,9 +307,7 @@ mod tests {
 
     #[test]
     fn toc_nests_by_level() {
-        let r = renderer().render(
-            "# A\n\n## A1\n\n### A1a\n\n## A2\n\n# B\n",
-        );
+        let r = renderer().render("# A\n\n## A1\n\n### A1a\n\n## A2\n\n# B\n");
         assert_eq!(r.toc.len(), 2, "최상위는 A와 B: {:?}", r.toc);
         assert_eq!(r.toc[0].title, "A");
         assert_eq!(r.toc[0].children.len(), 2);
diff --git a/src/render.rs b/src/render.rs
@@ -163,6 +163,8 @@ pub struct SiteCtx {
     pub language: String,
     /// 이 언어의 최상위 섹션들. 사이드바는 여기서 만든다.
     pub sections: Vec<crate::site::SectionCtx>,
+    /// 생성된 하이라이트 스타일시트 URL. 강조가 꺼져 있으면 `None`.
+    pub highlight_css: Option<String>,
 }
 
 #[derive(Debug, Clone, Serialize)]
@@ -273,7 +275,10 @@ mod tests {
     fn urls_keep_their_slashes() {
         // minijinja 기본 이스케이퍼는 `/`를 `&#x2f;`로 바꿔서 사이트의 모든 링크를
         // 읽을 수 없게 만든다. 커스텀 포매터로 되돌린 것을 고정한다.
-        let site = Site::new("slash", &[("page.html", r#"<a href="{{ page.url }}">x</a>"#)]);
+        let site = Site::new(
+            "slash",
+            &[("page.html", r#"<a href="{{ page.url }}">x</a>"#)],
+        );
         let out = site
             .templates()
             .render(
@@ -294,7 +299,10 @@ mod tests {
             ("a & b", "a &amp; b"),
             ("say \"hi\"", "say &quot;hi&quot;"),
             ("it's", "it&#x27;s"),
-            ("</script><img onerror=x>", "&lt;/script&gt;&lt;img onerror=x&gt;"),
+            (
+                "</script><img onerror=x>",
+                "&lt;/script&gt;&lt;img onerror=x&gt;",
+            ),
         ] {
             let out = t
                 .render("page.html", context! { page => context!{ t => input } })
diff --git a/src/site.rs b/src/site.rs
@@ -56,12 +56,20 @@ impl<'a> Site<'a> {
             .map(|(lang, idxs)| (lang, build_tree(pages, &idxs, cfg)))
             .collect();
 
-        Self { cfg, pages, trees, translations }
+        Self {
+            cfg,
+            pages,
+            trees,
+            translations,
+        }
     }
 
     /// 해당 언어의 최상위 섹션들.
     pub fn sections(&self, language: &str) -> &[Section] {
-        self.trees.get(language).map(|v| v.as_slice()).unwrap_or(&[])
+        self.trees
+            .get(language)
+            .map(|v| v.as_slice())
+            .unwrap_or(&[])
     }
 
     /// 페이지가 속한 섹션의 `page_template`. 템플릿 선택 단계에서 쓴다.
@@ -174,7 +182,10 @@ fn dirs_of(p: &Page) -> Vec<String> {
 fn ensure_section<'s>(root: &'s mut Section, dirs: &[String]) -> &'s mut Section {
     let mut cur = root;
     for (depth, seg) in dirs.iter().enumerate() {
-        let pos = cur.subsections.iter().position(|s| s.path.last() == Some(seg));
+        let pos = cur
+            .subsections
+            .iter()
+            .position(|s| s.path.last() == Some(seg));
         let pos = match pos {
             Some(p) => p,
             None => {
@@ -204,7 +215,11 @@ fn ensure_section<'s>(root: &'s mut Section, dirs: &[String]) -> &'s mut Section
 fn find_section<'s>(sections: &'s [Section], dirs: &[String]) -> Option<&'s Section> {
     let (first, rest) = dirs.split_first()?;
     let node = sections.iter().find(|s| s.path.last() == Some(first))?;
-    if rest.is_empty() { Some(node) } else { find_section(&node.subsections, rest) }
+    if rest.is_empty() {
+        Some(node)
+    } else {
+        find_section(&node.subsections, rest)
+    }
 }
 
 /// 섹션 자신의 `sort_by`가 있으면 그걸, 없으면 `[nav] sort_by`를 쓴다.
@@ -402,22 +417,26 @@ mod tests {
             .iter()
             .map(|&i| pages[i].title.as_str())
             .collect();
-        assert_eq!(titles, vec!["A", "B", "C"], "섹션의 sort_by가 전역을 못 이겼다");
+        assert_eq!(
+            titles,
+            vec!["A", "B", "C"],
+            "섹션의 sort_by가 전역을 못 이겼다"
+        );
     }
 
     #[test]
     fn directory_without_index_still_appears() {
         // `_index.md`를 깜빡해도 페이지가 사이드바에서 사라지면 안 된다.
         let cfg = cfg();
-        let f = Fixture::new(
-            "noindex",
-            &[("guides/howto.md", &fm("How to", ""))],
-        );
+        let f = Fixture::new("noindex", &[("guides/howto.md", &fm("How to", ""))]);
         let pages = f.pages(&cfg);
         let site = Site::build(&pages, &cfg);
         let en = site.sections("en");
         assert_eq!(en.len(), 1);
-        assert_eq!(en[0].title, "guides", "디렉터리 이름이 제목 대체값이어야 한다");
+        assert_eq!(
+            en[0].title, "guides",
+            "디렉터리 이름이 제목 대체값이어야 한다"
+        );
         assert_eq!(en[0].pages.len(), 1);
     }
 
@@ -455,7 +474,10 @@ mod tests {
         let f = Fixture::new(
             "ptmpl",
             &[
-                ("docs/_index.md", &fm("Docs", "page_template = \"doc.html\"\n")),
+                (
+                    "docs/_index.md",
+                    &fm("Docs", "page_template = \"doc.html\"\n"),
+                ),
                 ("docs/x.md", &fm("X", "")),
                 ("other.md", &fm("Other", "")),
             ],
@@ -475,8 +497,15 @@ mod tests {
         let mut a = comrak::Anchorizer::new();
         assert_eq!(a.anchorize("설치"), "설치");
         assert_eq!(a.anchorize("정적 사이트 생성기"), "정적-사이트-생성기");
-        assert_eq!(a.anchorize("설치"), "설치-1", "중복 제목 dedupe가 동작해야 한다");
-        assert_eq!(a.anchorize("한글 제목 with English"), "한글-제목-with-english");
+        assert_eq!(
+            a.anchorize("설치"),
+            "설치-1",
+            "중복 제목 dedupe가 동작해야 한다"
+        );
+        assert_eq!(
+            a.anchorize("한글 제목 with English"),
+            "한글-제목-with-english"
+        );
         let _ = Path::new("");
     }
 }