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("&")?,
+ '<' => out.write_str("<")?,
+ '>' => out.write_str(">")?,
+ _ => out.write_char(ch)?,
+ }
+ }
+ Ok(())
+}
+
+fn escape_attr(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+
+/// 라이트/다크 두 테마의 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 기본 이스케이퍼는 `/`를 `/`로 바꿔서 사이트의 모든 링크를
// 읽을 수 없게 만든다. 커스텀 포매터로 되돌린 것을 고정한다.
- 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 & b"),
("say \"hi\"", "say "hi""),
("it's", "it's"),
- ("</script><img onerror=x>", "</script><img onerror=x>"),
+ (
+ "</script><img onerror=x>",
+ "</script><img onerror=x>",
+ ),
] {
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("");
}
}
|