1use 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#[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 #[must_use]
46 pub fn new(theme: impl Into<String>) -> Self {
47 Self {
48 theme: theme.into(),
49 }
50 }
51
52 #[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 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
111fn 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; let remaining = &html[after_pre..];
126 if remaining.starts_with("<code class=\"language-") {
127 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 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 let code_tag_end = remaining.find('>').unwrap_or(0);
144 pos = after_pre + code_tag_end + 1;
145 continue;
146 }
147 }
148
149 result.push_str(&html[pos..]);
151 break;
152 }
153
154 result
155}
156
157fn inject_css_link(html: &str) -> String {
159 inject_before_head_close(
160 html,
161 "<link rel=\"stylesheet\" href=\"/highlight.css\">\n",
162 )
163}
164
165fn 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 #[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 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 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 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 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 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 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 assert_eq!(out.matches("/highlight.css").count(), 1);
327 }
328
329 #[test]
330 fn inject_css_link_without_head_returns_input_unchanged() {
331 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 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}