1use crate::plugin::{Plugin, PluginContext};
11use anyhow::Result;
12use std::fs;
13use std::path::Path;
14
15#[derive(Debug)]
21pub struct HighlightPlugin {
22 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 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 let css = generate_highlight_css(&self.theme);
76 fs::write(ctx.site_dir.join("highlight.css"), &css)?;
77
78 Ok(())
79 }
80}
81
82fn 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; let remaining = &html[after_pre..];
97 if remaining.starts_with("<code class=\"language-") {
98 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 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 let code_tag_end = remaining.find('>').unwrap_or(0);
115 pos = after_pre + code_tag_end + 1;
116 continue;
117 }
118 }
119
120 result.push_str(&html[pos..]);
122 break;
123 }
124
125 result
126}
127
128fn 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
141fn 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 #[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 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 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 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 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 assert_eq!(out.matches("/highlight.css").count(), 1);
278 }
279
280 #[test]
281 fn inject_css_link_without_head_returns_input_unchanged() {
282 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}