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;
14use std::path::Path;
15
16/// Plugin that adds syntax highlighting CSS classes to code blocks.
17///
18/// Runs in `after_compile`. Finds `<pre><code class="language-X">`
19/// blocks and injects classes. Also generates `highlight.css`.
20#[derive(Debug, Clone)]
21pub struct HighlightPlugin {
22    theme: String,
23}
24
25impl Default for HighlightPlugin {
26    fn default() -> Self {
27        Self {
28            theme: "github".to_string(),
29        }
30    }
31}
32
33impl HighlightPlugin {
34    /// Creates a new `HighlightPlugin` with the given theme.
35    ///
36    /// # Examples
37    ///
38    /// ```rust
39    /// use ssg::highlight::HighlightPlugin;
40    /// use ssg::plugin::Plugin;
41    ///
42    /// let p = HighlightPlugin::new("monokai");
43    /// assert_eq!(p.name(), "highlight");
44    /// ```
45    #[must_use]
46    pub fn new(theme: impl Into<String>) -> Self {
47        Self {
48            theme: theme.into(),
49        }
50    }
51
52    /// Creates a highlight plugin with the given theme name.
53    ///
54    /// # Examples
55    ///
56    /// ```rust
57    /// use ssg::highlight::HighlightPlugin;
58    /// use ssg::plugin::Plugin;
59    ///
60    /// let p = HighlightPlugin::with_theme("solarized");
61    /// assert_eq!(p.name(), "highlight");
62    /// ```
63    #[must_use]
64    pub fn with_theme(theme: impl Into<String>) -> Self {
65        Self {
66            theme: theme.into(),
67        }
68    }
69}
70
71impl Plugin for HighlightPlugin {
72    fn name(&self) -> &'static str {
73        "highlight"
74    }
75
76    fn has_transform(&self) -> bool {
77        true
78    }
79
80    fn transform_html(
81        &self,
82        html: &str,
83        _path: &Path,
84        _ctx: &PluginContext,
85    ) -> Result<String, SsgError> {
86        let result = add_highlight_markup(html);
87        if result == html {
88            return Ok(html.to_string());
89        }
90        if result.contains("highlight.css") {
91            Ok(result)
92        } else {
93            Ok(inject_css_link(&result))
94        }
95    }
96
97    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
98        if !ctx.site_dir.exists() {
99            return Ok(());
100        }
101
102        // Generate highlight.css
103        let css = generate_highlight_css(&self.theme);
104        let path = ctx.site_dir.join("highlight.css");
105        fs::write(&path, &css).with_path(&path)?;
106
107        Ok(())
108    }
109}
110
111/// Adds highlight markup to code blocks.
112///
113/// Transforms `<pre><code class="language-X">` into
114/// `<pre class="highlight"><code class="language-X" data-lang="X">`.
115fn add_highlight_markup(html: &str) -> String {
116    let mut result = String::with_capacity(html.len());
117    let mut pos = 0;
118
119    while pos < html.len() {
120        if let Some(pre_start) = html[pos..].find("<pre>") {
121            let abs_pre = pos + pre_start;
122            let after_pre = abs_pre + 5; // len("<pre>")
123
124            // Check if next element is <code class="language-
125            let remaining = &html[after_pre..];
126            if remaining.starts_with("<code class=\"language-") {
127                // Extract language name
128                let lang_start = "language-".len();
129                let code_attr = &remaining["<code class=\"".len()..];
130                let lang_end = code_attr.find('"').unwrap_or(0);
131                let lang = &code_attr[lang_start..lang_end];
132
133                // Write the enhanced pre tag
134                result.push_str(&html[pos..abs_pre]);
135                result.push_str(&format!(
136                    "<pre class=\"highlight language-{lang}\">"
137                ));
138                result.push_str(&format!(
139                    "<code class=\"language-{lang}\" data-lang=\"{lang}\">"
140                ));
141
142                // Skip past the original <pre><code class="language-X">
143                let code_tag_end = remaining.find('>').unwrap_or(0);
144                pos = after_pre + code_tag_end + 1;
145                continue;
146            }
147        }
148
149        // No match — copy rest and break
150        result.push_str(&html[pos..]);
151        break;
152    }
153
154    result
155}
156
157/// Injects a `<link>` to highlight.css before `</head>`.
158fn inject_css_link(html: &str) -> String {
159    inject_before_head_close(
160        html,
161        "<link rel=\"stylesheet\" href=\"/highlight.css\">\n",
162    )
163}
164
165/// Generates a CSS theme for syntax highlighting.
166fn generate_highlight_css(_theme: &str) -> String {
167    r#"/* Syntax highlighting — GitHub-inspired theme */
168pre.highlight {
169  background: #f6f8fa;
170  border: 1px solid #d0d7de;
171  border-radius: 6px;
172  padding: 1em;
173  overflow-x: auto;
174  font-size: 0.875em;
175  line-height: 1.45;
176}
177pre.highlight code {
178  background: none;
179  padding: 0;
180  border: none;
181  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
182}
183@media (prefers-color-scheme: dark) {
184  pre.highlight {
185    background: #161b22;
186    border-color: #30363d;
187    color: #e6edf3;
188  }
189}
190"#
191    .to_string()
192}
193
194#[cfg(test)]
195fn collect_html_files(dir: &Path) -> Result<Vec<std::path::PathBuf>, SsgError> {
196    crate::walk::walk_files(dir, "html")
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use tempfile::tempdir;
203
204    #[test]
205    fn test_add_highlight_markup() {
206        let html =
207            r#"<pre><code class="language-rust">fn main() {}</code></pre>"#;
208        let result = add_highlight_markup(html);
209        assert!(result.contains("class=\"highlight language-rust\""));
210        assert!(result.contains("data-lang=\"rust\""));
211    }
212
213    #[test]
214    fn test_no_code_block_unchanged() {
215        let html = "<pre>plain text</pre>";
216        let result = add_highlight_markup(html);
217        assert_eq!(result, html);
218    }
219
220    #[test]
221    fn test_inject_css_link() {
222        let html = "<html><head><title>X</title></head><body></body></html>";
223        let result = inject_css_link(html);
224        assert!(result.contains("highlight.css"));
225    }
226
227    #[test]
228    fn test_generate_css() {
229        let css = generate_highlight_css("github");
230        assert!(css.contains("pre.highlight"));
231        assert!(css.contains("prefers-color-scheme: dark"));
232    }
233
234    // -------------------------------------------------------------------
235    // Plugin trait + constructor surface
236    // -------------------------------------------------------------------
237
238    #[test]
239    fn name_returns_static_highlight_identifier() {
240        assert_eq!(HighlightPlugin::default().name(), "highlight");
241    }
242
243    #[test]
244    fn default_constructor_uses_github_theme() {
245        let plugin = HighlightPlugin::default();
246        assert_eq!(plugin.theme, "github");
247    }
248
249    #[test]
250    fn new_constructor_stores_supplied_theme_name() {
251        // Covers the `new` constructor (previously exercised only by
252        // doctests, which don't count towards lib coverage).
253        let plugin = HighlightPlugin::new("monokai");
254        assert_eq!(plugin.theme, "monokai");
255        let plugin2 = HighlightPlugin::new(String::from("nord"));
256        assert_eq!(plugin2.theme, "nord");
257    }
258
259    #[test]
260    fn transform_html_with_existing_css_link_skips_reinjection() {
261        // Covers the `result.contains("highlight.css")` true branch of
262        // transform_html: the markup is rewritten but no second <link>
263        // is injected.
264        let dir = tempdir().unwrap();
265        let ctx =
266            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
267        let html = r#"<html><head><link rel="stylesheet" href="/highlight.css"></head><body><pre><code class="language-rs">x</code></pre></body></html>"#;
268        let out = HighlightPlugin::default()
269            .transform_html(html, &dir.path().join("index.html"), &ctx)
270            .unwrap();
271        assert!(out.contains("highlight language-rs"));
272        assert_eq!(out.matches("/highlight.css").count(), 1);
273    }
274
275    #[test]
276    fn with_theme_stores_supplied_theme_name() {
277        // Covers the `with_theme` constructor at lines 38-42.
278        let plugin = HighlightPlugin::with_theme("solarized");
279        assert_eq!(plugin.theme, "solarized");
280        let plugin2 = HighlightPlugin::with_theme(String::from("dracula"));
281        assert_eq!(plugin2.theme, "dracula");
282    }
283
284    #[test]
285    fn after_compile_missing_site_dir_returns_ok() {
286        // Line 52: `!ctx.site_dir.exists()` early return.
287        let dir = tempdir().unwrap();
288        let missing = dir.path().join("missing");
289        let ctx =
290            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
291        HighlightPlugin::default().after_compile(&ctx).unwrap();
292        assert!(!missing.join("highlight.css").exists());
293    }
294
295    #[test]
296    fn after_compile_html_without_code_blocks_is_unchanged() {
297        // Covers the `result != html` false branch at line 66 —
298        // file is not rewritten when add_highlight_markup returns
299        // its input unchanged.
300        let dir = tempdir().unwrap();
301        let site = dir.path().join("site");
302        fs::create_dir_all(&site).unwrap();
303        let html = "<html><head></head><body><p>no code</p></body></html>";
304        fs::write(site.join("plain.html"), html).unwrap();
305
306        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
307        HighlightPlugin::default().after_compile(&ctx).unwrap();
308        assert_eq!(fs::read_to_string(site.join("plain.html")).unwrap(), html);
309    }
310
311    #[test]
312    fn after_compile_preserves_existing_highlight_css_link() {
313        // Covers the `result.contains("highlight.css")` true branch
314        // at line 68 — when the link is already present the file is
315        // rewritten without re-injection.
316        let dir = tempdir().unwrap();
317        let site = dir.path().join("site");
318        fs::create_dir_all(&site).unwrap();
319        let html = r#"<html><head><link rel="stylesheet" href="/highlight.css"></head><body><pre><code class="language-rs">x</code></pre></body></html>"#;
320        fs::write(site.join("index.html"), html).unwrap();
321
322        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
323        HighlightPlugin::default().after_compile(&ctx).unwrap();
324        let out = fs::read_to_string(site.join("index.html")).unwrap();
325        // Exactly one stylesheet link — no double-injection.
326        assert_eq!(out.matches("/highlight.css").count(), 1);
327    }
328
329    #[test]
330    fn inject_css_link_without_head_returns_input_unchanged() {
331        // Line 145: the `else` branch of the `</head>` search.
332        let html = "<body>no head</body>";
333        let result = inject_css_link(html);
334        assert_eq!(result, html);
335    }
336
337    #[test]
338    fn collect_html_files_recurses_and_sorts() {
339        let dir = tempdir().unwrap();
340        let sub = dir.path().join("sub");
341        fs::create_dir_all(&sub).unwrap();
342        fs::write(dir.path().join("z.html"), "").unwrap();
343        fs::write(dir.path().join("a.html"), "").unwrap();
344        fs::write(sub.join("m.html"), "").unwrap();
345
346        let files = collect_html_files(dir.path()).unwrap();
347        assert_eq!(files.len(), 3);
348        let first = files[0].file_name().unwrap().to_str().unwrap();
349        assert_eq!(first, "a.html");
350    }
351
352    #[test]
353    fn collect_html_files_returns_empty_for_missing_directory() {
354        let dir = tempdir().unwrap();
355        let result = collect_html_files(&dir.path().join("missing")).unwrap();
356        assert!(result.is_empty());
357    }
358
359    #[test]
360    fn test_plugin_generates_css() {
361        let dir = tempdir().unwrap();
362        let site = dir.path().join("site");
363        fs::create_dir_all(&site).unwrap();
364
365        let html = r#"<html><head><title>X</title></head><body><pre><code class="language-js">let x = 1;</code></pre></body></html>"#;
366
367        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
368        HighlightPlugin::default().after_compile(&ctx).unwrap();
369
370        assert!(site.join("highlight.css").exists());
371        let output = HighlightPlugin::default()
372            .transform_html(html, &site.join("index.html"), &ctx)
373            .unwrap();
374        assert!(output.contains("highlight.css"));
375        assert!(output.contains("highlight language-js"));
376    }
377
378    #[test]
379    fn after_compile_write_failure_returns_io_error() {
380        let dir = tempdir().unwrap();
381        let site = dir.path().join("site");
382        fs::create_dir_all(&site).unwrap();
383
384        // Create a directory where highlight.css is expected to be written, causing fs::write to fail.
385        let css_dir = site.join("highlight.css");
386        fs::create_dir(&css_dir).unwrap();
387
388        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
389        let res = HighlightPlugin::default().after_compile(&ctx);
390        assert!(res.is_err());
391        let err = res.unwrap_err();
392        assert!(
393            matches!(err, SsgError::Io { ref path, .. } if path == &css_dir)
394        );
395    }
396}