From 1188208dac0438d775a493b6e576aa3989cd8d2c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 03:39:18 +0000 Subject: [PATCH 1/3] Add KaTeX rendering for LaTeX math expressions in markdown Implements server-side LaTeX rendering using katex-rs when processing markdown content. Supports both inline math ($...$) and display math ($$...$$) notation. Features: - Server-side rendering converts LaTeX to HTML at build time - Auto-injects KaTeX CSS from CDN when math is detected - Prints informative message during build - Fails build on invalid LaTeX syntax with clear error messages - Environment variable SIMPLE_DISABLE_KATEX_CSS to disable auto-injection Implementation: - Added katex dependency for LaTeX rendering - Post-processes comrak HTML output to find and render math spans - Injects KaTeX CSS link into page when math is used - Clean separation of concerns with katex_assets module --- Cargo.lock | 133 ++++++++++++++++++++++++++++++++--- Cargo.toml | 1 + src/handlers/katex_assets.rs | 38 ++++++++++ src/handlers/markdown.rs | 69 ++++++++++++++++++ src/handlers/pages.rs | 18 +++++ src/main.rs | 1 + 6 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 src/handlers/katex_assets.rs diff --git a/Cargo.lock b/Cargo.lock index 5ae9180..e84ebd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,7 +379,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", "terminal_size", ] @@ -436,7 +436,7 @@ checksum = "395ab67843c57df5a4ee29d610740828dbc928cc64ecf0f2a1d5cd0e98e107a9" dependencies = [ "caseless", "clap", - "derive_builder", + "derive_builder 0.20.0", "entities", "memchr", "once_cell", @@ -478,6 +478,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "copy_dir" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "543d1dd138ef086e2ff05e3a48cf9da045da2033d16f8538fd76b86cd49b2ca3" +dependencies = [ + "walkdir", +] + [[package]] name = "core-foundation-sys" version = "0.8.6" @@ -578,14 +587,38 @@ dependencies = [ "syn 2.0.76", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] @@ -598,17 +631,28 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.76", ] +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", "quote", "syn 2.0.76", ] @@ -660,13 +704,34 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro 0.12.0", +] + [[package]] name = "derive_builder" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7" dependencies = [ - "derive_builder_macro", + "derive_builder_macro 0.20.0", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -675,19 +740,29 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.76", ] +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core 0.12.0", + "syn 1.0.109", +] + [[package]] name = "derive_builder_macro" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" dependencies = [ - "derive_builder_core", + "derive_builder_core 0.20.0", "syn 2.0.76", ] @@ -1101,6 +1176,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "katex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bdbc7a1823f188f56ac9486993536b70a2686a58d47095dcc10507a7d242bf5" +dependencies = [ + "cfg-if", + "derive_builder 0.12.0", + "itertools 0.10.5", + "quick-js", + "thiserror", +] + [[package]] name = "kqueue" version = "1.0.8" @@ -1133,6 +1221,16 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "libquickjs-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f0b24e9bd171b75ae0295bd428fb8fe58410fb23156e5f34a4657a70c3cee96" +dependencies = [ + "cc", + "copy_dir", +] + [[package]] name = "lightningcss" version = "1.0.0-alpha.58" @@ -1708,6 +1806,16 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-js" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19cb4cefcb00f4ab9b332664d06005a74f582ac16aa959c6ad5912957bd83e5f" +dependencies = [ + "libquickjs-sys", + "once_cell", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -2034,6 +2142,7 @@ dependencies = [ "color-print", "comrak", "fancy-regex 0.13.0", + "katex", "minify-html", "notify", "num_cpus", @@ -2105,6 +2214,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index a13a031..ced8a04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ authors = ["Tnixc "] color-print = "0.3.6" comrak = "0.26.0" fancy-regex = "0.13.0" +katex = "0.4" minify-html = "0.15.0" notify = "6.1.1" num_cpus = "1.16.0" diff --git a/src/handlers/katex_assets.rs b/src/handlers/katex_assets.rs new file mode 100644 index 0000000..dedc487 --- /dev/null +++ b/src/handlers/katex_assets.rs @@ -0,0 +1,38 @@ +use once_cell::sync::Lazy; +use std::env; +use std::sync::atomic::{AtomicBool, Ordering}; + +// Track if KaTeX was used in the current page (thread-local) +thread_local! { + static KATEX_USED: AtomicBool = const { AtomicBool::new(false) }; +} + +// Track if we've printed the KaTeX message (global, one-time) +static MESSAGE_PRINTED: Lazy = Lazy::new(|| AtomicBool::new(false)); + +pub fn mark_katex_used() { + KATEX_USED.with(|used| used.store(true, Ordering::Relaxed)); +} + +pub fn was_katex_used() -> bool { + KATEX_USED.with(|used| used.load(Ordering::Relaxed)) +} + +pub fn reset_katex_flag() { + KATEX_USED.with(|used| used.store(false, Ordering::Relaxed)); +} + +pub fn print_katex_message() { + if !MESSAGE_PRINTED.swap(true, Ordering::Relaxed) { + println!(" 📐 KaTeX CSS will be injected (using CDN)"); + } +} + +pub fn is_katex_injection_disabled() -> bool { + env::var("SIMPLE_DISABLE_KATEX_CSS").is_ok() +} + +pub fn get_katex_css_tag() -> &'static str { + r#" +"# +} diff --git a/src/handlers/markdown.rs b/src/handlers/markdown.rs index 79f368d..51d8443 100644 --- a/src/handlers/markdown.rs +++ b/src/handlers/markdown.rs @@ -2,8 +2,10 @@ use comrak::markdown_to_html_with_plugins; use comrak::plugins::syntect::SyntectAdapterBuilder; use comrak::{Options, Plugins}; use fancy_regex::Regex; +use katex::{Opts, OutputType}; use once_cell::sync::Lazy; +use crate::handlers::katex_assets; use crate::utils; use crate::IS_DEV; @@ -12,6 +14,11 @@ static MARKDOWN_REGEX: Lazy = Lazy::new(|| { .expect("Regex failed to parse. This shouldn't happen.") }); +static MATH_SPAN_REGEX: Lazy = Lazy::new(|| { + Regex::new(r#"([\s\S]+?)"#) + .expect("Math span regex failed to parse. This shouldn't happen.") +}); + static SYNTAX_HIGHLIGHTER: Lazy = Lazy::new(|| SyntectAdapterBuilder::new().css().build()); @@ -29,6 +36,59 @@ fn create_markdown_options() -> Options<'static> { options } +fn render_katex(html: &str) -> Result { + let mut result = String::with_capacity(html.len() + (html.len() >> 1)); + let mut last_end = 0; + let mut has_math = false; + + for captures in MATH_SPAN_REGEX.captures_iter(html) { + if let Ok(cap) = captures { + let mat = cap.get(0).unwrap(); + let start = mat.start(); + let end = mat.end(); + + // Add everything before this match + result.push_str(&html[last_end..start]); + + // Extract math style and content + let style = cap.get(1).unwrap().as_str(); + let latex = cap.get(2).unwrap().as_str(); + + // Configure KaTeX options + let opts = Opts::builder() + .output_type(OutputType::Html) + .display_mode(style == "display") + .build() + .map_err(|e| format!("Failed to build KaTeX options: {:?}", e))?; + + // Render with KaTeX + match katex::render_with_opts(latex, &opts) { + Ok(rendered) => { + result.push_str(&rendered); + has_math = true; + } + Err(e) => { + return Err(format!( + "Failed to render LaTeX expression '{}': {}", + latex, e + )); + } + } + + last_end = end; + } + } + + result.push_str(&html[last_end..]); + + // Mark that KaTeX was used if we rendered any math + if has_math { + katex_assets::mark_katex_used(); + } + + Ok(result) +} + pub fn render_markdown(input: String) -> String { let mut plugins = Plugins::default(); plugins.render.codefence_syntax_highlighter = Some(&*SYNTAX_HIGHLIGHTER); @@ -49,6 +109,15 @@ pub fn render_markdown(input: String) -> String { let unindented = utils::unindent(markdown_content); let rendered = markdown_to_html_with_plugins(&unindented, &options, &plugins); + // Render KaTeX math expressions + let rendered = match render_katex(&rendered) { + Ok(html) => html, + Err(e) => { + eprintln!("KaTeX rendering error: {}", e); + std::process::exit(1); + } + }; + if is_dev { result.push_str(r#"
Result<(), Vec> { let mut errors: Vec = Vec::new(); + // Reset KaTeX usage flag for this page + katex_assets::reset_katex_flag(); + let file_content = fs::read_to_string(&path) .map_proc_err(WithItem::File, ErrorType::Io, &path, None) .inspect_err(|e| errors.push((*e).clone())) @@ -188,6 +192,20 @@ fn process_single_file( let mut output = result.output; + // Inject KaTeX CSS if math was rendered (unless disabled) + if katex_assets::was_katex_used() && !katex_assets::is_katex_injection_disabled() { + // Print message once + katex_assets::print_katex_message(); + + // Inject CSS link in + if output.contains("") { + output = output.replace("", &format!("\n{}", katex_assets::get_katex_css_tag())); + } else { + // If no tag, prepend to document + output = format!("{}\n{}", katex_assets::get_katex_css_tag(), output); + } + } + if dev { let ws_port = *WS_PORT.get().unwrap(); if !output.contains("// * SCRIPT INCLUDED IN DEV MODE") { diff --git a/src/main.rs b/src/main.rs index 5c74dab..3bc08b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod handlers { pub mod components; pub mod entries; + pub mod katex_assets; pub mod markdown; pub mod pages; pub mod templates; From 985c8e1c707420b09937d3bcea10b1962755adc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 03:57:24 +0000 Subject: [PATCH 2/3] Fix KaTeX rendering for .md files and rename binary to 'simple' - Add KaTeX CSS injection to entries.rs for .md file processing - Reset KaTeX flag at start of process_entry - Inject KaTeX CSS when math is detected in entries - Rename binary from "replacer" to "simple" in Cargo.toml This ensures KaTeX works for both: - HTML files with blocks (pages.rs) - .md files processed through templates (entries.rs) --- Cargo.toml | 2 +- src/handlers/entries.rs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ced8a04..42698f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,6 @@ serde_json = "1.0.120" simple-websockets = "0.1.6" [[bin]] -name = "replacer" +name = "simple" path = "src/main.rs" diff --git a/src/handlers/entries.rs b/src/handlers/entries.rs index 5457b06..824247f 100644 --- a/src/handlers/entries.rs +++ b/src/handlers/entries.rs @@ -1,5 +1,6 @@ use crate::dev::{SCRIPT, WS_PORT}; use crate::error::{ErrorType, ProcessError, WithItem}; +use crate::handlers::katex_assets; use crate::handlers::pages::page; use crate::utils::kv_replace; use crate::IS_DEV; @@ -16,6 +17,9 @@ pub fn process_entry( let mut errors: Vec = Vec::new(); let is_dev = *IS_DEV.get().unwrap(); + // Reset KaTeX usage flag for this page + katex_assets::reset_katex_flag(); + if entry_path.is_empty() || result_path.is_empty() { return vec![ProcessError { error_type: ErrorType::Other, @@ -91,6 +95,20 @@ pub fn process_entry( let mut s = page_result.output; + // Inject KaTeX CSS if math was rendered (unless disabled) + if katex_assets::was_katex_used() && !katex_assets::is_katex_injection_disabled() { + // Print message once + katex_assets::print_katex_message(); + + // Inject CSS link in + if s.contains("") { + s = s.replace("", &format!("\n{}", katex_assets::get_katex_css_tag())); + } else { + // If no tag, prepend to document + s = format!("{}\n{}", katex_assets::get_katex_css_tag(), s); + } + } + if is_dev && !s.contains("// * SCRIPT INCLUDED IN DEV MODE") { s = s.replace("", &format!("{}", SCRIPT)); s = s.replace( From 4b818970dcb6160ed0f283a00438b16ccb48efa0 Mon Sep 17 00:00:00 2001 From: Tnixc Date: Tue, 4 Nov 2025 23:01:27 -0500 Subject: [PATCH 3/3] update README.md --- README.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0d63f3b..7e2842a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Simple Build tool -> [!WARNING] -> Probably very buggy, it's very opiniated and not extensible. +> [!WARNING] +> Probably very buggy, it's very opinionated and not extensible. A simple build tool that assembles a static site from components, templates, and data. I used it to build v6 of https://enochlau.com @@ -25,10 +25,6 @@ data. I used it to build v6 of https://enochlau.com - [ ] Improve JSON parsing error handling -### Markdown and Content - -- [ ] LaTex? - ### Errors/Logging - [ ] Exact file and line in error messages @@ -44,6 +40,7 @@ data. I used it to build v6 of https://enochlau.com ### Done +- [x] KaTeX support - [x] Handle port collisions in dev server - [x] Resolve dual sources of truth for Markdown frontmatter in blog posts (can't fix without proper Markdown parsing into entries) - [x] Bi-directional editing: You can now double click on a rendered `` element to edit it, and it's reflected in the source code. @@ -189,7 +186,7 @@ Will render out to: alt ``` -> [!NOTE] +> [!NOTE] > You can double click on a rendered markdown element and edit it from the web. The changes will be reflected in the source file. It is a bit flakely with escaped html entities, so try to avoid using those. ### Template entries