1use crate::plugin::{Plugin, PluginContext};
13use crate::seo::helpers::{extract_title, has_meta_tag};
14use anyhow::Result;
15use std::{fs, path::Path};
16
17#[derive(Debug, Clone)]
19pub struct OgImagePlugin {
20 base_url: String,
22 brand_color: String,
24 text_color: String,
26}
27
28impl OgImagePlugin {
29 #[must_use]
31 pub fn new(base_url: impl Into<String>) -> Self {
32 Self {
33 base_url: base_url.into(),
34 brand_color: "#1a1a2e".to_string(),
35 text_color: "#ffffff".to_string(),
36 }
37 }
38
39 #[must_use]
41 pub fn with_colors(
42 base_url: impl Into<String>,
43 brand_color: impl Into<String>,
44 text_color: impl Into<String>,
45 ) -> Self {
46 Self {
47 base_url: base_url.into(),
48 brand_color: brand_color.into(),
49 text_color: text_color.into(),
50 }
51 }
52}
53
54#[must_use]
58pub fn generate_og_svg(
59 title: &str,
60 site_name: &str,
61 brand_color: &str,
62 text_color: &str,
63) -> String {
64 let escaped_title = escape_svg(title);
65 let escaped_site = escape_svg(site_name);
66
67 let lines = wrap_text(&escaped_title, 30);
69 let title_y_start = if lines.len() == 1 { 300 } else { 260 };
70
71 let mut title_elements = String::new();
72 for (i, line) in lines.iter().enumerate() {
73 let y = title_y_start + i * 60;
74 title_elements.push_str(&format!(
75 r#" <text x="600" y="{y}" font-family="system-ui, -apple-system, sans-serif" font-size="48" font-weight="bold" fill="{text_color}" text-anchor="middle">{line}</text>
76"#
77 ));
78 }
79
80 let site_y = title_y_start + lines.len() * 60 + 60;
81 let divider_y = title_y_start + lines.len() * 60 + 20;
82
83 format!(
84 r#"<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
85 <rect width="1200" height="630" fill="{brand_color}"/>
86 <rect x="40" y="40" width="1120" height="550" rx="16" fill="none" stroke="{text_color}" stroke-opacity="0.15" stroke-width="2"/>
87{title_elements} <text x="600" y="{site_y}" font-family="system-ui, -apple-system, sans-serif" font-size="24" fill="{text_color}" fill-opacity="0.7" text-anchor="middle">{escaped_site}</text>
88 <rect x="520" y="{divider_y}" width="160" height="3" rx="2" fill="{text_color}" fill-opacity="0.3"/>
89</svg>"#
90 )
91}
92
93fn wrap_text(text: &str, max_chars: usize) -> Vec<String> {
95 let words: Vec<&str> = text.split_whitespace().collect();
96 let mut lines = Vec::new();
97 let mut current = String::new();
98
99 for word in words {
100 if current.is_empty() {
101 current = word.to_string();
102 } else if current.len() + 1 + word.len() > max_chars {
103 lines.push(current);
104 current = word.to_string();
105 } else {
106 current.push(' ');
107 current.push_str(word);
108 }
109 }
110 if !current.is_empty() {
111 lines.push(current);
112 }
113 if lines.is_empty() {
114 lines.push(String::new());
115 }
116 lines.truncate(4);
118 lines
119}
120
121fn escape_svg(text: &str) -> String {
123 text.replace('&', "&")
124 .replace('<', "<")
125 .replace('>', ">")
126 .replace('"', """)
127 .replace('\'', "'")
128}
129
130fn slug_from_path(path: &Path, site_dir: &Path) -> String {
132 let rel = path.strip_prefix(site_dir).unwrap_or(path);
133 let stem = rel.with_extension("");
134 stem.to_string_lossy()
135 .replace(['/', '\\'], "-")
136 .trim_matches('-')
137 .to_string()
138}
139
140impl Plugin for OgImagePlugin {
141 fn name(&self) -> &'static str {
142 "og-image"
143 }
144
145 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
146 if !ctx.site_dir.exists() {
147 return Ok(());
148 }
149
150 let html_files = ctx.get_html_files();
151 let base = self.base_url.trim_end_matches('/');
152 let site_name =
153 ctx.config.as_ref().map_or("", |c| c.site_name.as_str());
154 let mut generated = 0usize;
155
156 for path in &html_files {
157 let Ok(html) = fs::read_to_string(path) else {
158 continue;
159 };
160
161 if has_meta_tag(&html, "og:image") {
163 continue;
164 }
165
166 let title = extract_title(&html);
167 if title.is_empty() {
168 continue;
169 }
170
171 let slug = slug_from_path(path, &ctx.site_dir);
172 let svg_filename = format!("og-{slug}.svg");
173 let svg_path = ctx.site_dir.join(&svg_filename);
174
175 let svg = generate_og_svg(
177 &title,
178 site_name,
179 &self.brand_color,
180 &self.text_color,
181 );
182 fs::write(&svg_path, &svg)?;
183
184 let og_url = format!("{base}/{svg_filename}");
186 let meta = format!(
187 "<meta property=\"og:image\" content=\"{og_url}\">\n\
188 <meta property=\"og:image:width\" content=\"1200\">\n\
189 <meta property=\"og:image:height\" content=\"630\">\n"
190 );
191
192 if let Some(pos) = html.find("</head>") {
193 let mut modified = html.clone();
194 modified.insert_str(pos, &meta);
195 fs::write(path, &modified)?;
196 generated += 1;
197 }
198 }
199
200 if generated > 0 {
201 log::info!("[og-image] Generated {generated} social card(s)");
202 }
203
204 Ok(())
205 }
206}
207
208#[cfg(test)]
209#[allow(clippy::unwrap_used, clippy::expect_used)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn generate_og_svg_basic() {
215 let svg =
216 generate_og_svg("Hello World", "My Site", "#1a1a2e", "#ffffff");
217 assert!(svg.contains("<svg"));
218 assert!(svg.contains("Hello World"));
219 assert!(svg.contains("My Site"));
220 assert!(svg.contains("#1a1a2e"));
221 assert!(svg.contains("1200"));
222 assert!(svg.contains("630"));
223 }
224
225 #[test]
226 fn generate_og_svg_escapes_html() {
227 let svg = generate_og_svg("A <B> & C", "Site \"X\"", "#000", "#fff");
228 assert!(svg.contains("A <B> & C"));
229 assert!(svg.contains("Site "X""));
230 }
231
232 #[test]
233 fn generate_og_svg_wraps_long_title() {
234 let title = "This Is A Very Long Title That Should Be Wrapped Across Multiple Lines";
235 let svg = generate_og_svg(title, "Site", "#000", "#fff");
236 let text_count = svg.matches("<text").count();
238 assert!(
239 text_count >= 3,
240 "Long title should wrap, got {text_count} text elements"
241 );
242 }
243
244 #[test]
245 fn wrap_text_short() {
246 let lines = wrap_text("Hello", 30);
247 assert_eq!(lines, vec!["Hello"]);
248 }
249
250 #[test]
251 fn wrap_text_long() {
252 let lines =
253 wrap_text("one two three four five six seven eight nine ten", 15);
254 assert!(lines.len() > 1);
255 for line in &lines {
256 assert!(line.len() <= 20, "Line too long: {line}");
257 }
258 }
259
260 #[test]
261 fn wrap_text_empty() {
262 let lines = wrap_text("", 30);
263 assert_eq!(lines, vec![""]);
264 }
265
266 #[test]
267 fn wrap_text_truncates_at_4_lines() {
268 let long = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
269 let lines = wrap_text(long, 5);
270 assert!(lines.len() <= 4);
271 }
272
273 #[test]
274 fn escape_svg_special_chars() {
275 assert_eq!(escape_svg("a & b"), "a & b");
276 assert_eq!(escape_svg("<tag>"), "<tag>");
277 assert_eq!(escape_svg("\"quoted\""), ""quoted"");
278 }
279
280 #[test]
281 fn slug_from_path_basic() {
282 let slug = slug_from_path(
283 Path::new("/site/about/index.html"),
284 Path::new("/site"),
285 );
286 assert_eq!(slug, "about-index");
287 }
288
289 #[test]
290 fn slug_from_path_root() {
291 let slug =
292 slug_from_path(Path::new("/site/index.html"), Path::new("/site"));
293 assert_eq!(slug, "index");
294 }
295
296 #[test]
297 fn og_image_plugin_name() {
298 let plugin = OgImagePlugin::new("https://example.com");
299 assert_eq!(plugin.name(), "og-image");
300 }
301
302 #[test]
303 fn og_image_plugin_skips_missing_site_dir() {
304 let plugin = OgImagePlugin::new("https://example.com");
305 let ctx = PluginContext::new(
306 Path::new("/tmp/c"),
307 Path::new("/tmp/b"),
308 Path::new("/nonexistent/site"),
309 Path::new("/tmp/t"),
310 );
311 assert!(plugin.after_compile(&ctx).is_ok());
312 }
313
314 #[test]
315 fn og_image_plugin_generates_svg_and_injects_meta() {
316 let dir = tempfile::tempdir().unwrap();
317 let site = dir.path().join("site");
318 fs::create_dir_all(&site).unwrap();
319
320 let html =
321 "<html><head><title>Test Page</title></head><body></body></html>";
322 fs::write(site.join("index.html"), html).unwrap();
323
324 let plugin = OgImagePlugin::new("https://example.com");
325 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
326 plugin.after_compile(&ctx).unwrap();
327
328 let svg_path = site.join("og-index.svg");
330 assert!(svg_path.exists(), "SVG file should be created");
331 let svg = fs::read_to_string(&svg_path).unwrap();
332 assert!(svg.contains("Test Page"));
333
334 let modified = fs::read_to_string(site.join("index.html")).unwrap();
336 assert!(modified.contains("og:image"));
337 assert!(modified.contains("og-index.svg"));
338 }
339
340 #[test]
341 fn og_image_plugin_skips_existing_og_image() {
342 let dir = tempfile::tempdir().unwrap();
343 let site = dir.path().join("site");
344 fs::create_dir_all(&site).unwrap();
345
346 let html = r#"<html><head><title>T</title><meta property="og:image" content="existing.jpg"></head><body></body></html>"#;
347 fs::write(site.join("index.html"), html).unwrap();
348
349 let plugin = OgImagePlugin::new("https://example.com");
350 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
351 plugin.after_compile(&ctx).unwrap();
352
353 assert!(!site.join("og-index.svg").exists());
355 }
356
357 #[test]
358 fn og_image_with_custom_colors() {
359 let plugin = OgImagePlugin::with_colors(
360 "https://example.com",
361 "#ff0000",
362 "#00ff00",
363 );
364 assert_eq!(plugin.brand_color, "#ff0000");
365 assert_eq!(plugin.text_color, "#00ff00");
366 }
367}