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#[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 `![...](url)` 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}