Skip to main content

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