1use crate::error::{PathErrorExt, SsgError};
61use crate::plugin::{Plugin, PluginContext};
62use serde_json::Value;
63use std::fs;
64use std::path::Path;
65
66#[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 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 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 Ok(crate::util::head_dom::inject_before_head_close(html, &link))
174 }
175}
176
177#[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
227fn 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
247fn 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
254fn attr_escape(s: &str) -> String {
256 s.replace('&', "&")
257 .replace('<', "<")
258 .replace('"', """)
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 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 #[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 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 "quoted" & <tagged>"));
459 }
460
461 #[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&"<");
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 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 assert_eq!(page_rel_path("https://example.com"), None);
521 }
522
523 #[test]
524 fn build_oembed_omits_author_when_name_unparseable() {
525 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 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}