1use 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#[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 #[must_use]
49 pub fn new(theme: impl Into<String>) -> Self {
50 Self {
51 theme: theme.into(),
52 }
53 }
54
55 #[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 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 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
106fn 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
120fn 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 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
148fn 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
164fn 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 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 #[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 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 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 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 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 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 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 assert_eq!(out.matches("/highlight.css").count(), 1);
338 }
339
340 #[test]
341 fn inject_css_link_without_head_appends_the_link() {
342 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 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}