Skip to main content

ssg/plugins/
og_image.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Auto-generates Open Graph social card images from page metadata.
5//!
6//! For each HTML page, generates a branded SVG social card containing
7//! the page title and site name. Injects the `og:image` meta tag
8//! pointing to the generated image.
9//!
10//! No external dependencies — uses inline SVG generation.
11
12use 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/// Plugin that auto-generates Open Graph social card images.
20#[derive(Debug, Clone)]
21pub struct OgImagePlugin {
22    /// Base URL for the site (used in og:image URLs).
23    base_url: String,
24    /// Background colour for the card (CSS hex).
25    brand_color: String,
26    /// Text colour (CSS hex).
27    text_color: String,
28}
29
30impl OgImagePlugin {
31    /// Creates a new `OgImagePlugin` with default branding.
32    ///
33    /// # Examples
34    ///
35    /// ```rust
36    /// use ssg::og_image::OgImagePlugin;
37    /// use ssg::plugin::Plugin;
38    ///
39    /// let p = OgImagePlugin::new("https://example.com");
40    /// assert_eq!(p.name(), "og-image");
41    /// ```
42    #[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    /// Creates a plugin with custom brand colours.
52    ///
53    /// # Examples
54    ///
55    /// ```rust
56    /// use ssg::og_image::OgImagePlugin;
57    /// use ssg::plugin::Plugin;
58    ///
59    /// let p = OgImagePlugin::with_colors("https://example.com", "#000", "#fff");
60    /// assert_eq!(p.name(), "og-image");
61    /// ```
62    #[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/// Generates an SVG social card with the given title and site name.
77///
78/// The card is 1200x630 pixels (standard OG image dimensions).
79///
80/// # Examples
81///
82/// ```rust
83/// use ssg::og_image::generate_og_svg;
84///
85/// let svg = generate_og_svg("Hello", "My Site", "#000", "#fff");
86/// assert!(svg.starts_with("<svg"));
87/// assert!(svg.contains("Hello"));
88/// ```
89#[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    // Wrap long titles across multiple lines
100    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
125/// Wraps text into lines of approximately `max_chars` characters.
126fn 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    // Limit to 4 lines to stay within the card
149    lines.truncate(4);
150    lines
151}
152
153/// Escapes text for safe inclusion in SVG.
154fn escape_svg(text: &str) -> String {
155    text.replace('&', "&amp;")
156        .replace('<', "&lt;")
157        .replace('>', "&gt;")
158        .replace('"', "&quot;")
159        .replace('\'', "&apos;")
160}
161
162/// Derives a URL-safe slug from a file path relative to the site directory.
163fn 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            // Skip pages that already have an og:image
195            if has_meta_tag(&html, "og:image") {
196                continue;
197            }
198
199            // `extract_title` returns the title still HTML-encoded: lol_html
200            // passes text chunks through "as-is, without unescaping", so a
201            // page titled `A & B` yields `A &amp; B`. Escaping that for SVG
202            // would emit `A &amp;amp; B` and the generated preview image
203            // would render the entity as literal text. Decode to plain text
204            // first, then let `escape_svg` do the one escape SVG needs —
205            // the same order `extract_text` already uses for the search index.
206            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            // Generate SVG
216            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            // Inject og:image meta tag
225            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 &lt;B&gt; &amp; C"));
270        assert!(svg.contains("Site &quot;X&quot;"));
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        // Should contain multiple <text> elements for the title
278        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 &amp; b");
317    }
318
319    /// #706 sibling: `extract_title` returns HTML-encoded text (`lol_html`
320    /// passes text chunks through without unescaping), so escaping it for
321    /// SVG without decoding first renders `&amp;` as literal text inside
322    /// the generated preview image.
323    #[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 &amp; Post-Quantum";
327        let svg_ready = escape_svg(&decode_html_entities(from_page));
328        assert_eq!(svg_ready, "About: AI, Payments &amp; Post-Quantum");
329        assert!(!svg_ready.contains("&amp;amp;"));
330        assert_eq!(escape_svg("<tag>"), "&lt;tag&gt;");
331        assert_eq!(escape_svg("\"quoted\""), "&quot;quoted&quot;");
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        // `path.strip_prefix(site_dir).unwrap_or(path)` — when `path`
346        // isn't actually nested under `site_dir`, `strip_prefix` fails
347        // and the whole path is used as-is (the other two tests here
348        // only ever exercise the success arm).
349        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        // Check SVG was created
394        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        // Check meta tag was injected
400        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        // SVG should NOT be created
419        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        // Create a directory where the SVG is expected to be written, causing fs::write to fail.
444        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        // Unreadable pages are skipped, not fatal.
509        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        // Title present but no `</head>` — SVG is written, HTML left
533        // untouched.
534        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        // The literal `</head>` passes the contains() gate, but with
551        // no real `<head>` start tag lol_html injects nothing, so
552        // `modified == html` and no write happens.
553        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        // Root CI runners bypass perms; only assert when it errored.
586        if let Err(e) = res {
587            assert!(!format!("{e}").is_empty());
588        }
589    }
590}