Skip to main content

ssg/
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::plugin::{Plugin, PluginContext};
13use crate::seo::helpers::{extract_title, has_meta_tag};
14use anyhow::Result;
15use std::{fs, path::Path};
16
17/// Plugin that auto-generates Open Graph social card images.
18#[derive(Debug, Clone)]
19pub struct OgImagePlugin {
20    /// Base URL for the site (used in og:image URLs).
21    base_url: String,
22    /// Background colour for the card (CSS hex).
23    brand_color: String,
24    /// Text colour (CSS hex).
25    text_color: String,
26}
27
28impl OgImagePlugin {
29    /// Creates a new `OgImagePlugin` with default branding.
30    #[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    /// Creates a plugin with custom brand colours.
40    #[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/// Generates an SVG social card with the given title and site name.
55///
56/// The card is 1200x630 pixels (standard OG image dimensions).
57#[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    // Wrap long titles across multiple lines
68    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
93/// Wraps text into lines of approximately `max_chars` characters.
94fn 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    // Limit to 4 lines to stay within the card
117    lines.truncate(4);
118    lines
119}
120
121/// Escapes text for safe inclusion in SVG.
122fn escape_svg(text: &str) -> String {
123    text.replace('&', "&amp;")
124        .replace('<', "&lt;")
125        .replace('>', "&gt;")
126        .replace('"', "&quot;")
127        .replace('\'', "&apos;")
128}
129
130/// Derives a URL-safe slug from a file path relative to the site directory.
131fn 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            // Skip pages that already have an og:image
162            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            // Generate SVG
176            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            // Inject og:image meta tag
185            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 &lt;B&gt; &amp; C"));
229        assert!(svg.contains("Site &quot;X&quot;"));
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        // Should contain multiple <text> elements for the title
237        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 &amp; b");
276        assert_eq!(escape_svg("<tag>"), "&lt;tag&gt;");
277        assert_eq!(escape_svg("\"quoted\""), "&quot;quoted&quot;");
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        // Check SVG was created
329        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        // Check meta tag was injected
335        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        // SVG should NOT be created
354        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}