Skip to main content

ssg/plugins/
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::error::{PathErrorExt, SsgError};
11use crate::plugin::{Plugin, PluginContext};
12use crate::util::head_dom::inject_before_head_close;
13use std::fs;
14
15mod theme;
16use theme::generate_highlight_css;
17
18/// Plugin that adds syntax highlighting CSS classes to code blocks.
19///
20/// Runs in `after_compile`. Finds `<pre><code class="language-X">`
21/// blocks, injects classes and links `highlight.css` from each page
22/// that has one. Also generates `highlight.css`.
23#[derive(Debug, Clone)]
24pub struct HighlightPlugin {
25    theme: String,
26}
27
28impl Default for HighlightPlugin {
29    fn default() -> Self {
30        Self {
31            theme: "github".to_string(),
32        }
33    }
34}
35
36impl HighlightPlugin {
37    /// Creates a new `HighlightPlugin` with the given theme.
38    ///
39    /// # Examples
40    ///
41    /// ```rust
42    /// use ssg::highlight::HighlightPlugin;
43    /// use ssg::plugin::Plugin;
44    ///
45    /// let p = HighlightPlugin::new("monokai");
46    /// assert_eq!(p.name(), "highlight");
47    /// ```
48    #[must_use]
49    pub fn new(theme: impl Into<String>) -> Self {
50        Self {
51            theme: theme.into(),
52        }
53    }
54
55    /// Creates a highlight plugin with the given theme name.
56    ///
57    /// # Examples
58    ///
59    /// ```rust
60    /// use ssg::highlight::HighlightPlugin;
61    /// use ssg::plugin::Plugin;
62    ///
63    /// let p = HighlightPlugin::with_theme("solarized");
64    /// assert_eq!(p.name(), "highlight");
65    /// ```
66    #[must_use]
67    pub fn with_theme(theme: impl Into<String>) -> Self {
68        Self {
69            theme: theme.into(),
70        }
71    }
72}
73
74impl Plugin for HighlightPlugin {
75    fn name(&self) -> &'static str {
76        "highlight"
77    }
78
79    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
80        if !ctx.site_dir.exists() {
81            return Ok(());
82        }
83
84        // Pages are rewritten here rather than in `transform_html`: every
85        // `after_compile` hook runs before any transform, so by then
86        // `FingerprintPlugin` had renamed `highlight.css` and the injected
87        // link named a file that was never written (#790). Linking it
88        // first lets that pass rename the reference and add SRI with the
89        // rest of the stylesheets.
90        for path in ctx.get_html_files() {
91            let html = fs::read_to_string(&path).with_path(&path)?;
92            if let Some(out) = highlight_page(&html) {
93                fs::write(&path, out).with_path(&path)?;
94            }
95        }
96
97        // Generate highlight.css
98        let css = generate_highlight_css(&self.theme);
99        let path = ctx.site_dir.join("highlight.css");
100        fs::write(&path, &css).with_path(&path)?;
101
102        Ok(())
103    }
104}
105
106/// Adds highlight markup to a page and links `highlight.css` from it.
107///
108/// Returns `None` when the page has no code block to highlight.
109fn highlight_page(html: &str) -> Option<String> {
110    let result = add_highlight_markup(html);
111    if result == html {
112        None
113    } else if result.contains("highlight.css") {
114        Some(result)
115    } else {
116        Some(inject_css_link(&result))
117    }
118}
119
120/// Adds highlight markup to code blocks.
121///
122/// Transforms `<pre><code class="language-X">` into
123/// `<pre class="highlight"><code class="language-X" data-lang="X">`.
124fn add_highlight_markup(html: &str) -> String {
125    const PRE: &str = "<pre>";
126    let mut result = String::with_capacity(html.len());
127    let mut pos = 0;
128
129    // A `<pre>` that is not a tagged code block is copied as is and the
130    // scan continues after it. Stopping there (#823) left every later
131    // block on the page unmarked, including when the `<pre>` was only
132    // text in a CSS comment in the layout's `<head>`.
133    while let Some(found) = html[pos..].find(PRE) {
134        let after_pre = pos + found + PRE.len();
135        result.push_str(&html[pos..pos + found]);
136        if let Some((markup, consumed)) = tagged_code_open(&html[after_pre..]) {
137            result.push_str(&markup);
138            pos = after_pre + consumed;
139        } else {
140            result.push_str(PRE);
141            pos = after_pre;
142        }
143    }
144    result.push_str(&html[pos..]);
145    result
146}
147
148/// For the text right after a `<pre>`, returns the highlighted
149/// `<pre …><code …>` opening and the byte length of the original
150/// `<code …>` tag, or `None` when it is not `<code class="language-X">`.
151fn tagged_code_open(rest: &str) -> Option<(String, usize)> {
152    let attr = rest.strip_prefix("<code class=\"language-")?;
153    let lang = &attr[..attr.find('"')?];
154    let consumed = rest.find('>')? + 1;
155    Some((
156        format!(
157            "<pre class=\"highlight language-{lang}\">\
158             <code class=\"language-{lang}\" data-lang=\"{lang}\">"
159        ),
160        consumed,
161    ))
162}
163
164/// Injects a `<link>` to highlight.css before `</head>`.
165///
166/// A page with no `<head>` gets the link appended instead, as the CSP
167/// pass does for its stylesheets, so highlighted code is never left
168/// without its stylesheet.
169fn inject_css_link(html: &str) -> String {
170    let link = "<link rel=\"stylesheet\" href=\"/highlight.css\">\n";
171    let injected = inject_before_head_close(html, link);
172    if injected == html {
173        format!("{html}{link}")
174    } else {
175        injected
176    }
177}
178
179#[cfg(test)]
180fn collect_html_files(
181    dir: &std::path::Path,
182) -> Result<Vec<std::path::PathBuf>, SsgError> {
183    crate::walk::walk_files(dir, "html")
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use tempfile::tempdir;
190
191    #[test]
192    fn test_add_highlight_markup() {
193        let html =
194            r#"<pre><code class="language-rust">fn main() {}</code></pre>"#;
195        let result = add_highlight_markup(html);
196        assert!(result.contains("class=\"highlight language-rust\""));
197        assert!(result.contains("data-lang=\"rust\""));
198    }
199
200    #[test]
201    fn add_highlight_markup_continues_past_an_untagged_pre() {
202        // #823: an untagged block, or `<pre>` in a CSS comment, used to
203        // end the scan and leave every later tagged block unmarked.
204        let html = concat!(
205            "<style>/* <pre> */</style>",
206            "<pre><code>plain</code></pre>",
207            r#"<pre><code class="language-python">print()</code></pre>"#,
208        );
209        let out = add_highlight_markup(html);
210        assert!(
211            out.contains(concat!(
212                r#"<pre class="highlight language-python">"#,
213                r#"<code class="language-python" data-lang="python">"#,
214            )),
215            "{out}"
216        );
217        assert!(
218            out.contains("/* <pre> */") && out.contains("<pre><code>plain")
219        );
220        let page = highlight_page(html).expect("page has a tagged block");
221        assert!(page.contains("highlight.css"), "{page}");
222    }
223
224    #[test]
225    fn add_highlight_markup_leaves_an_unterminated_class_alone() {
226        let html = r#"<pre><code class="language-rust"#;
227        assert_eq!(add_highlight_markup(html), html);
228    }
229
230    #[test]
231    fn test_no_code_block_unchanged() {
232        let html = "<pre>plain text</pre>";
233        let result = add_highlight_markup(html);
234        assert_eq!(result, html);
235    }
236
237    #[test]
238    fn test_inject_css_link() {
239        let html = "<html><head><title>X</title></head><body></body></html>";
240        let result = inject_css_link(html);
241        assert!(result.contains("highlight.css"));
242    }
243
244    // -------------------------------------------------------------------
245    // Plugin trait + constructor surface
246    // -------------------------------------------------------------------
247
248    #[test]
249    fn name_returns_static_highlight_identifier() {
250        assert_eq!(HighlightPlugin::default().name(), "highlight");
251    }
252
253    #[test]
254    fn default_constructor_uses_github_theme() {
255        let plugin = HighlightPlugin::default();
256        assert_eq!(plugin.theme, "github");
257    }
258
259    #[test]
260    fn new_constructor_stores_supplied_theme_name() {
261        // Covers the `new` constructor (previously exercised only by
262        // doctests, which don't count towards lib coverage).
263        let plugin = HighlightPlugin::new("monokai");
264        assert_eq!(plugin.theme, "monokai");
265        let plugin2 = HighlightPlugin::new(String::from("nord"));
266        assert_eq!(plugin2.theme, "nord");
267    }
268
269    #[test]
270    fn highlight_page_with_existing_css_link_skips_reinjection() {
271        // Covers the `result.contains("highlight.css")` true branch of
272        // highlight_page: the markup is rewritten but no second <link>
273        // is injected.
274        let html = r#"<html><head><link rel="stylesheet" href="/highlight.css"></head><body><pre><code class="language-rs">x</code></pre></body></html>"#;
275        let out = highlight_page(html).unwrap();
276        assert!(out.contains("highlight language-rs"));
277        assert_eq!(out.matches("/highlight.css").count(), 1);
278    }
279
280    #[test]
281    fn highlight_page_without_code_blocks_returns_none() {
282        let html = "<html><head></head><body><p>no code</p></body></html>";
283        assert!(highlight_page(html).is_none());
284    }
285
286    #[test]
287    fn with_theme_stores_supplied_theme_name() {
288        // Covers the `with_theme` constructor at lines 38-42.
289        let plugin = HighlightPlugin::with_theme("solarized");
290        assert_eq!(plugin.theme, "solarized");
291        let plugin2 = HighlightPlugin::with_theme(String::from("dracula"));
292        assert_eq!(plugin2.theme, "dracula");
293    }
294
295    #[test]
296    fn after_compile_missing_site_dir_returns_ok() {
297        // Line 52: `!ctx.site_dir.exists()` early return.
298        let dir = tempdir().unwrap();
299        let missing = dir.path().join("missing");
300        let ctx =
301            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
302        HighlightPlugin::default().after_compile(&ctx).unwrap();
303        assert!(!missing.join("highlight.css").exists());
304    }
305
306    #[test]
307    fn after_compile_html_without_code_blocks_is_unchanged() {
308        // Covers the `result != html` false branch at line 66 —
309        // file is not rewritten when add_highlight_markup returns
310        // its input unchanged.
311        let dir = tempdir().unwrap();
312        let site = dir.path().join("site");
313        fs::create_dir_all(&site).unwrap();
314        let html = "<html><head></head><body><p>no code</p></body></html>";
315        fs::write(site.join("plain.html"), html).unwrap();
316
317        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
318        HighlightPlugin::default().after_compile(&ctx).unwrap();
319        assert_eq!(fs::read_to_string(site.join("plain.html")).unwrap(), html);
320    }
321
322    #[test]
323    fn after_compile_preserves_existing_highlight_css_link() {
324        // Covers the `result.contains("highlight.css")` true branch
325        // at line 68 — when the link is already present the file is
326        // rewritten without re-injection.
327        let dir = tempdir().unwrap();
328        let site = dir.path().join("site");
329        fs::create_dir_all(&site).unwrap();
330        let html = r#"<html><head><link rel="stylesheet" href="/highlight.css"></head><body><pre><code class="language-rs">x</code></pre></body></html>"#;
331        fs::write(site.join("index.html"), html).unwrap();
332
333        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
334        HighlightPlugin::default().after_compile(&ctx).unwrap();
335        let out = fs::read_to_string(site.join("index.html")).unwrap();
336        // Exactly one stylesheet link — no double-injection.
337        assert_eq!(out.matches("/highlight.css").count(), 1);
338    }
339
340    #[test]
341    fn inject_css_link_without_head_appends_the_link() {
342        // A page with no `<head>` still gets its stylesheet (#790).
343        let html = "<body>no head</body>";
344        let result = inject_css_link(html);
345        assert_eq!(
346            result,
347            "<body>no head</body><link rel=\"stylesheet\" href=\"/highlight.css\">\n"
348        );
349    }
350
351    #[test]
352    fn collect_html_files_recurses_and_sorts() {
353        let dir = tempdir().unwrap();
354        let sub = dir.path().join("sub");
355        fs::create_dir_all(&sub).unwrap();
356        fs::write(dir.path().join("z.html"), "").unwrap();
357        fs::write(dir.path().join("a.html"), "").unwrap();
358        fs::write(sub.join("m.html"), "").unwrap();
359
360        let files = collect_html_files(dir.path()).unwrap();
361        assert_eq!(files.len(), 3);
362        let first = files[0].file_name().unwrap().to_str().unwrap();
363        assert_eq!(first, "a.html");
364    }
365
366    #[test]
367    fn collect_html_files_returns_empty_for_missing_directory() {
368        let dir = tempdir().unwrap();
369        let result = collect_html_files(&dir.path().join("missing")).unwrap();
370        assert!(result.is_empty(), "{:?}", result);
371    }
372
373    #[test]
374    fn test_plugin_generates_css() {
375        let dir = tempdir().unwrap();
376        let site = dir.path().join("site");
377        fs::create_dir_all(&site).unwrap();
378
379        let html = r#"<html><head><title>X</title></head><body><pre><code class="language-js">let x = 1;</code></pre></body></html>"#;
380
381        fs::write(site.join("index.html"), html).unwrap();
382
383        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
384        HighlightPlugin::default().after_compile(&ctx).unwrap();
385
386        assert!(site.join("highlight.css").exists());
387        let output = fs::read_to_string(site.join("index.html")).unwrap();
388        assert!(output.contains("highlight.css"));
389        assert!(output.contains("highlight language-js"));
390    }
391
392    #[test]
393    fn after_compile_write_failure_returns_io_error() {
394        let dir = tempdir().unwrap();
395        let site = dir.path().join("site");
396        fs::create_dir_all(&site).unwrap();
397
398        // Create a directory where highlight.css is expected to be written, causing fs::write to fail.
399        let css_dir = site.join("highlight.css");
400        fs::create_dir(&css_dir).unwrap();
401
402        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
403        let res = HighlightPlugin::default().after_compile(&ctx);
404        assert!(res.is_err());
405        let err = res.unwrap_err();
406        assert!(
407            matches!(err, SsgError::Io { ref path, .. } if path == &css_dir)
408        );
409    }
410}