Skip to main content

ssg/plugins/
oembed.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! oEmbed 1.0 emitter (issue #586, port 4 of 5).
5//!
6//! Port of the site's Python-pipeline `OembedPlugin`: for every
7//! shareable (public, titled) page, emit an
8//! [oEmbed 1.0](https://oembed.com/) `link`-type document so
9//! consumers (chat unfurlers, editors, CMSs) can render rich previews
10//! without scraping the page.
11//!
12//! ## Files emitted
13//!
14//! For a page at `<site>/blog/post.html` the plugin writes the
15//! sibling document `<site>/blog/post.oembed.json`:
16//!
17//! ```json
18//! {
19//!   "version": "1.0",
20//!   "type": "link",
21//!   "title": "Post title",
22//!   "provider_name": "<site_name>",
23//!   "provider_url": "<base_url>",
24//!   "author_name": "<frontmatter author, when present>"
25//! }
26//! ```
27//!
28//! During the fused transform pass each page whose oEmbed sibling
29//! exists also gains the standard discovery link in `<head>`:
30//!
31//! ```html
32//! <link rel="alternate" type="application/json+oembed"
33//!       href="<base>/blog/post.oembed.json" title="Post title">
34//! ```
35//!
36//! ## Opt-in
37//!
38//! **Off by default**, per the tracker. Following the same
39//! registration-gated convention as
40//! [`crate::search_index::VectorSearchPlugin`], the plugin is *not*
41//! part of `register_default_plugins` — sites opt in by registering
42//! it explicitly:
43//!
44//! ```
45//! use ssg::oembed::OembedPlugin;
46//! use ssg::plugin::PluginManager;
47//! let mut pm = PluginManager::new();
48//! pm.register(OembedPlugin::default());
49//! ```
50//!
51//! ## Lifecycle & determinism
52//!
53//! JSON documents are written in `after_compile`; the discovery
54//! `<link>` is injected via `transform_html` which the pipeline runs
55//! *after* `after_compile` (see `run_fused_transforms`), so the
56//! sibling-exists check is reliable. Output contains no timestamps
57//! and `serde_json`'s `BTreeMap`-backed maps serialise keys sorted —
58//! byte-identical across rebuilds.
59
60use crate::error::{PathErrorExt, SsgError};
61use crate::plugin::{Plugin, PluginContext};
62use serde_json::Value;
63use std::fs;
64use std::path::Path;
65
66/// Plugin that emits per-page oEmbed 1.0 documents plus the discovery
67/// `<link>` tag.
68///
69/// # Examples
70///
71/// ```
72/// use ssg::oembed::OembedPlugin;
73/// use ssg::plugin::Plugin;
74/// assert_eq!(OembedPlugin::default().name(), "oembed");
75/// ```
76#[derive(Debug, Clone, Copy, Default)]
77pub struct OembedPlugin;
78
79impl Plugin for OembedPlugin {
80    fn name(&self) -> &'static str {
81        "oembed"
82    }
83
84    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
85        if ctx.dry_run || !ctx.site_dir.exists() {
86            return Ok(());
87        }
88
89        let posts = crate::agent_api::collect_posts(ctx);
90        let mut written = 0usize;
91
92        for post in &posts {
93            let Some(rel) = page_rel_path(&post.url) else {
94                continue;
95            };
96            let html_path = ctx.site_dir.join(&rel);
97            // Only emit for pages that actually exist on disk — a
98            // sidecar without a rendered page is stale metadata.
99            if !html_path.exists() {
100                continue;
101            }
102            let doc = build_oembed(
103                &post.title,
104                post.author.as_deref(),
105                ctx.config.as_ref().map(|c| c.site_name.as_str()),
106                ctx.config.as_ref().map(|c| c.base_url.as_str()),
107            );
108            let out = html_path.with_extension("oembed.json");
109            let mut body = serde_json::to_string_pretty(&doc).map_err(|e| {
110                SsgError::Io {
111                    path: out.clone(),
112                    source: std::io::Error::other(e),
113                }
114            })?;
115            body.push('\n');
116            fs::write(&out, body).with_path(&out)?;
117            written += 1;
118        }
119
120        if written > 0 {
121            log::info!("[oembed] Wrote {written} oembed.json document(s)");
122        }
123        Ok(())
124    }
125
126    fn has_transform(&self) -> bool {
127        true
128    }
129
130    /// Injects the oEmbed discovery `<link>` before `</head>` when the
131    /// page's `*.oembed.json` sibling exists. Idempotent.
132    fn transform_html(
133        &self,
134        html: &str,
135        path: &Path,
136        ctx: &PluginContext,
137    ) -> Result<String, SsgError> {
138        if html.contains("application/json+oembed") {
139            return Ok(html.to_string());
140        }
141        let sibling = path.with_extension("oembed.json");
142        if !sibling.exists() {
143            return Ok(html.to_string());
144        }
145
146        let rel = path
147            .strip_prefix(&ctx.site_dir)
148            .unwrap_or(path)
149            .with_extension("oembed.json");
150        let rel = rel.to_string_lossy().replace('\\', "/");
151        let base = ctx
152            .config
153            .as_ref()
154            .map(|c| c.base_url.trim_end_matches('/').to_string())
155            .unwrap_or_default();
156        let href = if base.is_empty() {
157            format!("/{rel}")
158        } else {
159            format!("{base}/{rel}")
160        };
161
162        let title = read_title(&sibling).unwrap_or_default();
163        let link = format!(
164            "<link rel=\"alternate\" type=\"application/json+oembed\" \
165             href=\"{}\" title=\"{}\">",
166            attr_escape(&href),
167            attr_escape(&title),
168        );
169        // Parser-backed injection, not a `find("</head>")` splice: the
170        // first byte match may be inside a comment or a script body in the
171        // head, and the payload would land there — inert, and silently so,
172        // because the document still parses (ssg#540).
173        Ok(crate::util::head_dom::inject_before_head_close(html, &link))
174    }
175}
176
177/// Builds one oEmbed 1.0 `link`-type document.
178///
179/// # Examples
180///
181/// ```
182/// use ssg::oembed::build_oembed;
183/// let doc = build_oembed(
184///     "Hello",
185///     Some("[email protected] (Jane)"),
186///     Some("Example"),
187///     Some("https://example.com"),
188/// );
189/// assert_eq!(doc["version"], "1.0");
190/// assert_eq!(doc["type"], "link");
191/// assert_eq!(doc["title"], "Hello");
192/// assert_eq!(doc["provider_name"], "Example");
193/// assert_eq!(doc["author_name"], "Jane");
194/// ```
195#[must_use]
196pub fn build_oembed(
197    title: &str,
198    author: Option<&str>,
199    provider_name: Option<&str>,
200    provider_url: Option<&str>,
201) -> Value {
202    let mut obj = serde_json::Map::new();
203    let _ = obj.insert("version".to_string(), Value::String("1.0".to_string()));
204    let _ = obj.insert("type".to_string(), Value::String("link".to_string()));
205    let _ = obj.insert("title".to_string(), Value::String(title.to_string()));
206    if let Some(name) = provider_name.filter(|n| !n.is_empty()) {
207        let _ = obj.insert(
208            "provider_name".to_string(),
209            Value::String(name.to_string()),
210        );
211    }
212    if let Some(url) = provider_url.filter(|u| !u.is_empty()) {
213        let _ = obj.insert(
214            "provider_url".to_string(),
215            Value::String(url.trim_end_matches('/').to_string()),
216        );
217    }
218    if let Some(raw) = author {
219        let (name, _) = crate::agent_api::parse_author(raw);
220        if let Some(name) = name {
221            let _ = obj.insert("author_name".to_string(), Value::String(name));
222        }
223    }
224    Value::Object(obj)
225}
226
227/// Extracts the site-relative page path (`blog/post.html`) from an
228/// absolute or root-relative post URL. Pretty (directory-shaped)
229/// URLs resolve to their `index.html`.
230fn page_rel_path(url: &str) -> Option<String> {
231    let path = if let Some(scheme_end) = url.find("://") {
232        let rest = &url[scheme_end + 3..];
233        let slash = rest.find('/')?;
234        &rest[slash + 1..]
235    } else {
236        url.trim_start_matches('/')
237    };
238    if path.is_empty() {
239        None
240    } else if path.ends_with('/') {
241        Some(format!("{path}index.html"))
242    } else {
243        Some(path.to_string())
244    }
245}
246
247/// Reads the `title` field back out of an emitted oEmbed document.
248fn read_title(oembed_path: &Path) -> Option<String> {
249    let body = fs::read_to_string(oembed_path).ok()?;
250    let doc: Value = serde_json::from_str(&body).ok()?;
251    doc.get("title").and_then(Value::as_str).map(str::to_string)
252}
253
254/// Minimal HTML attribute escaping for injected markup.
255fn attr_escape(s: &str) -> String {
256    s.replace('&', "&amp;")
257        .replace('<', "&lt;")
258        .replace('"', "&quot;")
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::cmd::SsgConfig;
265    use tempfile::{tempdir, TempDir};
266
267    fn make_ctx() -> (TempDir, PluginContext) {
268        let dir = tempdir().expect("tempdir");
269        let build = dir.path().join("build");
270        let site = dir.path().join("site");
271        fs::create_dir_all(build.join(".meta")).unwrap();
272        fs::create_dir_all(&site).unwrap();
273        let cfg = SsgConfig::builder()
274            .site_name("Example".to_string())
275            .base_url("https://example.com".to_string())
276            .build()
277            .expect("config");
278        let ctx = PluginContext::with_config(
279            dir.path(),
280            &build,
281            &site,
282            dir.path(),
283            cfg,
284        );
285        (dir, ctx)
286    }
287
288    fn add_page(ctx: &PluginContext, stem: &str, meta: &str) {
289        fs::write(
290            ctx.build_dir
291                .join(".meta")
292                .join(format!("{stem}.meta.json")),
293            meta,
294        )
295        .unwrap();
296        fs::write(
297            ctx.site_dir.join(format!("{stem}.html")),
298            "<html><head><title>t</title></head><body>b</body></html>",
299        )
300        .unwrap();
301    }
302
303    #[test]
304    fn name_is_stable() {
305        assert_eq!(OembedPlugin.name(), "oembed");
306        let via_default: OembedPlugin = OembedPlugin;
307        assert_eq!(via_default.name(), "oembed");
308    }
309
310    #[test]
311    fn opts_into_transform_pass() {
312        assert!(OembedPlugin.has_transform());
313    }
314
315    #[test]
316    fn dry_run_writes_nothing() {
317        let (_tmp, ctx) = make_ctx();
318        add_page(&ctx, "p", r#"{"title":"P"}"#);
319        let ctx = ctx.with_dry_run(true);
320        OembedPlugin.after_compile(&ctx).unwrap();
321        assert!(!ctx.site_dir.join("p.oembed.json").exists());
322    }
323
324    #[test]
325    fn missing_site_dir_is_noop() {
326        let dir = tempdir().unwrap();
327        let missing = dir.path().join("nope");
328        let ctx =
329            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
330        OembedPlugin.after_compile(&ctx).unwrap();
331        assert!(!missing.exists());
332    }
333
334    #[test]
335    fn emits_sibling_document_per_public_page() {
336        let (_tmp, ctx) = make_ctx();
337        add_page(&ctx, "post", r#"{"title":"Post","author":"[email protected] (Jane)"}"#);
338        OembedPlugin.after_compile(&ctx).unwrap();
339        let body =
340            fs::read_to_string(ctx.site_dir.join("post.oembed.json")).unwrap();
341        let doc: Value = serde_json::from_str(&body).unwrap();
342        assert_eq!(doc["version"], "1.0");
343        assert_eq!(doc["type"], "link");
344        assert_eq!(doc["title"], "Post");
345        assert_eq!(doc["provider_name"], "Example");
346        assert_eq!(doc["provider_url"], "https://example.com");
347        assert_eq!(doc["author_name"], "Jane");
348        assert!(body.ends_with('\n'));
349    }
350
351    #[test]
352    fn skips_pages_without_rendered_html() {
353        let (_tmp, ctx) = make_ctx();
354        // Sidecar exists but no HTML on disk.
355        fs::write(
356            ctx.build_dir.join(".meta/ghost.meta.json"),
357            r#"{"title":"Ghost"}"#,
358        )
359        .unwrap();
360        OembedPlugin.after_compile(&ctx).unwrap();
361        assert!(!ctx.site_dir.join("ghost.oembed.json").exists());
362    }
363
364    #[test]
365    fn skips_drafts() {
366        let (_tmp, ctx) = make_ctx();
367        add_page(&ctx, "d", r#"{"title":"D","draft":true}"#);
368        OembedPlugin.after_compile(&ctx).unwrap();
369        assert!(!ctx.site_dir.join("d.oembed.json").exists());
370    }
371
372    #[test]
373    fn nested_pages_get_nested_siblings() {
374        let (_tmp, ctx) = make_ctx();
375        fs::create_dir_all(ctx.build_dir.join(".meta/blog")).unwrap();
376        fs::create_dir_all(ctx.site_dir.join("blog")).unwrap();
377        add_page(&ctx, "blog/deep", r#"{"title":"Deep"}"#);
378        OembedPlugin.after_compile(&ctx).unwrap();
379        assert!(ctx.site_dir.join("blog/deep.oembed.json").exists());
380    }
381
382    #[test]
383    fn output_is_byte_identical_across_runs() {
384        let (_tmp, ctx) = make_ctx();
385        add_page(&ctx, "p", r#"{"title":"P"}"#);
386        OembedPlugin.after_compile(&ctx).unwrap();
387        let first =
388            fs::read_to_string(ctx.site_dir.join("p.oembed.json")).unwrap();
389        OembedPlugin.after_compile(&ctx).unwrap();
390        let second =
391            fs::read_to_string(ctx.site_dir.join("p.oembed.json")).unwrap();
392        assert_eq!(first, second);
393    }
394
395    // -----------------------------------------------------------------
396    // transform_html — discovery link injection
397    // -----------------------------------------------------------------
398
399    #[test]
400    fn transform_injects_discovery_link() {
401        let (_tmp, ctx) = make_ctx();
402        add_page(&ctx, "post", r#"{"title":"Post"}"#);
403        OembedPlugin.after_compile(&ctx).unwrap();
404
405        let path = ctx.site_dir.join("post.html");
406        let html = fs::read_to_string(&path).unwrap();
407        let out = OembedPlugin.transform_html(&html, &path, &ctx).unwrap();
408        assert!(out.contains("application/json+oembed"));
409        assert!(out.contains("href=\"https://example.com/post.oembed.json\""));
410        assert!(out.contains("title=\"Post\""));
411        // Link must land inside <head>.
412        let link = out.find("json+oembed").unwrap();
413        let head_end = out.find("</head>").unwrap();
414        assert!(link < head_end);
415    }
416
417    #[test]
418    fn transform_is_idempotent() {
419        let (_tmp, ctx) = make_ctx();
420        add_page(&ctx, "post", r#"{"title":"Post"}"#);
421        OembedPlugin.after_compile(&ctx).unwrap();
422        let path = ctx.site_dir.join("post.html");
423        let html = fs::read_to_string(&path).unwrap();
424        let once = OembedPlugin.transform_html(&html, &path, &ctx).unwrap();
425        let twice = OembedPlugin.transform_html(&once, &path, &ctx).unwrap();
426        assert_eq!(once, twice);
427    }
428
429    #[test]
430    fn transform_skips_pages_without_sibling() {
431        let (_tmp, ctx) = make_ctx();
432        let path = ctx.site_dir.join("plain.html");
433        let html = "<html><head></head><body></body></html>";
434        fs::write(&path, html).unwrap();
435        let out = OembedPlugin.transform_html(html, &path, &ctx).unwrap();
436        assert_eq!(out, html);
437    }
438
439    #[test]
440    fn transform_skips_html_without_head() {
441        let (_tmp, ctx) = make_ctx();
442        add_page(&ctx, "post", r#"{"title":"Post"}"#);
443        OembedPlugin.after_compile(&ctx).unwrap();
444        let path = ctx.site_dir.join("post.html");
445        let html = "<p>fragment only</p>";
446        let out = OembedPlugin.transform_html(html, &path, &ctx).unwrap();
447        assert_eq!(out, html);
448    }
449
450    #[test]
451    fn transform_escapes_title_attribute() {
452        let (_tmp, ctx) = make_ctx();
453        add_page(&ctx, "post", r#"{"title":"A \"quoted\" & <tagged>"}"#);
454        OembedPlugin.after_compile(&ctx).unwrap();
455        let path = ctx.site_dir.join("post.html");
456        let html = fs::read_to_string(&path).unwrap();
457        let out = OembedPlugin.transform_html(&html, &path, &ctx).unwrap();
458        assert!(out.contains("A &quot;quoted&quot; &amp; &lt;tagged>"));
459    }
460
461    // -----------------------------------------------------------------
462    // helpers
463    // -----------------------------------------------------------------
464
465    #[test]
466    fn build_oembed_omits_empty_fields() {
467        let doc = build_oembed("T", None, None, None);
468        assert!(doc.get("provider_name").is_none());
469        assert!(doc.get("provider_url").is_none());
470        assert!(doc.get("author_name").is_none());
471        let doc = build_oembed("T", None, Some(""), Some(""));
472        assert!(doc.get("provider_name").is_none());
473        assert!(doc.get("provider_url").is_none());
474    }
475
476    #[test]
477    fn page_rel_path_handles_absolute_and_relative() {
478        assert_eq!(
479            page_rel_path("https://x.example/blog/a.html").as_deref(),
480            Some("blog/a.html")
481        );
482        assert_eq!(page_rel_path("/a.html").as_deref(), Some("a.html"));
483        assert_eq!(
484            page_rel_path("https://x.example/contact/").as_deref(),
485            Some("contact/index.html"),
486            "pretty URLs resolve to their index.html"
487        );
488        assert_eq!(page_rel_path("https://x.example/"), None);
489        assert_eq!(page_rel_path("/"), None);
490    }
491
492    #[test]
493    fn attr_escape_covers_specials() {
494        assert_eq!(attr_escape(r#"a&"<"#), "a&amp;&quot;&lt;");
495    }
496
497    #[test]
498    fn read_title_missing_or_invalid_is_none() {
499        let dir = tempdir().unwrap();
500        assert!(read_title(&dir.path().join("nope.json")).is_none());
501        let bad = dir.path().join("bad.json");
502        fs::write(&bad, "not json").unwrap();
503        assert!(read_title(&bad).is_none());
504    }
505
506    #[test]
507    fn read_title_valid_json_without_title_field_is_none() {
508        // Valid JSON that parses fine but has no "title" key: the
509        // `doc.get("title")` lookup itself must return `None` (as
510        // opposed to the parse-failure path already covered above).
511        let dir = tempdir().unwrap();
512        let f = dir.path().join("no_title.json");
513        fs::write(&f, r#"{"version":"1.0","type":"link"}"#).unwrap();
514        assert!(read_title(&f).is_none());
515    }
516
517    #[test]
518    fn page_rel_path_none_when_scheme_url_has_no_path() {
519        // Scheme present but no `/` after the host at all.
520        assert_eq!(page_rel_path("https://example.com"), None);
521    }
522
523    #[test]
524    fn build_oembed_omits_author_when_name_unparseable() {
525        // `<email>` form yields no display name, so author_name is
526        // absent.
527        let doc = build_oembed("T", Some("<[email protected]>"), None, None);
528        assert!(doc.get("author_name").is_none());
529    }
530
531    #[test]
532    fn after_compile_fails_when_oembed_path_squatted_by_dir() {
533        let (_tmp, ctx) = make_ctx();
534        add_page(&ctx, "p", r#"{"title":"P"}"#);
535        fs::create_dir_all(ctx.site_dir.join("p.oembed.json")).unwrap();
536        let err = OembedPlugin.after_compile(&ctx).unwrap_err();
537        assert!(!format!("{err}").is_empty());
538    }
539
540    #[test]
541    fn transform_html_without_config_uses_root_relative_href() {
542        let dir = tempdir().unwrap();
543        let site = dir.path().join("site");
544        fs::create_dir_all(&site).unwrap();
545        fs::write(site.join("p.oembed.json"), r#"{"title":"P"}"#).unwrap();
546        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
547
548        let html = "<html><head></head><body>x</body></html>";
549        let out = OembedPlugin
550            .transform_html(html, &site.join("p.html"), &ctx)
551            .unwrap();
552        assert!(out.contains("href=\"/p.oembed.json\""));
553    }
554
555    #[test]
556    fn transform_html_path_outside_site_dir_uses_full_path_as_rel() {
557        // `path.strip_prefix(&ctx.site_dir).unwrap_or(path)` — when the
558        // page path isn't actually nested under `ctx.site_dir`,
559        // `strip_prefix` fails and the fallback returns `path`
560        // unchanged. This is otherwise never exercised because every
561        // other test's page path lives under the context's site_dir.
562        let (_tmp, ctx) = make_ctx();
563        let elsewhere = tempdir().unwrap();
564        let path = elsewhere.path().join("post.html");
565        let sibling = path.with_extension("oembed.json");
566        fs::write(&sibling, r#"{"title":"Elsewhere"}"#).unwrap();
567
568        let html = "<html><head></head><body>x</body></html>";
569        let out = OembedPlugin.transform_html(html, &path, &ctx).unwrap();
570        assert!(out.contains("application/json+oembed"));
571        assert!(out.contains("title=\"Elsewhere\""));
572    }
573}