Skip to main content

ssg/
highlight.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Syntax highlighting plugin.
5//!
6//! Post-processes compiled HTML to add syntax highlighting to code
7//! blocks. Uses class-based highlighting with a generated CSS file,
8//! avoiding inline styles for better performance and cacheability.
9
10use crate::plugin::{Plugin, PluginContext};
11use anyhow::Result;
12use std::fs;
13use std::path::Path;
14
15/// Plugin that adds syntax highlighting CSS classes to code blocks.
16///
17/// Runs in `after_compile`. Finds `<pre><code class="language-X">`
18/// blocks and wraps them with a highlight container. Generates a
19/// `highlight.css` file with the color theme.
20#[derive(Debug)]
21pub struct HighlightPlugin {
22    /// CSS theme name. Default themes are generated inline.
23    theme: String,
24}
25
26impl Default for HighlightPlugin {
27    fn default() -> Self {
28        Self {
29            theme: "github".to_string(),
30        }
31    }
32}
33
34impl HighlightPlugin {
35    /// Creates a highlight plugin with the given theme name.
36    pub fn with_theme(theme: impl Into<String>) -> Self {
37        Self {
38            theme: theme.into(),
39        }
40    }
41}
42
43impl Plugin for HighlightPlugin {
44    fn name(&self) -> &'static str {
45        "highlight"
46    }
47
48    fn has_transform(&self) -> bool {
49        true
50    }
51
52    fn transform_html(
53        &self,
54        html: &str,
55        _path: &Path,
56        _ctx: &PluginContext,
57    ) -> Result<String> {
58        let result = add_highlight_markup(html);
59        if result == html {
60            return Ok(html.to_string());
61        }
62        if result.contains("highlight.css") {
63            Ok(result)
64        } else {
65            Ok(inject_css_link(&result))
66        }
67    }
68
69    fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
70        if !ctx.site_dir.exists() {
71            return Ok(());
72        }
73
74        // Generate highlight.css
75        let css = generate_highlight_css(&self.theme);
76        fs::write(ctx.site_dir.join("highlight.css"), &css)?;
77
78        Ok(())
79    }
80}
81
82/// Adds highlight markup to code blocks.
83///
84/// Transforms `<pre><code class="language-X">` into
85/// `<pre class="highlight"><code class="language-X" data-lang="X">`.
86fn add_highlight_markup(html: &str) -> String {
87    let mut result = String::with_capacity(html.len());
88    let mut pos = 0;
89
90    while pos < html.len() {
91        if let Some(pre_start) = html[pos..].find("<pre>") {
92            let abs_pre = pos + pre_start;
93            let after_pre = abs_pre + 5; // len("<pre>")
94
95            // Check if next element is <code class="language-
96            let remaining = &html[after_pre..];
97            if remaining.starts_with("<code class=\"language-") {
98                // Extract language name
99                let lang_start = "language-".len();
100                let code_attr = &remaining["<code class=\"".len()..];
101                let lang_end = code_attr.find('"').unwrap_or(0);
102                let lang = &code_attr[lang_start..lang_end];
103
104                // Write the enhanced pre tag
105                result.push_str(&html[pos..abs_pre]);
106                result.push_str(&format!(
107                    "<pre class=\"highlight language-{lang}\">"
108                ));
109                result.push_str(&format!(
110                    "<code class=\"language-{lang}\" data-lang=\"{lang}\">"
111                ));
112
113                // Skip past the original <pre><code class="language-X">
114                let code_tag_end = remaining.find('>').unwrap_or(0);
115                pos = after_pre + code_tag_end + 1;
116                continue;
117            }
118        }
119
120        // No match — copy rest and break
121        result.push_str(&html[pos..]);
122        break;
123    }
124
125    result
126}
127
128/// Injects a `<link>` to highlight.css before `</head>`.
129fn inject_css_link(html: &str) -> String {
130    if let Some(pos) = html.find("</head>") {
131        format!(
132            "{}<link rel=\"stylesheet\" href=\"/highlight.css\">\n{}",
133            &html[..pos],
134            &html[pos..]
135        )
136    } else {
137        html.to_string()
138    }
139}
140
141/// Generates a CSS theme for syntax highlighting.
142fn generate_highlight_css(_theme: &str) -> String {
143    r#"/* Syntax highlighting — GitHub-inspired theme */
144pre.highlight {
145  background: #f6f8fa;
146  border: 1px solid #d0d7de;
147  border-radius: 6px;
148  padding: 1em;
149  overflow-x: auto;
150  font-size: 0.875em;
151  line-height: 1.45;
152}
153pre.highlight code {
154  background: none;
155  padding: 0;
156  border: none;
157  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
158}
159@media (prefers-color-scheme: dark) {
160  pre.highlight {
161    background: #161b22;
162    border-color: #30363d;
163    color: #e6edf3;
164  }
165}
166"#
167    .to_string()
168}
169
170#[cfg(test)]
171fn collect_html_files(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
172    crate::walk::walk_files(dir, "html")
173}
174
175#[cfg(test)]
176#[allow(clippy::unwrap_used, clippy::expect_used)]
177mod tests {
178    use super::*;
179    use tempfile::tempdir;
180
181    #[test]
182    fn test_add_highlight_markup() {
183        let html =
184            r#"<pre><code class="language-rust">fn main() {}</code></pre>"#;
185        let result = add_highlight_markup(html);
186        assert!(result.contains("class=\"highlight language-rust\""));
187        assert!(result.contains("data-lang=\"rust\""));
188    }
189
190    #[test]
191    fn test_no_code_block_unchanged() {
192        let html = "<pre>plain text</pre>";
193        let result = add_highlight_markup(html);
194        assert_eq!(result, html);
195    }
196
197    #[test]
198    fn test_inject_css_link() {
199        let html = "<html><head><title>X</title></head><body></body></html>";
200        let result = inject_css_link(html);
201        assert!(result.contains("highlight.css"));
202    }
203
204    #[test]
205    fn test_generate_css() {
206        let css = generate_highlight_css("github");
207        assert!(css.contains("pre.highlight"));
208        assert!(css.contains("prefers-color-scheme: dark"));
209    }
210
211    // -------------------------------------------------------------------
212    // Plugin trait + constructor surface
213    // -------------------------------------------------------------------
214
215    #[test]
216    fn name_returns_static_highlight_identifier() {
217        assert_eq!(HighlightPlugin::default().name(), "highlight");
218    }
219
220    #[test]
221    fn default_constructor_uses_github_theme() {
222        let plugin = HighlightPlugin::default();
223        assert_eq!(plugin.theme, "github");
224    }
225
226    #[test]
227    fn with_theme_stores_supplied_theme_name() {
228        // Covers the `with_theme` constructor at lines 38-42.
229        let plugin = HighlightPlugin::with_theme("solarized");
230        assert_eq!(plugin.theme, "solarized");
231        let plugin2 = HighlightPlugin::with_theme(String::from("dracula"));
232        assert_eq!(plugin2.theme, "dracula");
233    }
234
235    #[test]
236    fn after_compile_missing_site_dir_returns_ok() {
237        // Line 52: `!ctx.site_dir.exists()` early return.
238        let dir = tempdir().unwrap();
239        let missing = dir.path().join("missing");
240        let ctx =
241            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
242        HighlightPlugin::default().after_compile(&ctx).unwrap();
243        assert!(!missing.join("highlight.css").exists());
244    }
245
246    #[test]
247    fn after_compile_html_without_code_blocks_is_unchanged() {
248        // Covers the `result != html` false branch at line 66 —
249        // file is not rewritten when add_highlight_markup returns
250        // its input unchanged.
251        let dir = tempdir().unwrap();
252        let site = dir.path().join("site");
253        fs::create_dir_all(&site).unwrap();
254        let html = "<html><head></head><body><p>no code</p></body></html>";
255        fs::write(site.join("plain.html"), html).unwrap();
256
257        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
258        HighlightPlugin::default().after_compile(&ctx).unwrap();
259        assert_eq!(fs::read_to_string(site.join("plain.html")).unwrap(), html);
260    }
261
262    #[test]
263    fn after_compile_preserves_existing_highlight_css_link() {
264        // Covers the `result.contains("highlight.css")` true branch
265        // at line 68 — when the link is already present the file is
266        // rewritten without re-injection.
267        let dir = tempdir().unwrap();
268        let site = dir.path().join("site");
269        fs::create_dir_all(&site).unwrap();
270        let html = r#"<html><head><link rel="stylesheet" href="/highlight.css"></head><body><pre><code class="language-rs">x</code></pre></body></html>"#;
271        fs::write(site.join("index.html"), html).unwrap();
272
273        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
274        HighlightPlugin::default().after_compile(&ctx).unwrap();
275        let out = fs::read_to_string(site.join("index.html")).unwrap();
276        // Exactly one stylesheet link — no double-injection.
277        assert_eq!(out.matches("/highlight.css").count(), 1);
278    }
279
280    #[test]
281    fn inject_css_link_without_head_returns_input_unchanged() {
282        // Line 145: the `else` branch of the `</head>` search.
283        let html = "<body>no head</body>";
284        let result = inject_css_link(html);
285        assert_eq!(result, html);
286    }
287
288    #[test]
289    fn collect_html_files_recurses_and_sorts() {
290        let dir = tempdir().unwrap();
291        let sub = dir.path().join("sub");
292        fs::create_dir_all(&sub).unwrap();
293        fs::write(dir.path().join("z.html"), "").unwrap();
294        fs::write(dir.path().join("a.html"), "").unwrap();
295        fs::write(sub.join("m.html"), "").unwrap();
296
297        let files = collect_html_files(dir.path()).unwrap();
298        assert_eq!(files.len(), 3);
299        let first = files[0].file_name().unwrap().to_str().unwrap();
300        assert_eq!(first, "a.html");
301    }
302
303    #[test]
304    fn collect_html_files_returns_empty_for_missing_directory() {
305        let dir = tempdir().unwrap();
306        let result = collect_html_files(&dir.path().join("missing")).unwrap();
307        assert!(result.is_empty());
308    }
309
310    #[test]
311    fn test_plugin_generates_css() {
312        let dir = tempdir().unwrap();
313        let site = dir.path().join("site");
314        fs::create_dir_all(&site).unwrap();
315
316        let html = r#"<html><head><title>X</title></head><body><pre><code class="language-js">let x = 1;</code></pre></body></html>"#;
317
318        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
319        HighlightPlugin::default().after_compile(&ctx).unwrap();
320
321        assert!(site.join("highlight.css").exists());
322        let output = HighlightPlugin::default()
323            .transform_html(html, &site.join("index.html"), &ctx)
324            .unwrap();
325        assert!(output.contains("highlight.css"));
326        assert!(output.contains("highlight language-js"));
327    }
328}