use crate::plugin::{Plugin, PluginContext};
use anyhow::Result;
use std::{
fs,
path::{Path, PathBuf},
};
#[derive(Debug)]
pub struct HighlightPlugin {
theme: String,
}
impl Default for HighlightPlugin {
fn default() -> Self {
Self {
theme: "github".to_string(),
}
}
}
impl HighlightPlugin {
pub fn with_theme(theme: impl Into<String>) -> Self {
Self {
theme: theme.into(),
}
}
}
impl Plugin for HighlightPlugin {
fn name(&self) -> &'static str {
"highlight"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
if !ctx.site_dir.exists() {
return Ok(());
}
let css = generate_highlight_css(&self.theme);
fs::write(ctx.site_dir.join("highlight.css"), &css)?;
let html_files = collect_html_files(&ctx.site_dir)?;
let mut highlighted = 0usize;
for path in &html_files {
let html = fs::read_to_string(path)?;
let result = add_highlight_markup(&html);
if result != html {
let output = if result.contains("highlight.css") {
result
} else {
inject_css_link(&result)
};
fs::write(path, output)?;
highlighted += 1;
}
}
if highlighted > 0 {
log::info!(
"[highlight] Processed {} file(s), theme: {}",
highlighted,
self.theme
);
}
Ok(())
}
}
fn add_highlight_markup(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut pos = 0;
while pos < html.len() {
if let Some(pre_start) = html[pos..].find("<pre>") {
let abs_pre = pos + pre_start;
let after_pre = abs_pre + 5;
let remaining = &html[after_pre..];
if remaining.starts_with("<code class=\"language-") {
let lang_start = "language-".len();
let code_attr = &remaining["<code class=\"".len()..];
let lang_end = code_attr.find('"').unwrap_or(0);
let lang = &code_attr[lang_start..lang_end];
result.push_str(&html[pos..abs_pre]);
result.push_str(&format!(
"<pre class=\"highlight language-{lang}\">"
));
result.push_str(&format!(
"<code class=\"language-{lang}\" data-lang=\"{lang}\">"
));
let code_tag_end = remaining.find('>').unwrap_or(0);
pos = after_pre + code_tag_end + 1;
continue;
}
}
result.push_str(&html[pos..]);
break;
}
result
}
fn inject_css_link(html: &str) -> String {
if let Some(pos) = html.find("</head>") {
format!(
"{}<link rel=\"stylesheet\" href=\"/highlight.css\">\n{}",
&html[..pos],
&html[pos..]
)
} else {
html.to_string()
}
}
fn generate_highlight_css(theme: &str) -> String {
match theme {
"github" | _ => {
r#"/* Syntax highlighting — GitHub-inspired theme */
pre.highlight {
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 1em;
overflow-x: auto;
font-size: 0.875em;
line-height: 1.45;
}
pre.highlight code {
background: none;
padding: 0;
border: none;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
pre.highlight {
background: #161b22;
border-color: #30363d;
color: #e6edf3;
}
}
"#
.to_string()
}
}
}
fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>> {
crate::walk::walk_files(dir, "html")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_add_highlight_markup() {
let html =
r#"<pre><code class="language-rust">fn main() {}</code></pre>"#;
let result = add_highlight_markup(html);
assert!(result.contains("class=\"highlight language-rust\""));
assert!(result.contains("data-lang=\"rust\""));
}
#[test]
fn test_no_code_block_unchanged() {
let html = "<pre>plain text</pre>";
let result = add_highlight_markup(html);
assert_eq!(result, html);
}
#[test]
fn test_inject_css_link() {
let html = "<html><head><title>X</title></head><body></body></html>";
let result = inject_css_link(html);
assert!(result.contains("highlight.css"));
}
#[test]
fn test_generate_css() {
let css = generate_highlight_css("github");
assert!(css.contains("pre.highlight"));
assert!(css.contains("prefers-color-scheme: dark"));
}
#[test]
fn name_returns_static_highlight_identifier() {
assert_eq!(HighlightPlugin::default().name(), "highlight");
}
#[test]
fn default_constructor_uses_github_theme() {
let plugin = HighlightPlugin::default();
assert_eq!(plugin.theme, "github");
}
#[test]
fn with_theme_stores_supplied_theme_name() {
let plugin = HighlightPlugin::with_theme("solarized");
assert_eq!(plugin.theme, "solarized");
let plugin2 = HighlightPlugin::with_theme(String::from("dracula"));
assert_eq!(plugin2.theme, "dracula");
}
#[test]
fn after_compile_missing_site_dir_returns_ok() {
let dir = tempdir().unwrap();
let missing = dir.path().join("missing");
let ctx =
PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
HighlightPlugin::default().after_compile(&ctx).unwrap();
assert!(!missing.join("highlight.css").exists());
}
#[test]
fn after_compile_html_without_code_blocks_is_unchanged() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let html = "<html><head></head><body><p>no code</p></body></html>";
fs::write(site.join("plain.html"), html).unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
HighlightPlugin::default().after_compile(&ctx).unwrap();
assert_eq!(fs::read_to_string(site.join("plain.html")).unwrap(), html);
}
#[test]
fn after_compile_preserves_existing_highlight_css_link() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let html = r#"<html><head><link rel="stylesheet" href="/highlight.css"></head><body><pre><code class="language-rs">x</code></pre></body></html>"#;
fs::write(site.join("index.html"), html).unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
HighlightPlugin::default().after_compile(&ctx).unwrap();
let out = fs::read_to_string(site.join("index.html")).unwrap();
assert_eq!(out.matches("/highlight.css").count(), 1);
}
#[test]
fn inject_css_link_without_head_returns_input_unchanged() {
let html = "<body>no head</body>";
let result = inject_css_link(html);
assert_eq!(result, html);
}
#[test]
fn collect_html_files_recurses_and_sorts() {
let dir = tempdir().unwrap();
let sub = dir.path().join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(dir.path().join("z.html"), "").unwrap();
fs::write(dir.path().join("a.html"), "").unwrap();
fs::write(sub.join("m.html"), "").unwrap();
let files = collect_html_files(dir.path()).unwrap();
assert_eq!(files.len(), 3);
let first = files[0].file_name().unwrap().to_str().unwrap();
assert_eq!(first, "a.html");
}
#[test]
fn collect_html_files_returns_empty_for_missing_directory() {
let dir = tempdir().unwrap();
let result = collect_html_files(&dir.path().join("missing")).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_plugin_generates_css() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let html = r#"<html><head><title>X</title></head><body><pre><code class="language-js">let x = 1;</code></pre></body></html>"#;
fs::write(site.join("index.html"), html).unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
HighlightPlugin::default().after_compile(&ctx).unwrap();
assert!(site.join("highlight.css").exists());
let output = fs::read_to_string(site.join("index.html")).unwrap();
assert!(output.contains("highlight.css"));
assert!(output.contains("highlight language-js"));
}
}