moss_core/resolve/embed_renderer.rs
1//! Renderer registry for `![[file]]` embeds.
2//!
3//! Each renderer maps a file extension (or extension family) to an output
4//! format. The caller resolves the embed target via the ContentGraph, then
5//! dispatches to the renderer for the target's extension. Unknown extensions
6//! fall back to a file link (Obsidian parity) — that fallback lives in the
7//! caller, not here.
8//!
9//! # moss-core ↔ src-tauri boundary
10//!
11//! moss-core is pure: no filesystem, no network, no async. This constrains
12//! what a renderer can do:
13//!
14//! - **Pure renderers** (image, iframe, audio, video, 3D, table) — return
15//! `RenderedEmbed::Inline(markdown)` or `RenderedEmbed::Html(html)`. No I/O.
16//! The string is spliced directly into the compiled output.
17//! - **I/O-bound renderers** (markdown transclusion, notebook, PDF preview) —
18//! return `RenderedEmbed::Deferred { marker }`. src-tauri runs a post-pass
19//! (`resolve_embeds` in `embeds.rs`) that reads the target file and splices
20//! its rendered content into the marker.
21//!
22//! Plugin-registered renderers (Phase E) must follow the same rule: if they
23//! need I/O, they emit a marker and register a corresponding resolver on the
24//! src-tauri side.
25
26use std::sync::OnceLock;
27
28mod common;
29pub mod folder_list;
30use common::path_extension_lower;
31
32// Re-export the canonical 4-char attribute escaper so src-tauri synthesizers
33// (pdf / iframe / model / audio / video) can share one definition instead of
34// inlining private copies that drifted apart (moss-core's was 4 chars; some
35// synthesizers via `moss_core::media::html_escape` was 5 chars including
36// `'` → `'`). The 4-char form is correct per HTML5: apostrophe is safe
37// inside `"…"` attributes.
38pub use common::{file_stem, html_escape_attr};
39
40// ---------------------------------------------------------------------------
41// Reserved classnames (HTML/CSS contract, per moss#508)
42// ---------------------------------------------------------------------------
43
44/// Base class applied to all typed-embed output elements.
45///
46/// Theme authors may target `.moss-embed` to style the wrapper of any embed;
47/// renderer-specific classes (e.g. [`CLASS_EMBED_IFRAME`]) extend the base.
48/// The CSS that ships with moss is defined in src-tauri (see issue #508 for
49/// the HTML/CSS contract).
50pub const CLASS_EMBED: &str = "moss-embed";
51
52/// Applied to iframe renderer output (Phase B).
53pub const CLASS_EMBED_IFRAME: &str = "moss-embed-iframe";
54
55/// Applied to PDF renderer output (Phase C).
56pub const CLASS_EMBED_PDF: &str = "moss-embed-pdf";
57
58/// Applied to audio renderer output (Phase C).
59pub const CLASS_EMBED_AUDIO: &str = "moss-embed-audio";
60
61/// Applied to video renderer output (Phase C).
62pub const CLASS_EMBED_VIDEO: &str = "moss-embed-video";
63
64/// Applied to notebook renderer output (Phase D).
65pub const CLASS_EMBED_NOTEBOOK: &str = "moss-embed-notebook";
66
67/// Applied to 3D model renderer output (Phase D).
68pub const CLASS_EMBED_3D: &str = "moss-embed-3d";
69
70/// Applied to tabular-data renderer output (Phase D).
71pub const CLASS_EMBED_TABLE: &str = "moss-embed-table";
72
73// ---------------------------------------------------------------------------
74// Deferred-marker prefixes (contract with src-tauri resolvers)
75// ---------------------------------------------------------------------------
76
77/// Marker prefix emitted by [`MarkdownEmbedRenderer`].
78///
79/// Format: `<!-- moss-embed:PATH[#anchor] -->`. Resolved by src-tauri's
80/// `resolve_embeds` (inlines target markdown content).
81///
82/// No `-<type>` suffix for historical reasons: this was the original embed
83/// marker before typed embeds existed. New typed markers use
84/// `moss-embed-<type>:` (see [`MARKER_IPYNB`], [`MARKER_TABLE`]).
85pub const MARKER_MARKDOWN: &str = "moss-embed";
86
87/// Marker prefix emitted by [`NotebookRenderer`].
88///
89/// Format: `<!-- moss-embed-ipynb:PATH[?query] -->`. Resolved by src-tauri
90/// via nbconvert.
91pub const MARKER_IPYNB: &str = "moss-embed-ipynb";
92
93/// Marker prefix emitted by [`TableRenderer`].
94///
95/// Format: `<!-- moss-embed-table:PATH -->`. src-tauri reads the file and
96/// calls [`crate::csv_table::render`] (a pure renderer).
97pub const MARKER_TABLE: &str = "moss-embed-table";
98
99// Re-export folder_list marker constants for convenience.
100pub use folder_list::{MARKER_END, MARKER_FOLDER_LIST};
101
102/// An embed that has been parsed and path-resolved, ready for rendering.
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub struct ParsedEmbed<'a> {
105 /// Resolved target path, as returned by the ContentGraph.
106 pub resolved_path: &'a str,
107 /// The calling file's path (for computing relative asset URLs).
108 pub from_path: &'a str,
109 /// `?query` from the source wikilink, without the leading `?`.
110 pub query: Option<&'a str>,
111 /// `#fragment` from the source wikilink, without the leading `#`.
112 /// For `.md` renderers this is a heading/block-ref marker (block refs
113 /// keep their `^` prefix). For every other renderer this is a URL fragment.
114 pub section: Option<&'a str>,
115 /// `|pipe-content` from the source wikilink — with any spec § P9 width
116 /// token already split out into [`Self::width`]. Image renderer uses
117 /// this for display keywords / size; other renderers parse per their
118 /// convention.
119 pub alias: Option<&'a str>,
120 /// Canonical width value (`body | wide | page | screen`) extracted from
121 /// the pipe-alias by the wikilink resolver. `None` means the author
122 /// did not include a width token; renderers omit `data-width` in that
123 /// case so themes can target the default via `:not([data-width])`.
124 ///
125 /// `full` is normalised to `screen` upstream — values reaching here
126 /// are already in value-space terms (see
127 /// [`crate::media::match_width_token`]).
128 pub width: Option<&'static str>,
129 /// Trailing Pandoc `{.class key=value}` attribute block, if present.
130 ///
131 /// Per Decision #8 of the unified-image-emission architecture, Pandoc-
132 /// style attribute blocks are the canonical author surface for moss-
133 /// vocabulary attributes; the pipe-keyword form remains as compat sugar.
134 /// When both are present, the attribute block wins on typed-field
135 /// conflicts (Decision #11); class lists union+dedupe.
136 pub attrs: Option<crate::ast::attrs::AttrBlock>,
137}
138
139/// Output of a renderer.
140///
141/// The variant tells the caller what further processing (if any) the string
142/// needs. See the module-level doc for the moss-core ↔ src-tauri boundary rule.
143#[derive(Debug, PartialEq, Eq)]
144pub enum RenderedEmbed {
145 /// Markdown-level text that will be processed by CommonMark downstream.
146 /// Example: `` from the image renderer.
147 Inline(String),
148 /// Final HTML to splice into the output — must NOT be re-processed by the
149 /// markdown parser. Example: `<iframe …>` from the iframe renderer.
150 Html(String),
151 /// A marker comment for a post-pass resolver to expand with file I/O.
152 ///
153 /// Format convention: `<!-- <prefix>:<target> -->` where `<prefix>`
154 /// uniquely identifies the resolver (e.g. `moss-embed-ipynb`,
155 /// `moss-embed-table`, `moss-embed-plugin-<plugin-name>`) and
156 /// `<target>` is the body the resolver parses (commonly a path,
157 /// optionally with `?query#fragment|alias`).
158 ///
159 /// The resolver lives in src-tauri (where async and I/O are allowed).
160 /// Built-in prefixes are exported as pub const: [`MARKER_MARKDOWN`],
161 /// [`MARKER_IPYNB`], [`MARKER_TABLE`]. Plugin-registered renderers
162 /// emit `moss-embed-plugin-<plugin-name>:` — see
163 /// [`super::registry`] for the full two-pass dispatch design.
164 Deferred { marker: String },
165}
166
167/// A single dimension with a unit.
168#[cfg_attr(feature = "specta", derive(specta::Type))]
169#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
170pub enum Dim {
171 Px(u32),
172 Percent(f32),
173 Vh(f32),
174}
175
176impl Dim {
177 /// Render this dimension as a CSS length string.
178 pub fn to_css(self) -> String {
179 match self {
180 Dim::Px(n) => format!("{}px", n),
181 Dim::Percent(v) => {
182 if v.fract() == 0.0 {
183 format!("{}%", v as i64)
184 } else {
185 format!("{}%", v)
186 }
187 }
188 Dim::Vh(v) => {
189 if v.fract() == 0.0 {
190 format!("{}vh", v as i64)
191 } else {
192 format!("{}vh", v)
193 }
194 }
195 }
196 }
197
198 /// Parse one dimension. Accepts: `200`, `200px`, `100%`, `80vh`.
199 /// Returns None on any parse failure.
200 fn parse(s: &str) -> Option<Self> {
201 let s = s.trim();
202 if s.is_empty() {
203 return None;
204 }
205 if let Some(rest) = s.strip_suffix('%') {
206 return rest.trim().parse::<f32>().ok().map(Dim::Percent);
207 }
208 if let Some(rest) = s.strip_suffix("vh") {
209 return rest.trim().parse::<f32>().ok().map(Dim::Vh);
210 }
211 if let Some(rest) = s.strip_suffix("px") {
212 return rest.trim().parse::<u32>().ok().map(Dim::Px);
213 }
214 s.parse::<u32>().ok().map(Dim::Px)
215 }
216}
217
218/// Parsed `|WxH` sizing hint from a wikilink pipe segment.
219#[cfg_attr(feature = "specta", derive(specta::Type))]
220#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
221pub enum Sizing {
222 /// `|200` or `|100%` — width only.
223 Width(Dim),
224 /// `|200x150` or `|100%x600` — width × height.
225 Box(Dim, Dim),
226}
227
228impl Sizing {
229 /// Parse a pipe segment. Returns None if the string does not look like a
230 /// sizing hint — callers can then fall through to their own parser
231 /// (e.g. image display keywords).
232 pub fn parse(s: &str) -> Option<Self> {
233 let s = s.trim();
234 if s.is_empty() {
235 return None;
236 }
237 if let Some((w, h)) = s.split_once('x') {
238 let wd = Dim::parse(w)?;
239 let hd = Dim::parse(h)?;
240 return Some(Sizing::Box(wd, hd));
241 }
242 Dim::parse(s).map(Sizing::Width)
243 }
244}
245
246/// A renderer converts a `ParsedEmbed` into its rendered form.
247pub trait EmbedRenderer: std::fmt::Debug + Send + Sync {
248 /// Extensions this renderer claims (lowercase, without leading dot).
249 fn extensions(&self) -> &[&'static str];
250
251 /// Render the embed. Must be pure; moss-core is I/O-free.
252 fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed;
253
254 /// Page-level HTML fragments this renderer needs in `<head>`, injected
255 /// once per page that contains at least one embed from this renderer.
256 ///
257 /// Example: `ModelViewerRenderer` returns a `<script type="module">` tag
258 /// so that `<model-viewer>` custom elements work. The build pipeline
259 /// collects and deduplicates these across all embeds on a page.
260 ///
261 /// Default: empty. Renderers with no page-level assets don't override.
262 fn head_assets(&self) -> &[&'static str] {
263 &[]
264 }
265}
266
267/// Built-in renderer registry. Initialized lazily on first lookup.
268///
269/// Each renderer is a unit struct, so the pointer is to a zero-size `'static`
270/// — no heap allocation ever. Future renderers (notebook, 3d, table, plugins)
271/// get appended here as they ship.
272///
273/// Extension sets across renderers are currently disjoint. Adding overlap
274/// (e.g., if a future renderer claims `.ogg` for video) would require
275/// tie-break logic here; first-match-wins is the only implicit rule today.
276fn registry() -> &'static [&'static dyn EmbedRenderer] {
277 static INIT: OnceLock<Vec<&'static dyn EmbedRenderer>> = OnceLock::new();
278 INIT.get_or_init(|| {
279 vec![
280 &MarkdownEmbedRenderer as &'static dyn EmbedRenderer,
281 &IframeRenderer as &'static dyn EmbedRenderer,
282 &PdfRenderer as &'static dyn EmbedRenderer,
283 &AudioRenderer as &'static dyn EmbedRenderer,
284 &VideoRenderer as &'static dyn EmbedRenderer,
285 &NotebookRenderer as &'static dyn EmbedRenderer,
286 &ModelViewerRenderer as &'static dyn EmbedRenderer,
287 &TableRenderer as &'static dyn EmbedRenderer,
288 ]
289 })
290}
291
292/// Look up a renderer by file extension (case-insensitive, no leading dot).
293pub fn lookup_renderer(ext: &str) -> Option<&'static dyn EmbedRenderer> {
294 if ext.is_empty() {
295 return None;
296 }
297 registry()
298 .iter()
299 .copied()
300 .find(|r| r.extensions().iter().any(|e| e.eq_ignore_ascii_case(ext)))
301}
302
303// ---------------------------------------------------------------------------
304// ImageRenderer
305// ---------------------------------------------------------------------------
306
307use crate::heading_anchor::obsidian_heading_anchor;
308use crate::media::parse_media_attrs;
309
310use super::fuzzy_path::relative_asset_path;
311use super::title_params::TitleParams;
312
313/// Image file extensions recognized by `ImageRenderer`.
314pub(crate) const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "svg", "webp", "avif"];
315
316/// Map an `AlignSide` to its canonical title-param keyword (`"left"` or
317/// `"right"`). Stage 2 reverses this via `AlignSide::from_keyword`-style
318/// recognition (it accepts both `left` and `align-left`).
319fn align_keyword(side: crate::media::AlignSide) -> &'static str {
320 match side {
321 crate::media::AlignSide::Left => "left",
322 crate::media::AlignSide::Right => "right",
323 }
324}
325
326/// Push `class` into `acc` only if not already present. Used when merging
327/// pipe-alias passthrough classes with Pandoc attribute-block classes
328/// (Decision #11: class lists union and dedupe).
329fn add_class_dedup(acc: &mut Vec<String>, class: &str) {
330 if !acc.iter().any(|c| c == class) {
331 acc.push(class.to_string());
332 }
333}
334
335/// Shared attribute-block fold for non-image renderers (iframe, pdf, audio,
336/// video, 3D). Mirrors the ImageRenderer logic at a smaller scope: extract
337/// recognized vocabulary (align) into typed params; pass through everything
338/// else as `classes` + extra key=value attrs.
339///
340/// Lives here so all `render_link_markdown` consumers stay in lockstep when
341/// Decision #11 (attribute-block-wins, class lists union+dedupe) evolves.
342///
343/// `pub(super)` for sibling-module use within `resolve/`. Originally exposed
344/// so the Stage 1 native-markdown sweep (`wikilinks::stage1_sweep`) could
345/// fold trailing `{...}` attribute blocks into native-image rewrites;
346/// that sweep retired in Phase 3 PR2, but the visibility stays the same
347/// shape so plugin-side callers can still reach it.
348pub(super) fn fold_attrs_into_params(
349 block: &crate::ast::attrs::AttrBlock,
350 params: &mut TitleParams,
351) {
352 let mut classes: Vec<String> = Vec::new();
353 let mut consumed_class_kv = false;
354
355 for class in &block.classes {
356 if let Some(side) = crate::media::AlignSide::from_keyword(class) {
357 params.insert("align", align_keyword(side));
358 } else {
359 add_class_dedup(&mut classes, class);
360 }
361 }
362
363 if let Some(w) = block.width {
364 // Non-image renderers expose width on the wrapper as `data-width`
365 // (see `render_link_markdown`), matching the pipe-alias path.
366 params.insert("data-width", w);
367 }
368
369 for (k, v) in &block.kvs {
370 if k == "class" {
371 consumed_class_kv = true;
372 for c in v.split_whitespace() {
373 if let Some(side) = crate::media::AlignSide::from_keyword(c) {
374 params.insert("align", align_keyword(side));
375 } else {
376 add_class_dedup(&mut classes, c);
377 }
378 }
379 }
380 }
381 if !classes.is_empty() {
382 params.insert("classes", classes.join(" "));
383 }
384 for (k, v) in &block.kvs {
385 if consumed_class_kv && k == "class" {
386 continue;
387 }
388 params.insert(k.clone(), v.clone());
389 }
390}
391
392/// Escape alt text for markdown `` syntax.
393///
394/// Brackets MUST be escaped; HTML entities are NOT needed (pulldown-cmark
395/// handles `<` `>` `&` per CommonMark rules when alt text is rendered).
396fn markdown_escape_alt(s: &str) -> String {
397 s.replace('\\', "\\\\")
398 .replace('[', "\\[")
399 .replace(']', "\\]")
400}
401
402/// Shared Stage 1 emitter for the five non-image renderers (iframe, pdf,
403/// audio, video, 3D). Produces a CommonMark link:
404///
405/// ```text
406/// [filename](url "moss:kind=<kind> <params>")
407/// ```
408///
409/// pulldown-cmark parses this as `Tag::Link`; Phase 1's Stage 2 dispatcher
410/// keys off `moss:kind=` to choose the right HTML synthesizer.
411///
412/// `kind` is the canonical kind name (`iframe`, `pdf`, `audio`, `video`,
413/// `3d`). `extra` is invoked to inject renderer-specific params; the helper
414/// pre-fills `kind=` so callers only handle their own grammar.
415fn render_link_markdown(
416 embed: &ParsedEmbed<'_>,
417 kind: &'static str,
418 extra: impl FnOnce(&ParsedEmbed<'_>, &mut TitleParams),
419) -> String {
420 let url = relative_asset_path(embed.from_path, embed.resolved_path);
421 // Phase 3 PR4 (2026-05-27): the `moss:kind=…` title channel retired.
422 // The accumulated params (kind / data-width / extras / attribute
423 // block) are no longer round-tripped through a markdown title —
424 // `parse_title` is gone, and `render_inline_md_for_dispatch` was
425 // already discarding the title in its Tag::Link arm. We keep the
426 // `params` accumulation as a no-op so the `extra` and
427 // `fold_attrs_into_params` plumbing stays exercised (tests still
428 // call through). Future PRs threading typed params into iframe /
429 // pdf / video / audio / 3D wikilink dispatch should bypass this
430 // markdown round-trip entirely — `EmitKind::Inline` is the wrong
431 // channel for typed structural data.
432 let mut params = TitleParams::default();
433 params.insert("kind", kind);
434 if let Some(w) = embed.width {
435 params.insert("data-width", w);
436 }
437 extra(embed, &mut params);
438 if let Some(block) = &embed.attrs {
439 fold_attrs_into_params(block, &mut params);
440 }
441 let _ = params;
442 let name = file_stem(embed.resolved_path);
443 format!("[{}]({})", markdown_escape_alt(&name), url)
444}
445
446// file_stem now lives in common.rs — imported via common::file_stem below.
447
448// ---------------------------------------------------------------------------
449// MarkdownEmbedRenderer
450// ---------------------------------------------------------------------------
451
452/// Renderer for markdown transclusion: `![[file.md]]` → `<!-- moss-embed:path -->`.
453///
454/// The marker comment is resolved later by src-tauri's embed resolver, which
455/// reads the target file's content and splices it inline. This renderer does
456/// not perform I/O.
457#[derive(Debug)]
458pub struct MarkdownEmbedRenderer;
459
460impl EmbedRenderer for MarkdownEmbedRenderer {
461 fn extensions(&self) -> &[&'static str] {
462 &["md"]
463 }
464
465 fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
466 let anchor = build_embed_anchor(embed.section);
467 RenderedEmbed::Deferred {
468 marker: format!(
469 "<!-- {}:{}{} -->",
470 MARKER_MARKDOWN, embed.resolved_path, anchor
471 ),
472 }
473 }
474}
475
476/// Build the anchor fragment for a markdown embed marker.
477///
478/// Preserves the `^` prefix on block references so the downstream embed
479/// resolver can distinguish them from headings.
480fn build_embed_anchor(section: Option<&str>) -> String {
481 match section {
482 None => String::new(),
483 Some(s) if s.is_empty() => String::new(),
484 Some(s) => {
485 if s.starts_with('^') {
486 format!("#{}", s)
487 } else {
488 format!("#{}", obsidian_heading_anchor(s))
489 }
490 }
491 }
492}
493
494// ---------------------------------------------------------------------------
495// IframeRenderer
496// ---------------------------------------------------------------------------
497
498/// Renderer for local HTML embeds: `![[file.html?query#frag|WxH]]` → `<iframe>`.
499///
500/// - `?query` is appended to the iframe `src` as URL query.
501/// - `#fragment` is appended as URL fragment (order: path?query#frag).
502/// - `|W` or `|WxH` becomes iframe width/height attributes via [`Sizing`].
503/// - No sandbox attribute is set by default — noted as a follow-up.
504#[derive(Debug)]
505pub struct IframeRenderer;
506
507impl EmbedRenderer for IframeRenderer {
508 fn extensions(&self) -> &[&'static str] {
509 &["html", "htm"]
510 }
511
512 fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
513 RenderedEmbed::Inline(render_link_markdown(embed, "iframe", iframe_extra_params))
514 }
515}
516
517/// Iframe-specific param extraction:
518///
519/// - `?query` and `#fragment` from the wikilink fold into a `src` param so
520/// Stage 2 can reconstruct the URL it serves on the iframe element. They
521/// are NOT re-inserted into the markdown URL slot because pulldown-cmark
522/// would percent-encode them, which would break iframe `src` semantics
523/// downstream.
524/// - `|WxH` sizing in alias becomes `width=`/`height=` params.
525/// - Non-sizing alias text becomes the `title=` param (used today as
526/// accessible name / tooltip on the iframe).
527fn iframe_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
528 if let Some(q) = embed.query {
529 params.insert("query", q);
530 }
531 if let Some(f) = embed.section {
532 params.insert("fragment", f);
533 }
534 let Some(alias) = embed.alias else {
535 return;
536 };
537 match Sizing::parse(alias) {
538 Some(Sizing::Width(w)) => {
539 params.insert("width", w.to_css());
540 }
541 Some(Sizing::Box(w, h)) => {
542 params.insert("width", w.to_css());
543 params.insert("height", h.to_css());
544 }
545 None => {
546 // Non-sizing text alias → iframe title.
547 params.insert("title", alias);
548 }
549 }
550}
551
552// build_src, dim_attrs, html_escape_attr now live in common.rs — imported above.
553
554// ---------------------------------------------------------------------------
555// PdfRenderer
556// ---------------------------------------------------------------------------
557
558/// Renderer for PDF embeds: `![[report.pdf]]` → `<object type="application/pdf">`.
559///
560/// `<object>` has better keyboard navigation than `<iframe>` for PDFs and
561/// supports inline fallback content for browsers that can't render PDFs natively.
562#[derive(Debug)]
563pub struct PdfRenderer;
564
565impl EmbedRenderer for PdfRenderer {
566 fn extensions(&self) -> &[&'static str] {
567 &["pdf"]
568 }
569
570 fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
571 RenderedEmbed::Inline(render_link_markdown(embed, "pdf", pdf_extra_params))
572 }
573}
574
575/// PDF-specific params: viewer fragment (`#page=5`), sizing.
576fn pdf_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
577 if let Some(q) = embed.query {
578 params.insert("query", q);
579 }
580 if let Some(f) = embed.section {
581 params.insert("fragment", f);
582 }
583 if let Some(alias) = embed.alias {
584 match Sizing::parse(alias) {
585 Some(Sizing::Width(w)) => {
586 params.insert("width", w.to_css());
587 }
588 Some(Sizing::Box(w, h)) => {
589 params.insert("width", w.to_css());
590 params.insert("height", h.to_css());
591 }
592 None => {}
593 }
594 }
595}
596
597// ---------------------------------------------------------------------------
598// AudioRenderer
599// ---------------------------------------------------------------------------
600
601const AUDIO_EXTENSIONS: &[&str] = &["mp3", "wav", "ogg", "flac", "m4a", "opus", "aac"];
602
603/// Renderer for audio embeds: `![[song.mp3]]` → `<audio controls>`.
604///
605/// `preload=metadata` so the browser fetches duration/sample-rate but not the
606/// full payload until the user presses play.
607///
608/// Output form: `<audio><source src="..." type="..."></audio>` (HTML5
609/// multi-source). This is safe today because no audio extension rewriter
610/// exists in src-tauri — audio files pass through unchanged. If a future
611/// converter is introduced (e.g., `.flac→.mp3` for size, `.m4a→.opus` for
612/// browser parity, see #504), this renderer must switch to the single
613/// `src=` form for the same reason `VideoRenderer` did: the
614/// `add_*_placeholder_attributes` regex pattern in
615/// `src-tauri/src/build/media/placeholder.rs` matches `<tag\s+[^>]*?src=>`,
616/// not nested `<source>` children. See #593 and the docstring on
617/// `VideoRenderer` for the full failure mode.
618#[derive(Debug)]
619pub struct AudioRenderer;
620
621impl EmbedRenderer for AudioRenderer {
622 fn extensions(&self) -> &[&'static str] {
623 AUDIO_EXTENSIONS
624 }
625
626 fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
627 RenderedEmbed::Inline(render_link_markdown(embed, "audio", audio_extra_params))
628 }
629}
630
631/// Audio-specific params: source extension (for downstream MIME selection).
632/// The historical author grammar exposed no per-embed audio flags, so today
633/// the only extra param is the file extension. Future flags (`controls`,
634/// `loop`, `autoplay`, `muted`) extend here.
635fn audio_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
636 let ext = path_extension_lower(embed.resolved_path);
637 if !ext.is_empty() {
638 params.insert("ext", ext);
639 }
640}
641
642// MIME-type selection for audio embeds now lives downstream (Phase 1
643// Stage 2 picks the MIME from the `ext=` title param when synthesizing the
644// `<audio><source>` HTML). moss-core's Stage 1 emits the file extension
645// directly via `audio_extra_params`; the legacy `audio_mime_for_ext` helper
646// is no longer needed here.
647
648// ---------------------------------------------------------------------------
649// VideoRenderer
650// ---------------------------------------------------------------------------
651
652const VIDEO_EXTENSIONS: &[&str] = &["mp4", "webm", "mov", "m4v"];
653
654/// Renderer for video embeds: `![[clip.mp4]]` → `<video src="..." controls>`.
655///
656/// `|WxH` becomes width/height attrs. `preload=metadata` so the browser
657/// fetches duration/dimensions but not the full payload until play.
658///
659/// Output form: single `src=` attribute on `<video>` (no `<source>` child),
660/// no `type=` attribute. Two coupled reasons:
661///
662/// 1. **`type=` would go stale.** The downstream rewriter
663/// `src-tauri/src/build/media/placeholder.rs::add_video_placeholder_attributes`
664/// rewrites the `src` extension from `.mov` to `.mp4` after the renderer
665/// runs (moss converts `.mov` source files to `.mp4` during build, so a
666/// raw `.mov` reference would 404). Any explicit `type="video/quicktime"`
667/// emitted here would survive the rewrite as a lie. Browser sniffing
668/// from the rewritten URL extension is more reliable than a stale type
669/// hint.
670///
671/// 2. **The rewriter regex requires single-`src=` form.** It matches
672/// `<video\s+[^>]*?src="...">` — `src=` must be on the `<video>` tag
673/// itself, not on a nested `<source>` child. With nested `<source>`,
674/// the regex no-ops and the `.mov→.mp4` rewrite + `data-placeholder-src`
675/// + `poster` + `data-thumb-src` injection all silently drop. This
676/// constraint is load-bearing; see #592 for an integration test that
677/// pins it across the cross-crate boundary, and #593 for the audio
678/// asymmetry. This shape also matches the historical moss output that
679/// liu-guo.com still ships.
680///
681/// Note: `.mov` is codec-dependent at the source. Safari plays QuickTime
682/// natively; Chrome/Firefox accept the MIME but decode only if the
683/// container's video codec is supported (usually H.264). The `.mov→.mp4`
684/// rewriter solves this in practice — served files end as `.mp4`.
685#[derive(Debug)]
686pub struct VideoRenderer;
687
688impl EmbedRenderer for VideoRenderer {
689 fn extensions(&self) -> &[&'static str] {
690 VIDEO_EXTENSIONS
691 }
692
693 fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
694 RenderedEmbed::Inline(render_link_markdown(embed, "video", video_extra_params))
695 }
696}
697
698/// Video-specific params: sizing from alias. Author flags (controls, loop,
699/// autoplay, muted, poster) extend here when wired up.
700fn video_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
701 if let Some(alias) = embed.alias {
702 match Sizing::parse(alias) {
703 Some(Sizing::Width(w)) => {
704 params.insert("width", w.to_css());
705 }
706 Some(Sizing::Box(w, h)) => {
707 params.insert("width", w.to_css());
708 params.insert("height", h.to_css());
709 }
710 None => {}
711 }
712 }
713}
714
715// ---------------------------------------------------------------------------
716// NotebookRenderer
717// ---------------------------------------------------------------------------
718
719/// Renderer for Jupyter notebooks: `![[file.ipynb]]` → deferred marker.
720///
721/// Emits `<!-- moss-embed-ipynb:PATH -->` (with optional `?query` appended).
722/// The real rendering happens in src-tauri via nbconvert or equivalent —
723/// src-tauri resolves the marker post-pass.
724#[derive(Debug)]
725pub struct NotebookRenderer;
726
727impl EmbedRenderer for NotebookRenderer {
728 fn extensions(&self) -> &[&'static str] {
729 &["ipynb"]
730 }
731
732 fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
733 // NOTE: embed.width is intentionally dropped at the marker boundary.
734 // Notebook wrappers are emitted in src-tauri post-passes that don't
735 // currently read width from the marker target. Track when those
736 // switch to data-width emission — file a follow-up issue if needed.
737 let target = match embed.query {
738 Some(q) => format!("{}?{}", embed.resolved_path, q),
739 None => embed.resolved_path.to_string(),
740 };
741 RenderedEmbed::Deferred {
742 marker: format!("<!-- {}:{} -->", MARKER_IPYNB, target),
743 }
744 }
745}
746
747// ---------------------------------------------------------------------------
748// ModelViewerRenderer (3D)
749// ---------------------------------------------------------------------------
750
751/// Page-level script import needed for `<model-viewer>` to work.
752///
753/// Loaded from Google's CDN. Pinned to a major version for stability.
754/// If this URL becomes unavailable, self-host and update this constant.
755const MODEL_VIEWER_SCRIPT: &str = "<script type=\"module\" src=\"https://ajax.googleapis.com/ajax/libs/model-viewer/3.4.0/model-viewer.min.js\"></script>";
756
757/// Renderer for 3D model embeds: `![[model.glb|400x400]]` → `<model-viewer>`.
758///
759/// Requires the `<model-viewer>` custom element script, injected via
760/// `head_assets` once per page that contains any `.glb`/`.gltf` embed.
761#[derive(Debug)]
762pub struct ModelViewerRenderer;
763
764impl EmbedRenderer for ModelViewerRenderer {
765 fn extensions(&self) -> &[&'static str] {
766 &["glb", "gltf"]
767 }
768
769 fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
770 RenderedEmbed::Inline(render_link_markdown(embed, "3d", model_viewer_extra_params))
771 }
772
773 fn head_assets(&self) -> &[&'static str] {
774 &[MODEL_VIEWER_SCRIPT]
775 }
776}
777
778/// 3D-viewer-specific params: sizing from alias. Author flags (`auto-rotate`,
779/// `camera-controls`, `ar`) extend here when surfaced in the wikilink grammar.
780fn model_viewer_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
781 if let Some(alias) = embed.alias {
782 match Sizing::parse(alias) {
783 Some(Sizing::Width(w)) => {
784 params.insert("width", w.to_css());
785 }
786 Some(Sizing::Box(w, h)) => {
787 params.insert("width", w.to_css());
788 params.insert("height", h.to_css());
789 }
790 None => {}
791 }
792 }
793}
794
795// ---------------------------------------------------------------------------
796// TableRenderer
797// ---------------------------------------------------------------------------
798
799/// Renderer for tabular data: `![[data.csv]]` → deferred marker.
800///
801/// Emits `<!-- moss-embed-table:PATH -->`. src-tauri reads the CSV/TSV file
802/// and calls `moss_core::csv_table::render` (a pure renderer) in a post-pass.
803#[derive(Debug)]
804pub struct TableRenderer;
805
806impl EmbedRenderer for TableRenderer {
807 fn extensions(&self) -> &[&'static str] {
808 &["csv", "tsv"]
809 }
810
811 fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
812 // NOTE: embed.width is intentionally dropped at the marker boundary.
813 // Table wrappers are emitted in src-tauri post-passes (csv_table) that
814 // don't currently read width from the marker target. Track when those
815 // switch to data-width emission — file a follow-up issue if needed.
816 RenderedEmbed::Deferred {
817 marker: format!("<!-- {}:{} -->", MARKER_TABLE, embed.resolved_path),
818 }
819 }
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825
826 #[derive(Debug)]
827 struct DummyRenderer;
828 impl EmbedRenderer for DummyRenderer {
829 fn extensions(&self) -> &[&'static str] {
830 &["xyz"]
831 }
832 fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
833 RenderedEmbed::Inline(format!("<dummy src={}>", embed.resolved_path))
834 }
835 }
836
837 #[test]
838 fn test_dummy_renderer_trait_surface() {
839 let r = DummyRenderer;
840 assert_eq!(r.extensions(), &["xyz"]);
841 let embed = ParsedEmbed {
842 resolved_path: "a.xyz",
843 from_path: "post.md",
844 query: None,
845 section: None,
846 alias: None,
847 width: None,
848 attrs: None,
849 };
850 assert_eq!(
851 r.render(&embed),
852 RenderedEmbed::Inline("<dummy src=a.xyz>".to_string())
853 );
854 }
855
856 // --- MarkdownEmbedRenderer ---
857
858 #[test]
859 fn test_markdown_embed_renderer_no_section() {
860 let r = MarkdownEmbedRenderer;
861 let embed = ParsedEmbed {
862 resolved_path: "posts/intro.md",
863 from_path: "index.md",
864 query: None,
865 section: None,
866 alias: None,
867 width: None,
868 attrs: None,
869 };
870 assert_eq!(
871 r.render(&embed),
872 RenderedEmbed::Deferred {
873 marker: "<!-- moss-embed:posts/intro.md -->".to_string()
874 }
875 );
876 }
877
878 #[test]
879 fn test_markdown_embed_renderer_heading_section() {
880 let r = MarkdownEmbedRenderer;
881 let embed = ParsedEmbed {
882 resolved_path: "guide.md",
883 from_path: "index.md",
884 query: None,
885 section: Some("Getting Started"),
886 alias: None,
887 width: None,
888 attrs: None,
889 };
890 assert_eq!(
891 r.render(&embed),
892 RenderedEmbed::Deferred {
893 marker: "<!-- moss-embed:guide.md#getting-started -->".to_string()
894 }
895 );
896 }
897
898 #[test]
899 fn test_markdown_embed_renderer_block_ref_section() {
900 let r = MarkdownEmbedRenderer;
901 let embed = ParsedEmbed {
902 resolved_path: "guide.md",
903 from_path: "index.md",
904 query: None,
905 section: Some("^block-xyz"),
906 alias: None,
907 width: None,
908 attrs: None,
909 };
910 assert_eq!(
911 r.render(&embed),
912 RenderedEmbed::Deferred {
913 marker: "<!-- moss-embed:guide.md#^block-xyz -->".to_string()
914 }
915 );
916 }
917
918 #[test]
919 fn test_markdown_embed_renderer_extensions() {
920 assert_eq!(MarkdownEmbedRenderer.extensions(), &["md"]);
921 }
922
923 // -- markdown escape helpers (covered above; spec from plan §D1) -----
924
925 #[test]
926 fn markdown_escape_alt_brackets() {
927 assert_eq!(markdown_escape_alt("plain"), "plain");
928 assert_eq!(markdown_escape_alt("has [brackets]"), r"has \[brackets\]");
929 assert_eq!(
930 markdown_escape_alt(r"with \ backslash"),
931 r"with \\ backslash"
932 );
933 }
934
935 // --- Dim parser ---
936
937 #[test]
938 fn test_dim_css_px() {
939 assert_eq!(Dim::Px(200).to_css(), "200px");
940 }
941
942 #[test]
943 fn test_dim_css_percent() {
944 assert_eq!(Dim::Percent(100.0).to_css(), "100%");
945 assert_eq!(Dim::Percent(50.5).to_css(), "50.5%");
946 }
947
948 #[test]
949 fn test_dim_css_vh() {
950 assert_eq!(Dim::Vh(100.0).to_css(), "100vh");
951 }
952
953 // --- Sizing parser ---
954
955 #[test]
956 fn test_sizing_parse_width_only_px() {
957 assert_eq!(Sizing::parse("200"), Some(Sizing::Width(Dim::Px(200))));
958 }
959
960 #[test]
961 fn test_sizing_parse_width_only_percent() {
962 assert_eq!(
963 Sizing::parse("100%"),
964 Some(Sizing::Width(Dim::Percent(100.0)))
965 );
966 }
967
968 #[test]
969 fn test_sizing_parse_box_px() {
970 assert_eq!(
971 Sizing::parse("200x150"),
972 Some(Sizing::Box(Dim::Px(200), Dim::Px(150)))
973 );
974 }
975
976 #[test]
977 fn test_sizing_parse_box_percent_by_px() {
978 assert_eq!(
979 Sizing::parse("100%x600"),
980 Some(Sizing::Box(Dim::Percent(100.0), Dim::Px(600)))
981 );
982 }
983
984 #[test]
985 fn test_sizing_parse_box_vh_height() {
986 assert_eq!(
987 Sizing::parse("100%x100vh"),
988 Some(Sizing::Box(Dim::Percent(100.0), Dim::Vh(100.0)))
989 );
990 }
991
992 #[test]
993 fn test_sizing_parse_rejects_display_keywords() {
994 assert_eq!(Sizing::parse("contain"), None);
995 assert_eq!(Sizing::parse("left top"), None);
996 }
997
998 #[test]
999 fn test_sizing_parse_empty_returns_none() {
1000 assert_eq!(Sizing::parse(""), None);
1001 assert_eq!(Sizing::parse(" "), None);
1002 }
1003
1004 // --- Reserved classnames ---
1005
1006 #[test]
1007 fn test_embed_class_constants_stable() {
1008 // These strings are part of moss's HTML/CSS contract (#508).
1009 // Changing them is a breaking change for theme authors; this test
1010 // exists to force an explicit decision if anyone tries.
1011 assert_eq!(CLASS_EMBED, "moss-embed");
1012 assert_eq!(CLASS_EMBED_IFRAME, "moss-embed-iframe");
1013 assert_eq!(CLASS_EMBED_PDF, "moss-embed-pdf");
1014 assert_eq!(CLASS_EMBED_AUDIO, "moss-embed-audio");
1015 assert_eq!(CLASS_EMBED_VIDEO, "moss-embed-video");
1016 assert_eq!(CLASS_EMBED_NOTEBOOK, "moss-embed-notebook");
1017 assert_eq!(CLASS_EMBED_3D, "moss-embed-3d");
1018 assert_eq!(CLASS_EMBED_TABLE, "moss-embed-table");
1019 }
1020
1021 #[test]
1022 fn test_embed_marker_prefixes_stable() {
1023 // Marker prefixes are a contract between moss-core (emit) and
1024 // src-tauri (resolve). Changing them breaks the resolver.
1025 assert_eq!(MARKER_MARKDOWN, "moss-embed");
1026 assert_eq!(MARKER_IPYNB, "moss-embed-ipynb");
1027 assert_eq!(MARKER_TABLE, "moss-embed-table");
1028 }
1029
1030 // --- RenderedEmbed variants ---
1031
1032 #[test]
1033 fn test_rendered_embed_html_variant() {
1034 let h = RenderedEmbed::Html("<iframe src=\"x\"></iframe>".to_string());
1035 match h {
1036 RenderedEmbed::Html(s) => assert!(s.contains("iframe")),
1037 _ => panic!("expected Html variant"),
1038 }
1039 }
1040
1041 #[test]
1042 fn test_rendered_embed_deferred_variant() {
1043 let d = RenderedEmbed::Deferred {
1044 marker: "<!-- moss-embed-ipynb:nb.ipynb -->".to_string(),
1045 };
1046 match d {
1047 RenderedEmbed::Deferred { marker } => assert!(marker.contains("ipynb")),
1048 _ => panic!("expected Deferred variant"),
1049 }
1050 }
1051
1052 // --- Registry lookup ---
1053
1054 #[test]
1055 fn test_lookup_renderer_by_extension() {
1056 // Image extensions no longer resolve through the registry — the
1057 // image-embed synth-collapse routes them to the dispatcher's
1058 // Block::Figure arm (via IMAGE_EXTENSIONS), not an EmbedRenderer.
1059 assert!(lookup_renderer("jpg").is_none());
1060 assert!(lookup_renderer("JPG").is_none()); // case-insensitive
1061 assert!(lookup_renderer("md").is_some());
1062 assert!(lookup_renderer("MD").is_some()); // case-insensitive
1063 assert!(lookup_renderer("xyz").is_none());
1064 assert!(lookup_renderer("").is_none());
1065 }
1066
1067 // --- IframeRenderer ---
1068
1069 #[test]
1070 fn test_iframe_renderer_extensions() {
1071 let r = IframeRenderer;
1072 let exts: Vec<&&str> = r.extensions().iter().collect();
1073 assert!(exts.iter().any(|&&x| x == "html"));
1074 assert!(exts.iter().any(|&&x| x == "htm"));
1075 }
1076
1077 /// Helper: render iframe embed to Stage 1 markdown (Inline string).
1078 fn iframe_md(e: &ParsedEmbed) -> String {
1079 match IframeRenderer.render(e) {
1080 RenderedEmbed::Inline(s) => s,
1081 _ => panic!("expected Inline (Stage 1 markdown)"),
1082 }
1083 }
1084
1085 // Phase 3 PR4 (2026-05-27): the `moss:kind=…` title channel retired —
1086 // `render_link_markdown` now emits bare `[name](url)`. The accumulated
1087 // typed params (query / fragment / sizing / data-width / title-alias)
1088 // are discarded at the markdown boundary; future PRs will thread them
1089 // through wikilink dispatch's `EmitKind` instead of round-tripping.
1090 // Until then these renderers smoke-test only the alt / url shape.
1091
1092 #[test]
1093 fn stage1_iframe_basic_is_bare_link() {
1094 let out = iframe_md(&ParsedEmbed {
1095 resolved_path: "widget.html",
1096 from_path: "post.md",
1097 query: None,
1098 section: None,
1099 alias: None,
1100 width: None,
1101 attrs: None,
1102 });
1103 assert_eq!(out, "[widget](widget.html)");
1104 }
1105
1106 #[test]
1107 fn stage1_iframe_with_query_emits_bare_link() {
1108 // `?query` is no longer round-tripped through the markdown title
1109 // attribute.
1110 let out = iframe_md(&ParsedEmbed {
1111 resolved_path: "scale.html",
1112 from_path: "post.md",
1113 query: Some("a=major,minor&r=D"),
1114 section: None,
1115 alias: None,
1116 width: None,
1117 attrs: None,
1118 });
1119 assert_eq!(out, "[scale](scale.html)");
1120 }
1121
1122 #[test]
1123 fn stage1_iframe_with_sizing_alias_emits_bare_link() {
1124 let out = iframe_md(&ParsedEmbed {
1125 resolved_path: "widget.html",
1126 from_path: "post.md",
1127 query: None,
1128 section: None,
1129 alias: Some("100%x600"),
1130 width: None,
1131 attrs: None,
1132 });
1133 assert_eq!(out, "[widget](widget.html)");
1134 }
1135
1136 #[test]
1137 fn stage1_iframe_text_alias_emits_bare_link() {
1138 let out = iframe_md(&ParsedEmbed {
1139 resolved_path: "widget.html",
1140 from_path: "post.md",
1141 query: None,
1142 section: None,
1143 alias: Some("My cool widget"),
1144 width: None,
1145 attrs: None,
1146 });
1147 assert_eq!(out, "[widget](widget.html)");
1148 }
1149
1150 #[test]
1151 fn stage1_iframe_with_fragment_emits_bare_link() {
1152 let out = iframe_md(&ParsedEmbed {
1153 resolved_path: "doc.html",
1154 from_path: "post.md",
1155 query: Some("x=1"),
1156 section: Some("section2"),
1157 alias: None,
1158 width: None,
1159 attrs: None,
1160 });
1161 assert_eq!(out, "[doc](doc.html)");
1162 }
1163
1164 #[test]
1165 fn stage1_iframe_with_canonical_width_emits_bare_link() {
1166 let out = iframe_md(&ParsedEmbed {
1167 resolved_path: "widget.html",
1168 from_path: "post.md",
1169 query: None,
1170 section: None,
1171 alias: None,
1172 width: Some("wide"),
1173 attrs: None,
1174 });
1175 assert_eq!(out, "[widget](widget.html)");
1176 }
1177
1178 // --- Sizing malformed-input coverage ---
1179
1180 #[test]
1181 fn test_sizing_parse_malformed_box_is_none() {
1182 assert_eq!(Sizing::parse("100xbad"), None);
1183 assert_eq!(Sizing::parse("100x"), None);
1184 assert_eq!(Sizing::parse("-100"), None);
1185 }
1186
1187 #[test]
1188 fn stage1_iframe_malformed_sizing_emits_bare_link() {
1189 // PR4: title-attribute fallback retired. Malformed sizing aliases
1190 // simply drop alongside other typed params.
1191 let out = iframe_md(&ParsedEmbed {
1192 resolved_path: "widget.html",
1193 from_path: "post.md",
1194 query: None,
1195 section: None,
1196 alias: Some("100xbad"),
1197 width: None,
1198 attrs: None,
1199 });
1200 assert_eq!(out, "[widget](widget.html)");
1201 }
1202
1203 // --- PdfRenderer ---
1204
1205 fn pdf_md(e: &ParsedEmbed) -> String {
1206 match PdfRenderer.render(e) {
1207 RenderedEmbed::Inline(s) => s,
1208 _ => panic!("expected Inline (Stage 1 markdown)"),
1209 }
1210 }
1211
1212 #[test]
1213 fn test_pdf_renderer_extensions() {
1214 assert_eq!(PdfRenderer.extensions(), &["pdf"]);
1215 }
1216
1217 #[test]
1218 fn stage1_pdf_basic_is_bare_link() {
1219 let out = pdf_md(&ParsedEmbed {
1220 resolved_path: "report.pdf",
1221 from_path: "post.md",
1222 query: None,
1223 section: None,
1224 alias: None,
1225 width: None,
1226 attrs: None,
1227 });
1228 assert_eq!(out, "[report](report.pdf)");
1229 }
1230
1231 #[test]
1232 fn stage1_pdf_with_page_fragment_emits_bare_link() {
1233 let out = pdf_md(&ParsedEmbed {
1234 resolved_path: "doc.pdf",
1235 from_path: "post.md",
1236 query: None,
1237 section: Some("page=5"),
1238 alias: None,
1239 width: None,
1240 attrs: None,
1241 });
1242 assert_eq!(out, "[doc](doc.pdf)");
1243 }
1244
1245 #[test]
1246 fn stage1_pdf_with_sizing_emits_bare_link() {
1247 let out = pdf_md(&ParsedEmbed {
1248 resolved_path: "doc.pdf",
1249 from_path: "post.md",
1250 query: None,
1251 section: None,
1252 alias: Some("100%x800"),
1253 width: None,
1254 attrs: None,
1255 });
1256 assert_eq!(out, "[doc](doc.pdf)");
1257 }
1258
1259 // --- AudioRenderer ---
1260
1261 fn audio_md(e: &ParsedEmbed) -> String {
1262 match AudioRenderer.render(e) {
1263 RenderedEmbed::Inline(s) => s,
1264 _ => panic!("expected Inline (Stage 1 markdown)"),
1265 }
1266 }
1267
1268 #[test]
1269 fn test_audio_renderer_extensions() {
1270 let r = AudioRenderer;
1271 let exts: Vec<&&str> = r.extensions().iter().collect();
1272 for e in &["mp3", "wav", "ogg", "flac", "m4a", "opus", "aac"] {
1273 assert!(exts.iter().any(|&&x| x == *e), "missing: {}", e);
1274 }
1275 }
1276
1277 #[test]
1278 fn stage1_audio_basic_is_bare_link() {
1279 let out = audio_md(&ParsedEmbed {
1280 resolved_path: "song.mp3",
1281 from_path: "post.md",
1282 query: None,
1283 section: None,
1284 alias: None,
1285 width: None,
1286 attrs: None,
1287 });
1288 assert_eq!(out, "[song](song.mp3)");
1289 }
1290
1291 #[test]
1292 fn stage1_audio_each_extension_emits_bare_link() {
1293 // Phase 3 PR4: the `ext=` param is dropped at the markdown
1294 // boundary. Per-extension MIME routing now happens inside the
1295 // Stage 2 synthesizer via the wikilink dispatcher's typed path.
1296 for ext in ["mp3", "wav", "ogg", "flac", "m4a", "opus", "aac"] {
1297 let path = format!("a.{}", ext);
1298 let out = audio_md(&ParsedEmbed {
1299 resolved_path: &path,
1300 from_path: "post.md",
1301 query: None,
1302 section: None,
1303 alias: None,
1304 width: None,
1305 attrs: None,
1306 });
1307 assert_eq!(out, format!("[a](a.{})", ext), "ext={}", ext);
1308 }
1309 }
1310
1311 // --- VideoRenderer ---
1312
1313 fn video_md(e: &ParsedEmbed) -> String {
1314 match VideoRenderer.render(e) {
1315 RenderedEmbed::Inline(s) => s,
1316 _ => panic!("expected Inline (Stage 1 markdown)"),
1317 }
1318 }
1319
1320 #[test]
1321 fn test_video_renderer_extensions() {
1322 let r = VideoRenderer;
1323 let exts: Vec<&&str> = r.extensions().iter().collect();
1324 for e in &["mp4", "webm", "mov", "m4v"] {
1325 assert!(exts.iter().any(|&&x| x == *e), "missing: {}", e);
1326 }
1327 }
1328
1329 #[test]
1330 fn stage1_video_basic_is_bare_link() {
1331 let out = video_md(&ParsedEmbed {
1332 resolved_path: "trailer.mp4",
1333 from_path: "post.md",
1334 query: None,
1335 section: None,
1336 alias: None,
1337 width: None,
1338 attrs: None,
1339 });
1340 assert_eq!(out, "[trailer](trailer.mp4)");
1341 }
1342
1343 #[test]
1344 fn stage1_video_emits_original_extension_in_url() {
1345 // The URL slot carries the original extension (e.g. `.mov`) so the
1346 // downstream `add_video_placeholder_attributes` rewriter can perform
1347 // `.mov→.mp4` swap on the served URL. The renderer never modifies it.
1348 for ext in ["mp4", "webm", "mov", "m4v"] {
1349 let path = format!("clip.{}", ext);
1350 let out = video_md(&ParsedEmbed {
1351 resolved_path: &path,
1352 from_path: "post.md",
1353 query: None,
1354 section: None,
1355 alias: None,
1356 width: None,
1357 attrs: None,
1358 });
1359 assert_eq!(out, format!("[clip](clip.{})", ext), "ext={}", ext);
1360 }
1361 }
1362
1363 #[test]
1364 fn stage1_video_with_sizing_emits_bare_link() {
1365 let out = video_md(&ParsedEmbed {
1366 resolved_path: "clip.mp4",
1367 from_path: "post.md",
1368 query: None,
1369 section: None,
1370 alias: Some("640x360"),
1371 width: None,
1372 attrs: None,
1373 });
1374 assert_eq!(out, "[clip](clip.mp4)");
1375 }
1376
1377 // --- NotebookRenderer ---
1378
1379 #[test]
1380 fn test_notebook_renderer_extensions() {
1381 assert_eq!(NotebookRenderer.extensions(), &["ipynb"]);
1382 }
1383
1384 #[test]
1385 fn test_notebook_renderer_basic() {
1386 let embed = ParsedEmbed {
1387 resolved_path: "resources/habitable-zone.ipynb",
1388 from_path: "posts/hello.md",
1389 query: None,
1390 section: None,
1391 alias: None,
1392 width: None,
1393 attrs: None,
1394 };
1395 match NotebookRenderer.render(&embed) {
1396 RenderedEmbed::Deferred { marker } => assert_eq!(
1397 marker,
1398 "<!-- moss-embed-ipynb:resources/habitable-zone.ipynb -->"
1399 ),
1400 _ => panic!("expected Deferred"),
1401 }
1402 }
1403
1404 #[test]
1405 fn test_notebook_renderer_with_query() {
1406 let embed = ParsedEmbed {
1407 resolved_path: "nb.ipynb",
1408 from_path: "post.md",
1409 query: Some("cells=1-5"),
1410 section: None,
1411 alias: None,
1412 width: None,
1413 attrs: None,
1414 };
1415 match NotebookRenderer.render(&embed) {
1416 RenderedEmbed::Deferred { marker } => {
1417 assert!(marker.contains("nb.ipynb?cells=1-5"), "got: {}", marker)
1418 }
1419 _ => panic!("expected Deferred"),
1420 }
1421 }
1422
1423 #[test]
1424 fn test_notebook_renderer_no_head_assets() {
1425 // nbconvert embeds its own styles inline; no page-level assets needed.
1426 assert!(NotebookRenderer.head_assets().is_empty());
1427 }
1428
1429 // --- ModelViewerRenderer ---
1430
1431 fn mv_md(e: &ParsedEmbed) -> String {
1432 match ModelViewerRenderer.render(e) {
1433 RenderedEmbed::Inline(s) => s,
1434 _ => panic!("expected Inline (Stage 1 markdown)"),
1435 }
1436 }
1437
1438 #[test]
1439 fn test_model_viewer_extensions() {
1440 let exts = ModelViewerRenderer.extensions();
1441 assert!(exts.iter().any(|&x| x == "glb"));
1442 assert!(exts.iter().any(|&x| x == "gltf"));
1443 }
1444
1445 #[test]
1446 fn stage1_model_viewer_basic_is_bare_link() {
1447 let out = mv_md(&ParsedEmbed {
1448 resolved_path: "teapot.glb",
1449 from_path: "post.md",
1450 query: None,
1451 section: None,
1452 alias: None,
1453 width: None,
1454 attrs: None,
1455 });
1456 assert_eq!(out, "[teapot](teapot.glb)");
1457 }
1458
1459 #[test]
1460 fn stage1_model_viewer_with_sizing_emits_bare_link() {
1461 let out = mv_md(&ParsedEmbed {
1462 resolved_path: "m.glb",
1463 from_path: "post.md",
1464 query: None,
1465 section: None,
1466 alias: Some("400x400"),
1467 width: None,
1468 attrs: None,
1469 });
1470 assert_eq!(out, "[m](m.glb)");
1471 }
1472
1473 #[test]
1474 fn test_model_viewer_head_assets() {
1475 let assets = ModelViewerRenderer.head_assets();
1476 assert_eq!(assets.len(), 1);
1477 assert!(assets[0].contains("model-viewer"), "got: {}", assets[0]);
1478 assert!(assets[0].contains("<script"), "got: {}", assets[0]);
1479 }
1480
1481 // --- TableRenderer ---
1482
1483 #[test]
1484 fn test_table_renderer_extensions() {
1485 let exts = TableRenderer.extensions();
1486 assert!(exts.iter().any(|&x| x == "csv"));
1487 assert!(exts.iter().any(|&x| x == "tsv"));
1488 }
1489
1490 #[test]
1491 fn test_table_renderer_emits_deferred() {
1492 let embed = ParsedEmbed {
1493 resolved_path: "data/stars.csv",
1494 from_path: "post.md",
1495 query: None,
1496 section: None,
1497 alias: None,
1498 width: None,
1499 attrs: None,
1500 };
1501 match TableRenderer.render(&embed) {
1502 RenderedEmbed::Deferred { marker } => {
1503 assert_eq!(marker, "<!-- moss-embed-table:data/stars.csv -->")
1504 }
1505 _ => panic!("expected Deferred"),
1506 }
1507 }
1508
1509 // -- spec § P9 width: PR4 retires the title-attribute round-trip ----
1510
1511 /// Build a width-only `ParsedEmbed` mirroring the wikilink resolver's
1512 /// pre-pass output for `![[file|full]]`-style aliases.
1513 fn embed_with_width<'a>(resolved_path: &'a str, width: &'static str) -> ParsedEmbed<'a> {
1514 ParsedEmbed {
1515 resolved_path,
1516 from_path: "post.md",
1517 query: None,
1518 section: None,
1519 alias: None,
1520 width: Some(width),
1521 attrs: None,
1522 }
1523 }
1524
1525 // Phase 3 PR4: width now drops at the markdown boundary. Each renderer
1526 // still constructs the typed param (so `extra` / `fold_attrs_into_params`
1527 // plumbing stays exercised), but the resulting markdown is bare.
1528
1529 #[test]
1530 fn stage1_iframe_width_emits_bare_link() {
1531 let out = iframe_md(&embed_with_width("widget.html", "screen"));
1532 assert_eq!(out, "[widget](widget.html)");
1533 }
1534
1535 #[test]
1536 fn stage1_iframe_no_width_emits_bare_link() {
1537 let out = iframe_md(&ParsedEmbed {
1538 resolved_path: "widget.html",
1539 from_path: "post.md",
1540 query: None,
1541 section: None,
1542 alias: None,
1543 width: None,
1544 attrs: None,
1545 });
1546 assert_eq!(out, "[widget](widget.html)");
1547 }
1548
1549 #[test]
1550 fn stage1_pdf_width_emits_bare_link() {
1551 let out = pdf_md(&embed_with_width("doc.pdf", "wide"));
1552 assert_eq!(out, "[doc](doc.pdf)");
1553 }
1554
1555 #[test]
1556 fn stage1_audio_width_emits_bare_link() {
1557 let out = audio_md(&embed_with_width("song.mp3", "body"));
1558 assert_eq!(out, "[song](song.mp3)");
1559 }
1560
1561 #[test]
1562 fn stage1_video_width_emits_bare_link() {
1563 let out = video_md(&embed_with_width("clip.mp4", "screen"));
1564 // URL slot still carries the original extension — the rewriter
1565 // contract is preserved at the markdown level.
1566 assert_eq!(out, "[clip](clip.mp4)");
1567 }
1568
1569 #[test]
1570 fn stage1_video_no_width_emits_bare_link() {
1571 let out = video_md(&ParsedEmbed {
1572 resolved_path: "clip.mp4",
1573 from_path: "post.md",
1574 query: None,
1575 section: None,
1576 alias: None,
1577 width: None,
1578 attrs: None,
1579 });
1580 assert_eq!(out, "[clip](clip.mp4)");
1581 }
1582
1583 #[test]
1584 fn stage1_model_viewer_width_emits_bare_link() {
1585 let out = mv_md(&embed_with_width("model.glb", "page"));
1586 assert_eq!(out, "[model](model.glb)");
1587 }
1588
1589 #[test]
1590 fn renderer_and_figure_extensions_are_in_registry() {
1591 use crate::resolve::asset_registry::{asset_info, all_assets};
1592 use crate::resolve::ext_kind::ExtKind;
1593 for r in registry() {
1594 for ext in r.extensions() {
1595 assert!(asset_info(ext).is_some(), "renderer ext {ext} not in registry");
1596 }
1597 }
1598 for ext in IMAGE_EXTENSIONS { // the figure-arm image list at embed_renderer.rs:314
1599 assert!(asset_info(ext).is_some(), "figure image ext {ext} not in registry");
1600 }
1601 // Reverse: every registry Image ext with can_embed:true must be in IMAGE_EXTENSIONS,
1602 // so ![[photo.avif]] routes to Block::Figure (wikilink_dispatch.rs). Guards against
1603 // registry additions that silently skip the figure arm.
1604 for a in all_assets() {
1605 if a.kind == ExtKind::Image && a.can_embed {
1606 assert!(
1607 IMAGE_EXTENSIONS.contains(&a.ext),
1608 "registry image ext {} with can_embed:true is missing from IMAGE_EXTENSIONS",
1609 a.ext
1610 );
1611 }
1612 }
1613 // Reverse: every registry Video ext with can_embed:true must be in VIDEO_EXTENSIONS,
1614 // so ![[clip.mp4]] routes to VideoRenderer (wikilink_dispatch.rs). Guards against
1615 // registry additions that silently promise an embeddable video the renderer doesn't list.
1616 for a in all_assets() {
1617 if a.kind == ExtKind::Video && a.can_embed {
1618 assert!(
1619 VIDEO_EXTENSIONS.contains(&a.ext),
1620 "registry video ext {} (can_embed) missing from VIDEO_EXTENSIONS",
1621 a.ext
1622 );
1623 }
1624 }
1625 }
1626
1627 #[test]
1628 fn avif_in_figure_images_and_aac_in_audio() {
1629 assert!(IMAGE_EXTENSIONS.contains(&"avif"));
1630 assert!(AUDIO_EXTENSIONS.contains(&"aac"));
1631 }
1632}