diff --git a/Cargo.lock b/Cargo.lock index 3e16a40..34f90e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.20" @@ -241,6 +250,30 @@ 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 = "1.3.2" @@ -286,6 +319,15 @@ version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" +[[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.3" @@ -338,6 +380,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[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 = "cookie" version = "0.18.1" @@ -357,6 +416,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -409,6 +477,18 @@ dependencies = [ "syn", ] +[[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" @@ -419,12 +499,39 @@ dependencies = [ "windows-sys 0.61.0", ] +[[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.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "finl_unicode" +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" @@ -549,6 +656,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[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" @@ -768,6 +881,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inotify" version = "0.11.0" @@ -811,6 +934,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jetscii" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + [[package]] name = "js-sys" version = "0.3.80" @@ -847,6 +976,12 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[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.11.0" @@ -865,15 +1000,6 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" -[[package]] -name = "markdown" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" -dependencies = [ - "unicode-id", -] - [[package]] name = "matchit" version = "0.7.3" @@ -888,13 +1014,14 @@ dependencies = [ "axum", "axum-test", "clap", + "comrak", "futures-util", - "markdown", "minijinja", "minijinja-embed", "notify", "serde", "serde_json", + "syntect", "tempfile", "tokio", "tokio-test", @@ -946,6 +1073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1017,6 +1145,45 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[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 = "pin-project-lite" version = "0.2.16" @@ -1029,6 +1196,19 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[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 = "potential_utf" version = "0.1.3" @@ -1072,6 +1252,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.40" @@ -1117,6 +1306,23 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +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 = "reserve-port" version = "2.3.0" @@ -1148,6 +1354,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[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.2" @@ -1259,6 +1471,18 @@ dependencies = [ "digest", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.11" @@ -1321,6 +1545,27 @@ dependencies = [ "syn", ] +[[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 2.0.16", + "walkdir", + "yaml-rust", +] + [[package]] name = "tempfile" version = "3.22.0" @@ -1415,6 +1660,21 @@ dependencies = [ "zerovec", ] +[[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 = "tokio" version = "1.47.1" @@ -1591,6 +1851,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typenum" version = "1.18.0" @@ -1603,18 +1869,21 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" -[[package]] -name = "unicode-id" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" - [[package]] name = "unicode-ident" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "url" version = "2.5.7" @@ -1953,6 +2222,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[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 = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 1be4545..e357cd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,8 @@ documentation = "https://github.com/jfernandez/mdserve#readme" [dependencies] axum = { version = "0.7.9", features = ["ws"] } tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "net", "fs", "time"] } -markdown = "1.0" +comrak = { version = "0.54", default-features = false } +syntect = { version = "5.2", default-features = false, features = ["default-fancy"] } clap = { version = "4.5.45", features = ["derive"] } tower = "0.5.2" tower-http = { version = "0.6.6", features = ["fs", "cors"] } diff --git a/src/app.rs b/src/app.rs index 68b3358..96aac23 100644 --- a/src/app.rs +++ b/src/app.rs @@ -159,14 +159,24 @@ impl MarkdownState { } fn markdown_to_html(content: &str) -> Result { - let mut options = markdown::Options::gfm(); - options.compile.allow_dangerous_html = true; - options.parse.constructs.frontmatter = true; - - let html_body = markdown::to_html_with_options(content, &options) - .unwrap_or_else(|_| "Error parsing markdown".to_string()); - - Ok(html_body) + let mut options = comrak::Options::default(); + // GFM parity with the previous markdown-rs configuration. + options.extension.table = true; + options.extension.strikethrough = true; + options.extension.autolink = true; + options.extension.tasklist = true; + options.extension.footnotes = true; + options.extension.front_matter_delimiter = Some("---".to_string()); + // Allow raw HTML in markdown (mirrors allow_dangerous_html). + options.render.r#unsafe = true; + + let mut plugins = comrak::options::Plugins::default(); + let adapter = crate::highlight::SyntectAdapter; + plugins.render.codefence_syntax_highlighter = Some(&adapter); + + Ok(comrak::markdown_to_html_with_plugins( + content, &options, &plugins, + )) } } @@ -516,6 +526,7 @@ async fn render_markdown(state: &MarkdownState, current_file: &str) -> (StatusCo files => files, current_file => current_file, page_title => page_title, + highlight_css => Value::from_safe_string(crate::highlight::highlight_css().to_string()), }) { Ok(r) => r, Err(e) => { @@ -531,6 +542,7 @@ async fn render_markdown(state: &MarkdownState, current_file: &str) -> (StatusCo mermaid_enabled => has_mermaid, show_navigation => false, page_title => page_title, + highlight_css => Value::from_safe_string(crate::highlight::highlight_css().to_string()), }) { Ok(r) => r, Err(e) => { @@ -1027,8 +1039,10 @@ fn main() { assert!(body.contains("Name")); assert!(body.contains("John")); assert!(body.contains("deleted text")); - assert!(body.contains("
"));
-        assert!(body.contains("fn main()"));
+        assert!(body.contains(r#""#));
+        // Code is syntax-highlighted: tokens are wrapped in Syntect spans.
+        assert!(body.contains("syn-"));
+        assert!(body.contains("main"));
     }
 
     #[tokio::test]
diff --git a/src/highlight.rs b/src/highlight.rs
new file mode 100644
index 0000000..0487cc4
--- /dev/null
+++ b/src/highlight.rs
@@ -0,0 +1,283 @@
+//! Syntax highlighting for fenced code blocks.
+//!
+//! Comrak parses markdown into an AST and calls into a
+//! [`SyntaxHighlighterAdapter`] for every fenced code block. We highlight
+//! known languages with Syntect (emitting CSS classes, not inline colors, so
+//! the client-side theme switcher keeps working) and pass everything else
+//! through untouched. In particular `mermaid` blocks must retain their
+//! `class="language-mermaid"` marker so the client can transform them into
+//! diagrams.
+
+use std::borrow::Cow;
+use std::collections::HashMap;
+use std::fmt::{self, Write};
+use std::sync::OnceLock;
+
+use comrak::adapters::SyntaxHighlighterAdapter;
+use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGenerator};
+use syntect::parsing::SyntaxSet;
+use syntect::util::LinesWithEndings;
+
+/// Class prefix for every Syntect-generated span, keeping highlight classes
+/// namespaced away from the template's own CSS.
+const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed { prefix: "syn-" };
+
+fn syntax_set() -> &'static SyntaxSet {
+    static SYNTAX_SET: OnceLock = OnceLock::new();
+    SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
+}
+
+/// Adapter Comrak invokes for each fenced code block.
+pub(crate) struct SyntectAdapter;
+
+impl SyntaxHighlighterAdapter for SyntectAdapter {
+    fn write_highlighted(
+        &self,
+        output: &mut dyn Write,
+        lang: Option<&str>,
+        code: &str,
+    ) -> fmt::Result {
+        let lang = lang.unwrap_or("").trim();
+
+        // Mermaid and unknown languages: emit escaped source unchanged. The
+        // language class lives on the surrounding  tag, so mermaid blocks
+        // keep their marker for the client-side transform.
+        let syntax = if lang.eq_ignore_ascii_case("mermaid") {
+            None
+        } else {
+            syntax_set().find_syntax_by_token(lang)
+        };
+
+        let Some(syntax) = syntax else {
+            return write_escaped(output, code);
+        };
+
+        let mut generator =
+            ClassedHTMLGenerator::new_with_class_style(syntax, syntax_set(), CLASS_STYLE);
+        for line in LinesWithEndings::from(code) {
+            if generator
+                .parse_html_for_line_which_includes_newline(line)
+                .is_err()
+            {
+                // Fall back to plain escaped source on any highlight error.
+                return write_escaped(output, code);
+            }
+        }
+        output.write_str(&generator.finalize())
+    }
+
+    fn write_pre_tag(
+        &self,
+        output: &mut dyn Write,
+        attributes: HashMap<&'static str, Cow<'_, str>>,
+    ) -> fmt::Result {
+        write_opening_tag(output, "pre", &attributes)
+    }
+
+    fn write_code_tag(
+        &self,
+        output: &mut dyn Write,
+        attributes: HashMap<&'static str, Cow<'_, str>>,
+    ) -> fmt::Result {
+        write_opening_tag(output, "code", &attributes)
+    }
+}
+
+fn write_opening_tag(
+    output: &mut dyn Write,
+    tag: &str,
+    attributes: &HashMap<&'static str, Cow<'_, str>>,
+) -> fmt::Result {
+    write!(output, "<{tag}")?;
+    // Sort keys for deterministic output (tests match on exact attributes).
+    let mut keys: Vec<&&str> = attributes.keys().collect();
+    keys.sort();
+    for key in keys {
+        write!(output, " {key}=\"{}\"", attributes[*key])?;
+    }
+    output.write_char('>')
+}
+
+fn write_escaped(output: &mut dyn Write, text: &str) -> fmt::Result {
+    for ch in text.chars() {
+        match ch {
+            '&' => output.write_str("&")?,
+            '<' => output.write_str("<")?,
+            '>' => output.write_str(">")?,
+            '"' => output.write_str(""")?,
+            _ => output.write_char(ch)?,
+        }
+    }
+    Ok(())
+}
+
+/// CSS for the Syntect highlight classes, scoped per UI theme so the same
+/// pre-rendered HTML restyles instantly when the client switches themes.
+///
+/// Light UI themes use a light Syntect theme; dark ones a dark theme. Only
+/// foreground colors are kept — the code block background stays `--code-bg`.
+pub(crate) fn highlight_css() -> &'static str {
+    static CSS: OnceLock = OnceLock::new();
+    CSS.get_or_init(|| {
+        let themes = syntect::highlighting::ThemeSet::load_defaults();
+        let light = &themes.themes["InspiredGitHub"];
+        let dark = &themes.themes["base16-ocean.dark"];
+
+        let light_css = css_for_theme_with_class_style(light, CLASS_STYLE).unwrap_or_default();
+        let dark_css = css_for_theme_with_class_style(dark, CLASS_STYLE).unwrap_or_default();
+
+        let mut out = String::new();
+        for theme in ["light", "catppuccin-latte"] {
+            out.push_str(&scope_css(&light_css, theme));
+        }
+        for theme in ["dark", "catppuccin-macchiato", "catppuccin-mocha"] {
+            out.push_str(&scope_css(&dark_css, theme));
+        }
+        out
+    })
+}
+
+/// Prefix every rule in `css` with `html[data-theme=""]` and drop the
+/// base `background-color` so highlighting only sets foreground colors.
+fn scope_css(css: &str, theme: &str) -> String {
+    let scope = format!("html[data-theme=\"{theme}\"]");
+    let css = strip_comments(css);
+    let mut out = String::new();
+
+    for rule in css.split('}') {
+        let rule = rule.trim();
+        if rule.is_empty() {
+            continue;
+        }
+        let Some(brace) = rule.find('{') else {
+            continue;
+        };
+        let selectors = rule[..brace].trim();
+        let body = rule[brace + 1..].trim();
+
+        let scoped: Vec = selectors
+            .split(',')
+            .map(|sel| format!("{scope} {}", sel.trim()))
+            .collect();
+
+        // Keep foreground colors, drop backgrounds so --code-bg wins.
+        let body: String = body
+            .split(';')
+            .map(str::trim)
+            .filter(|decl| !decl.is_empty() && !decl.starts_with("background"))
+            .map(|decl| format!("{decl};"))
+            .collect();
+
+        if body.is_empty() {
+            continue;
+        }
+
+        out.push_str(&scoped.join(", "));
+        out.push_str(" { ");
+        out.push_str(&body);
+        out.push_str(" }\n");
+    }
+    out
+}
+
+fn strip_comments(css: &str) -> String {
+    let mut out = String::new();
+    let mut rest = css;
+    while let Some(start) = rest.find("/*") {
+        out.push_str(&rest[..start]);
+        match rest[start..].find("*/") {
+            Some(end) => rest = &rest[start + end + 2..],
+            None => return out,
+        }
+    }
+    out.push_str(rest);
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Run the adapter's code-block highlighter and return the inner HTML.
+    fn highlight(lang: Option<&str>, code: &str) -> String {
+        let mut out = String::new();
+        SyntectAdapter
+            .write_highlighted(&mut out, lang, code)
+            .expect("write_highlighted");
+        out
+    }
+
+    #[test]
+    fn known_language_emits_scope_classes() {
+        let html = highlight(Some("rust"), "fn main() {}\n");
+        assert!(html.contains("syn-"), "expected highlight spans: {html}");
+        assert!(html.contains("main"));
+    }
+
+    #[test]
+    fn mermaid_passes_through_without_highlighting() {
+        let code = "graph TD\n  A-->B\n";
+        let html = highlight(Some("mermaid"), code);
+        assert!(!html.contains("syn-"), "mermaid must not be highlighted");
+        // Content is preserved verbatim (only HTML-escaped) for the client.
+        assert!(html.contains("graph TD"));
+        assert!(html.contains("A-->B"));
+    }
+
+    #[test]
+    fn unknown_language_passes_through_escaped() {
+        let html = highlight(Some("not-a-language"), "a < b & c\n");
+        assert!(!html.contains("syn-"));
+        assert_eq!(html, "a < b & c\n");
+    }
+
+    #[test]
+    fn no_language_passes_through_escaped() {
+        let html = highlight(None, "\n");
+        assert!(!html.contains("syn-"));
+        assert_eq!(html, "<tag>\n");
+    }
+
+    #[test]
+    fn code_tag_keeps_language_class() {
+        // The language class lives on the  tag; this is what lets the
+        // client find mermaid blocks after skip-highlighting.
+        let mut out = String::new();
+        let mut attrs = HashMap::new();
+        attrs.insert("class", Cow::Borrowed("language-mermaid"));
+        SyntectAdapter
+            .write_code_tag(&mut out, attrs)
+            .expect("write_code_tag");
+        assert_eq!(out, r#""#);
+    }
+
+    #[test]
+    fn scope_css_prefixes_selector_and_drops_background() {
+        let css = ".syn-keyword {\n color: #abc;\n background-color: #000;\n}\n";
+        let scoped = scope_css(css, "dark");
+        assert!(scoped.contains(r#"html[data-theme="dark"] .syn-keyword"#));
+        assert!(scoped.contains("color: #abc;"));
+        assert!(!scoped.contains("background"));
+    }
+
+    #[test]
+    fn highlight_css_covers_every_theme() {
+        let css = highlight_css();
+        for theme in [
+            "light",
+            "catppuccin-latte",
+            "dark",
+            "catppuccin-macchiato",
+            "catppuccin-mocha",
+        ] {
+            assert!(
+                css.contains(&format!(r#"html[data-theme="{theme}"]"#)),
+                "missing scoped rules for {theme}"
+            );
+        }
+        assert!(
+            !css.contains("background"),
+            "backgrounds should be stripped"
+        );
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 2bdc5fa..422e801 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,6 +3,7 @@ use clap::Parser;
 use std::path::PathBuf;
 
 mod app;
+mod highlight;
 
 use app::{scan_markdown_files, serve_markdown};
 
diff --git a/templates/main.html b/templates/main.html
index 99b31bb..2750ec6 100644
--- a/templates/main.html
+++ b/templates/main.html
@@ -449,6 +449,9 @@
         a { color: var(--link-color); text-decoration: none; }
         a:hover { text-decoration: underline; }
         img { max-width: 100%; height: auto; }
+
+        /* Syntax highlighting (Syntect), scoped per theme */
+        {{ highlight_css }}
     
 
     {% if mermaid_enabled %}