feat: add syntect code highlighting with catppuccin themes

- Add syntect dependency (server feature, fancy-regex backend)
- Create highlight module with LazyLock globals for SyntaxSet + themes
- Intercept CodeBlock events in markdown rendering for syntax highlighting
- Update ammonia whitelist to allow span/pre/code class/style attributes
- Add generate_highlight_css binary for CSS generation
- Add highlight-css Makefile target (runs before tailwindcss)
- Import generated highlight.css in input.css
- Remove hardcoded code block colors, let catppuccin CSS take over
This commit is contained in:
xfy 2026-06-03 11:52:58 +08:00
parent 37d95e6a33
commit 11261836c7
9 changed files with 2250 additions and 7 deletions

135
Cargo.lock generated
View File

@ -2,6 +2,12 @@
# 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"
@ -233,6 +239,30 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[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.11.1"
@ -572,6 +602,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 = "crossbeam-utils"
version = "0.8.21"
@ -1482,12 +1521,33 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[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 = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[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"
@ -2276,6 +2336,12 @@ dependencies = [
"libc",
]
[[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"
@ -2484,6 +2550,16 @@ dependencies = [
"unicase",
]
[[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 = "mio"
version = "1.2.0"
@ -2780,6 +2856,19 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plist"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64",
"indexmap",
"quick-xml",
"serde",
"time",
]
[[package]]
name = "postgres-protocol"
version = "0.6.11"
@ -2915,6 +3004,15 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
@ -3437,6 +3535,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "siphasher"
version = "1.0.3"
@ -3617,6 +3721,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.18",
"walkdir",
"yaml-rust",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
@ -4868,6 +4993,15 @@ version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[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 = "yggdrasil"
version = "0.1.0"
@ -4885,6 +5019,7 @@ dependencies = [
"rand 0.8.6",
"regex",
"serde",
"syntect",
"tokio",
"tokio-postgres",
"tower-http",

View File

@ -22,6 +22,7 @@ rand = { version = "0.8", features = ["getrandom"] }
getrandom = { version = "0.2", features = ["js"] }
http = "1"
ammonia = { version = "4", optional = true }
syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "default-fancy", "html", "parsing", "dump-load"], optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Document", "Window", "HtmlDocument", "Storage", "Element", "DomTokenList", "MediaQueryList", "HtmlScriptElement"] }
@ -50,4 +51,5 @@ server = [
"dep:tracing-subscriber",
"dep:tower-http",
"dep:ammonia",
"dep:syntect",
]

View File

@ -1,10 +1,14 @@
.PHONY: dev build css css-watch clean build-editor
.PHONY: dev build css css-watch clean build-editor highlight-css
build:
@$(MAKE) build-editor
@$(MAKE) highlight-css
@tailwindcss -i input.css -o public/style.css --minify
@dx build --release
highlight-css:
@cargo run --bin generate_highlight_css
build-editor:
@echo "Building Tiptap editor..."
@cd libs/tiptap-editor && npm install && npx vite build

1895
generated/highlight.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
@import "tailwindcss";
@import "./generated/highlight.css";
@custom-variant dark (&:where(.dark, .dark *));
@ -342,7 +343,6 @@
.md-content pre {
position: relative;
margin-bottom: var(--content-gap-paper);
background: var(--color-paper-code-block);
border-radius: var(--radius-paper);
overflow-x: auto;
}
@ -350,7 +350,6 @@
.md-content pre code {
display: block;
padding: 16px;
color: rgb(213, 213, 214);
background: transparent;
border-radius: 0;
overflow-x: auto;

View File

@ -172,10 +172,13 @@ async fn ensure_unique_slug(
fn clean_html(input: &str) -> String {
let mut builder = ammonia::Builder::default();
builder
.add_generic_attributes(&["class", "aria-hidden", "aria-label", "id", "role", "accesskey", "title"])
.add_generic_attributes(&["class", "style", "aria-hidden", "aria-label", "id", "role", "accesskey", "title"])
.add_tags(&["details", "summary"])
.url_relative(ammonia::UrlRelative::PassThrough)
.add_tag_attributes("a", &["class", "aria-hidden", "aria-label"])
.add_tag_attributes("span", &["class", "style"])
.add_tag_attributes("pre", &["class", "style"])
.add_tag_attributes("code", &["class", "style"])
.add_tag_attributes("h1", &["id", "class"])
.add_tag_attributes("h2", &["id", "class"])
.add_tag_attributes("h3", &["id", "class"])
@ -243,12 +246,14 @@ fn render_markdown_enhanced(md: &str) -> RenderedContent {
let mut html = String::new();
let mut heading_idx = 0;
let mut in_heading = false;
let mut in_codeblock = false;
let mut code_lang: Option<String> = None;
let mut code_buffer = String::new();
let mut non_heading_events: Vec<Event> = Vec::new();
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => {
// Flush non-heading events first
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
@ -286,9 +291,39 @@ fn render_markdown_enhanced(md: &str) -> RenderedContent {
}
in_heading = false;
}
Event::Start(Tag::CodeBlock(kind)) => {
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
in_codeblock = true;
code_lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(lang) => {
if lang.is_empty() {
None
} else {
Some(lang.to_string())
}
}
_ => None,
};
code_buffer.clear();
}
Event::Text(text) if in_codeblock => {
code_buffer.push_str(&text);
}
Event::End(TagEnd::CodeBlock) => {
let highlighted =
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
html.push_str("<pre><code>");
html.push_str(&highlighted);
html.push_str("</code></pre>");
in_codeblock = false;
code_lang = None;
code_buffer.clear();
}
_ => {
if in_heading {
// Manually render heading content
match event {
Event::Text(text) => html.push_str(&clean_html(&text)),
Event::Code(code) => {
@ -298,7 +333,7 @@ fn render_markdown_enhanced(md: &str) -> RenderedContent {
}
_ => {}
}
} else {
} else if !in_codeblock {
non_heading_events.push(event);
}
}

View File

@ -0,0 +1,104 @@
use syntect::highlighting::ThemeSet;
use syntect::html::{css_for_theme_with_class_style, ClassStyle};
fn main() {
let latte = ThemeSet::get_theme("themes/Catppuccin Latte.tmTheme")
.expect("Failed to load Catppuccin Latte theme");
let mocha = ThemeSet::get_theme("themes/Catppuccin Mocha.tmTheme")
.expect("Failed to load Catppuccin Mocha theme");
let latte_css = css_for_theme_with_class_style(&latte, ClassStyle::Spaced)
.expect("Failed to generate Latte CSS");
let mocha_css = css_for_theme_with_class_style(&mocha, ClassStyle::Spaced)
.expect("Failed to generate Mocha CSS");
let latte_clean = strip_comments(&latte_css);
let mocha_clean = strip_comments(&mocha_css);
let latte_rewritten = rewrite_rules(&latte_clean, ".md-content pre code", "");
let mocha_rewritten = rewrite_rules(&mocha_clean, ".md-content pre code", ".dark ");
let mut output = String::new();
output.push_str("/* Auto-generated by generate_highlight_css — DO NOT EDIT */\n\n");
output.push_str("/* Catppuccin Latte (light) */\n");
output.push_str(&latte_rewritten);
output.push_str("\n/* Catppuccin Mocha (dark) */\n");
output.push_str(&mocha_rewritten);
std::fs::create_dir_all("generated").expect("Failed to create generated/");
std::fs::write("generated/highlight.css", output)
.expect("Failed to write generated/highlight.css");
println!("Generated generated/highlight.css");
}
fn strip_comments(css: &str) -> String {
let mut result = String::with_capacity(css.len());
let chars: Vec<char> = css.chars().collect();
let mut i = 0;
while i < chars.len() {
if i + 1 < chars.len() && chars[i] == '/' && chars[i + 1] == '*' {
while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
i += 2;
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
fn rewrite_rules(css: &str, base: &str, prefix: &str) -> String {
let mut out = String::new();
let mut pos = 0;
let bytes = css.as_bytes();
while pos < bytes.len() {
let rest = &css[pos..];
let open = match rest.find('{') {
Some(i) => i,
None => break,
};
let selector = rest[..open].trim();
if selector.is_empty() {
pos += open + 1;
continue;
}
let after_open = pos + open + 1;
let mut depth = 1usize;
let mut close = after_open;
while close < bytes.len() && depth > 0 {
if bytes[close] == b'{' {
depth += 1;
} else if bytes[close] == b'}' {
depth -= 1;
}
close += 1;
}
let body = css[after_open..close - 1].trim();
pos = close;
if body.is_empty() {
continue;
}
if selector == ".code" {
out.push_str(&format!("{}{} {{\n{}\n}}\n\n", prefix, base, body));
} else {
let rewritten = selector
.split(',')
.map(|s| format!("{}{} {}", prefix, base, s.trim()))
.collect::<Vec<_>>()
.join(",\n");
out.push_str(&format!("{} {{\n{}\n}}\n\n", rewritten, body));
}
}
out
}

68
src/highlight.rs Normal file
View File

@ -0,0 +1,68 @@
#[cfg(feature = "server")]
pub mod server {
use std::sync::{LazyLock, OnceLock};
use syntect::highlighting::{Theme, ThemeSet};
use syntect::html::{ClassedHTMLGenerator, ClassStyle};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use syntect::util::LinesWithEndings;
static SYNTAX_SET: LazyLock<SyntaxSet> =
LazyLock::new(|| SyntaxSet::load_defaults_newlines());
static LATTE_THEME: LazyLock<Theme> = LazyLock::new(|| {
ThemeSet::get_theme("themes/Catppuccin Latte.tmTheme")
.expect("Failed to load Catppuccin Latte theme")
});
static MOCHA_THEME: LazyLock<Theme> = LazyLock::new(|| {
ThemeSet::get_theme("themes/Catppuccin Mocha.tmTheme")
.expect("Failed to load Catppuccin Mocha theme")
});
static FALLBACK_SYNTAX: OnceLock<SyntaxReference> = OnceLock::new();
fn find_syntax(lang: Option<&str>) -> &'static SyntaxReference {
let ss = &*SYNTAX_SET;
if let Some(lang) = lang {
if !lang.is_empty() {
if let Some(s) = ss.find_syntax_by_extension(lang) {
return s;
}
if let Some(s) = ss.find_syntax_by_name(lang) {
return s;
}
}
}
let plain = ss
.find_syntax_by_extension("txt")
.or_else(|| ss.find_syntax_by_name("Plain Text"))
.expect("no plain text syntax");
FALLBACK_SYNTAX.get_or_init(|| plain.clone())
}
pub fn highlight_code(code: &str, lang: Option<&str>) -> String {
let syntax = find_syntax(lang);
let ss = &*SYNTAX_SET;
let mut generator =
ClassedHTMLGenerator::new_with_class_style(syntax, ss, ClassStyle::Spaced);
for line in LinesWithEndings::from(code) {
let _ = generator.parse_html_for_line_which_includes_newline(line);
}
generator.finalize()
}
pub fn get_latte_theme() -> &'static Theme {
&*LATTE_THEME
}
pub fn get_mocha_theme() -> &'static Theme {
&*MOCHA_THEME
}
pub fn get_syntax_set() -> &'static SyntaxSet {
&*SYNTAX_SET
}
}

View File

@ -3,6 +3,7 @@ mod auth;
mod components;
mod context;
mod db;
mod highlight;
mod hooks;
mod models;
mod pages;