1use crate::error::{PathErrorExt, SsgError};
13use crate::plugin::{Plugin, PluginContext};
14use crate::seo::helpers::{extract_title, has_meta_tag};
15use crate::util::head_dom::inject_before_head_close;
16use crate::util::html_rewriter::decode_html_entities;
17use std::{fs, path::Path};
18
19#[derive(Debug, Clone)]
21pub struct OgImagePlugin {
22 base_url: String,
24 brand_color: String,
26 text_color: String,
28}
29
30impl OgImagePlugin {
31 #[must_use]
43 pub fn new(base_url: impl Into<String>) -> Self {
44 Self {
45 base_url: base_url.into(),
46 brand_color: "#1a1a2e".to_string(),
47 text_color: "#ffffff".to_string(),
48 }
49 }
50
51 #[must_use]
63 pub fn with_colors(
64 base_url: impl Into<String>,
65 brand_color: impl Into<String>,
66 text_color: impl Into<String>,
67 ) -> Self {
68 Self {
69 base_url: base_url.into(),
70 brand_color: brand_color.into(),
71 text_color: text_color.into(),
72 }
73 }
74}
75
76#[must_use]
90pub fn generate_og_svg(
91 title: &str,
92 site_name: &str,
93 brand_color: &str,
94 text_color: &str,
95) -> String {
96 let escaped_title = escape_svg(title);
97 let escaped_site = escape_svg(site_name);
98
99 let lines = wrap_text(&escaped_title, 30);
101 let title_y_start = if lines.len() == 1 { 300 } else { 260 };
102
103 let mut title_elements = String::new();
104 for (i, line) in lines.iter().enumerate() {
105 let y = title_y_start + i * 60;
106 title_elements.push_str(&format!(
107 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>
108"#
109 ));
110 }
111
112 let site_y = title_y_start + lines.len() * 60 + 60;
113 let divider_y = title_y_start + lines.len() * 60 + 20;
114
115 format!(
116 r#"<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
117 <rect width="1200" height="630" fill="{brand_color}"/>
118 <rect x="40" y="40" width="1120" height="550" rx="16" fill="none" stroke="{text_color}" stroke-opacity="0.15" stroke-width="2"/>
119{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>
120 <rect x="520" y="{divider_y}" width="160" height="3" rx="2" fill="{text_color}" fill-opacity="0.3"/>
121</svg>"#
122 )
123}
124
125fn wrap_text(text: &str, max_chars: usize) -> Vec<String> {
127 let words: Vec<&str> = text.split_whitespace().collect();
128 let mut lines = Vec::new();
129 let mut current = String::new();
130
131 for word in words {
132 if current.is_empty() {
133 current = word.to_string();
134 } else if current.len() + 1 + word.len() > max_chars {
135 lines.push(current);
136 current = word.to_string();
137 } else {
138 current.push(' ');
139 current.push_str(word);
140 }
141 }
142 if !current.is_empty() {
143 lines.push(current);
144 }
145 if lines.is_empty() {
146 lines.push(String::new());
147 }
148 lines.truncate(4);
150 lines
151}
152
153fn escape_svg(text: &str) -> String {
155 text.replace('&', "&")
156 .replace('<', "<")
157 .replace('>', ">")
158 .replace('"', """)
159 .replace('\'', "'")
160}
161
162fn slug_from_path(path: &Path, site_dir: &Path) -> String {
164 let rel = path.strip_prefix(site_dir).unwrap_or(path);
165 let stem = rel.with_extension("");
166 let stem_str = stem.to_string_lossy();
167 stem_str
168 .replace(['/', '\\'], "-")
169 .trim_matches('-')
170 .to_string()
171}
172
173impl Plugin for OgImagePlugin {
174 fn name(&self) -> &'static str {
175 "og-image"
176 }
177
178 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
179 if !ctx.site_dir.exists() {
180 return Ok(());
181 }
182
183 let html_files = ctx.get_html_files();
184 let base = self.base_url.trim_end_matches('/');
185 let site_name =
186 ctx.config.as_ref().map_or("", |c| c.site_name.as_str());
187 let mut generated = 0usize;
188
189 for path in &html_files {
190 let Ok(html) = fs::read_to_string(path) else {
191 continue;
192 };
193
194 if has_meta_tag(&html, "og:image") {
196 continue;
197 }
198
199 let title = decode_html_entities(&extract_title(&html));
207 if title.is_empty() {
208 continue;
209 }
210
211 let slug = slug_from_path(path, &ctx.site_dir);
212 let svg_filename = format!("og-{slug}.svg");
213 let svg_path = ctx.site_dir.join(&svg_filename);
214
215 let svg = generate_og_svg(
217 &title,
218 site_name,
219 &self.brand_color,
220 &self.text_color,
221 );
222 fs::write(&svg_path, &svg).with_path(&svg_path)?;
223
224 let og_url = format!("{base}/{svg_filename}");
226 let meta = format!(
227 "<meta property=\"og:image\" content=\"{og_url}\">\n\
228 <meta property=\"og:image:width\" content=\"1200\">\n\
229 <meta property=\"og:image:height\" content=\"630\">\n"
230 );
231
232 if html.contains("</head>") {
233 let modified = inject_before_head_close(&html, &meta);
234 if modified != html {
235 fs::write(path, &modified).with_path(path)?;
236 generated += 1;
237 }
238 }
239 }
240
241 if generated > 0 {
242 log::info!("[og-image] Generated {generated} social card(s)");
243 }
244
245 Ok(())
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::error::SsgError;
253
254 #[test]
255 fn generate_og_svg_basic() {
256 let svg =
257 generate_og_svg("Hello World", "My Site", "#1a1a2e", "#ffffff");
258 assert!(svg.contains("<svg"));
259 assert!(svg.contains("Hello World"));
260 assert!(svg.contains("My Site"));
261 assert!(svg.contains("#1a1a2e"));
262 assert!(svg.contains("1200"));
263 assert!(svg.contains("630"));
264 }
265
266 #[test]
267 fn generate_og_svg_escapes_html() {
268 let svg = generate_og_svg("A <B> & C", "Site \"X\"", "#000", "#fff");
269 assert!(svg.contains("A <B> & C"));
270 assert!(svg.contains("Site "X""));
271 }
272
273 #[test]
274 fn generate_og_svg_wraps_long_title() {
275 let title = "This Is A Very Long Title That Should Be Wrapped Across Multiple Lines";
276 let svg = generate_og_svg(title, "Site", "#000", "#fff");
277 let text_count = svg.matches("<text").count();
279 assert!(
280 text_count >= 3,
281 "Long title should wrap, got {text_count} text elements"
282 );
283 }
284
285 #[test]
286 fn wrap_text_short() {
287 let lines = wrap_text("Hello", 30);
288 assert_eq!(lines, vec!["Hello"]);
289 }
290
291 #[test]
292 fn wrap_text_long() {
293 let lines =
294 wrap_text("one two three four five six seven eight nine ten", 15);
295 assert!(lines.len() > 1);
296 for line in &lines {
297 assert!(line.len() <= 20, "Line too long: {line}");
298 }
299 }
300
301 #[test]
302 fn wrap_text_empty() {
303 let lines = wrap_text("", 30);
304 assert_eq!(lines, vec![""]);
305 }
306
307 #[test]
308 fn wrap_text_truncates_at_4_lines() {
309 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";
310 let lines = wrap_text(long, 5);
311 assert!(lines.len() <= 4);
312 }
313
314 #[test]
315 fn escape_svg_special_chars() {
316 assert_eq!(escape_svg("a & b"), "a & b");
317 }
318
319 #[test]
324 fn svg_title_round_trip_is_single_escaped() {
325 use crate::util::html_rewriter::decode_html_entities;
326 let from_page = "About: AI, Payments & Post-Quantum";
327 let svg_ready = escape_svg(&decode_html_entities(from_page));
328 assert_eq!(svg_ready, "About: AI, Payments & Post-Quantum");
329 assert!(!svg_ready.contains("&amp;"));
330 assert_eq!(escape_svg("<tag>"), "<tag>");
331 assert_eq!(escape_svg("\"quoted\""), ""quoted"");
332 }
333
334 #[test]
335 fn slug_from_path_basic() {
336 let slug = slug_from_path(
337 Path::new("/site/about/index.html"),
338 Path::new("/site"),
339 );
340 assert_eq!(slug, "about-index");
341 }
342
343 #[test]
344 fn slug_from_path_falls_back_when_not_under_site_dir() {
345 let slug =
350 slug_from_path(Path::new("/other/page.html"), Path::new("/site"));
351 assert_eq!(slug, "other-page");
352 }
353
354 #[test]
355 fn slug_from_path_root() {
356 let slug =
357 slug_from_path(Path::new("/site/index.html"), Path::new("/site"));
358 assert_eq!(slug, "index");
359 }
360
361 #[test]
362 fn og_image_plugin_name() {
363 let plugin = OgImagePlugin::new("https://example.com");
364 assert_eq!(plugin.name(), "og-image");
365 }
366
367 #[test]
368 fn og_image_plugin_skips_missing_site_dir() {
369 let plugin = OgImagePlugin::new("https://example.com");
370 let ctx = PluginContext::new(
371 Path::new("/tmp/c"),
372 Path::new("/tmp/b"),
373 Path::new("/nonexistent/site"),
374 Path::new("/tmp/t"),
375 );
376 assert!(plugin.after_compile(&ctx).is_ok());
377 }
378
379 #[test]
380 fn og_image_plugin_generates_svg_and_injects_meta() {
381 let dir = tempfile::tempdir().unwrap();
382 let site = dir.path().join("site");
383 fs::create_dir_all(&site).unwrap();
384
385 let html =
386 "<html><head><title>Test Page</title></head><body></body></html>";
387 fs::write(site.join("index.html"), html).unwrap();
388
389 let plugin = OgImagePlugin::new("https://example.com");
390 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
391 plugin.after_compile(&ctx).unwrap();
392
393 let svg_path = site.join("og-index.svg");
395 assert!(svg_path.exists(), "SVG file should be created");
396 let svg = fs::read_to_string(&svg_path).unwrap();
397 assert!(svg.contains("Test Page"));
398
399 let modified = fs::read_to_string(site.join("index.html")).unwrap();
401 assert!(modified.contains("og:image"));
402 assert!(modified.contains("og-index.svg"));
403 }
404
405 #[test]
406 fn og_image_plugin_skips_existing_og_image() {
407 let dir = tempfile::tempdir().unwrap();
408 let site = dir.path().join("site");
409 fs::create_dir_all(&site).unwrap();
410
411 let html = r#"<html><head><title>T</title><meta property="og:image" content="existing.jpg"></head><body></body></html>"#;
412 fs::write(site.join("index.html"), html).unwrap();
413
414 let plugin = OgImagePlugin::new("https://example.com");
415 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
416 plugin.after_compile(&ctx).unwrap();
417
418 assert!(!site.join("og-index.svg").exists());
420 }
421
422 #[test]
423 fn og_image_with_custom_colors() {
424 let plugin = OgImagePlugin::with_colors(
425 "https://example.com",
426 "#ff0000",
427 "#00ff00",
428 );
429 assert_eq!(plugin.brand_color, "#ff0000");
430 assert_eq!(plugin.text_color, "#00ff00");
431 }
432
433 #[test]
434 fn after_compile_write_failure_returns_io_error() {
435 let dir = tempfile::tempdir().unwrap();
436 let site = dir.path().join("site");
437 fs::create_dir_all(&site).unwrap();
438
439 let html =
440 "<html><head><title>Test Page</title></head><body></body></html>";
441 fs::write(site.join("index.html"), html).unwrap();
442
443 let svg_dir = site.join("og-index.svg");
445 fs::create_dir(&svg_dir).unwrap();
446
447 let plugin = OgImagePlugin::new("https://example.com");
448 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
449
450 let res = plugin.after_compile(&ctx);
451 assert!(res.is_err());
452 let err = res.unwrap_err();
453 assert!(
454 matches!(err, SsgError::Io { ref path, .. } if path == &svg_dir)
455 );
456 }
457
458 #[test]
459 fn after_compile_uses_config_site_name() {
460 use crate::cmd::SsgConfig;
461 let dir = tempfile::tempdir().unwrap();
462 let site = dir.path().join("site");
463 fs::create_dir_all(&site).unwrap();
464 fs::write(
465 site.join("index.html"),
466 "<html><head><title>T</title></head><body>x</body></html>",
467 )
468 .unwrap();
469 let cfg = SsgConfig::builder()
470 .site_name("Branded".to_string())
471 .base_url("https://example.com".to_string())
472 .build()
473 .unwrap();
474 let ctx = PluginContext::with_config(
475 dir.path(),
476 dir.path(),
477 &site,
478 dir.path(),
479 cfg,
480 );
481
482 OgImagePlugin::new("https://example.com")
483 .after_compile(&ctx)
484 .unwrap();
485 let svg = fs::read_to_string(site.join("og-index.svg")).unwrap();
486 assert!(svg.contains("Branded"));
487 }
488
489 #[test]
490 #[cfg(unix)]
491 fn after_compile_skips_unreadable_html() {
492 use std::os::unix::fs::PermissionsExt;
493 let dir = tempfile::tempdir().unwrap();
494 let site = dir.path().join("site");
495 fs::create_dir_all(&site).unwrap();
496 let html = site.join("index.html");
497 fs::write(
498 &html,
499 "<html><head><title>T</title></head><body>x</body></html>",
500 )
501 .unwrap();
502 fs::set_permissions(&html, fs::Permissions::from_mode(0o000)).unwrap();
503
504 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
505 let res = OgImagePlugin::new("https://example.com").after_compile(&ctx);
506
507 let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
508 assert!(res.is_ok());
510 }
511
512 #[test]
513 fn after_compile_skips_pages_without_title() {
514 let dir = tempfile::tempdir().unwrap();
515 let site = dir.path().join("site");
516 fs::create_dir_all(&site).unwrap();
517 fs::write(
518 site.join("index.html"),
519 "<html><head></head><body>no title here</body></html>",
520 )
521 .unwrap();
522
523 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
524 OgImagePlugin::new("https://example.com")
525 .after_compile(&ctx)
526 .unwrap();
527 assert!(!site.join("og-index.svg").exists());
528 }
529
530 #[test]
531 fn after_compile_skips_injection_without_head_close() {
532 let dir = tempfile::tempdir().unwrap();
535 let site = dir.path().join("site");
536 fs::create_dir_all(&site).unwrap();
537 let html = "<title>T</title><body>x</body>";
538 fs::write(site.join("index.html"), html).unwrap();
539
540 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
541 OgImagePlugin::new("https://example.com")
542 .after_compile(&ctx)
543 .unwrap();
544 assert!(site.join("og-index.svg").exists());
545 assert_eq!(fs::read_to_string(site.join("index.html")).unwrap(), html);
546 }
547
548 #[test]
549 fn after_compile_stray_head_close_leaves_html_unchanged() {
550 let dir = tempfile::tempdir().unwrap();
554 let site = dir.path().join("site");
555 fs::create_dir_all(&site).unwrap();
556 let html = "<html><title>T</title>x</head><body>b</body></html>";
557 fs::write(site.join("index.html"), html).unwrap();
558
559 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
560 OgImagePlugin::new("https://example.com")
561 .after_compile(&ctx)
562 .unwrap();
563 assert_eq!(fs::read_to_string(site.join("index.html")).unwrap(), html);
564 }
565
566 #[test]
567 #[cfg(unix)]
568 fn after_compile_fails_when_html_is_readonly() {
569 use std::os::unix::fs::PermissionsExt;
570 let dir = tempfile::tempdir().unwrap();
571 let site = dir.path().join("site");
572 fs::create_dir_all(&site).unwrap();
573 let html = site.join("index.html");
574 fs::write(
575 &html,
576 "<html><head><title>T</title></head><body>x</body></html>",
577 )
578 .unwrap();
579 fs::set_permissions(&html, fs::Permissions::from_mode(0o444)).unwrap();
580
581 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
582 let res = OgImagePlugin::new("https://example.com").after_compile(&ctx);
583
584 let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
585 if let Err(e) = res {
587 assert!(!format!("{e}").is_empty());
588 }
589 }
590}