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 — identifies the referencing note, and is NOT an
108    /// input to the emitted URL (see [`Self::pinned_url`]).
109    pub from_path: &'a str,
110    /// The URL the site serves [`Self::resolved_path`] at, pinned once by the
111    /// dispatcher via `ContentGraph::pinned_url`. Emit it verbatim; re-deriving
112    /// a URL per referencing page is moss#903 bug 3.
113    pub pinned_url: &'a str,
114    /// `?query` from the source wikilink, without the leading `?`.
115    pub query: Option<&'a str>,
116    /// `#fragment` from the source wikilink, without the leading `#`.
117    /// For `.md` renderers this is a heading/block-ref marker (block refs
118    /// keep their `^` prefix). For every other renderer this is a URL fragment.
119    pub section: Option<&'a str>,
120    /// `|pipe-content` from the source wikilink — with any spec § P9 width
121    /// token already split out into [`Self::width`]. Image renderer uses
122    /// this for display keywords / size; other renderers parse per their
123    /// convention.
124    pub alias: Option<&'a str>,
125    /// Canonical width value (`body | wide | page | screen`) extracted from
126    /// the pipe-alias by the wikilink resolver. `None` means the author
127    /// did not include a width token; renderers omit `data-width` in that
128    /// case so themes can target the default via `:not([data-width])`.
129    ///
130    /// `full` is normalised to `screen` upstream — values reaching here
131    /// are already in value-space terms (see
132    /// [`crate::media::match_width_token`]).
133    pub width: Option<&'static str>,
134    /// Trailing Pandoc `{.class key=value}` attribute block, if present.
135    ///
136    /// Per Decision #8 of the unified-image-emission architecture, Pandoc-
137    /// style attribute blocks are the canonical author surface for moss-
138    /// vocabulary attributes; the pipe-keyword form remains as compat sugar.
139    /// When both are present, the attribute block wins on typed-field
140    /// conflicts (Decision #11); class lists union+dedupe.
141    pub attrs: Option<crate::ast::attrs::AttrBlock>,
142}
143
144/// Output of a renderer.
145///
146/// The variant tells the caller what further processing (if any) the string
147/// needs. See the module-level doc for the moss-core ↔ src-tauri boundary rule.
148#[derive(Debug, PartialEq, Eq)]
149pub enum RenderedEmbed {
150    /// Markdown-level text that will be processed by CommonMark downstream.
151    /// Example: `![alt](url)` from the image renderer.
152    Inline(String),
153    /// Final HTML to splice into the output — must NOT be re-processed by the
154    /// markdown parser. Example: `<iframe …>` from the iframe renderer.
155    Html(String),
156    /// A marker comment for a post-pass resolver to expand with file I/O.
157    ///
158    /// Format convention: `<!-- <prefix>:<target> -->` where `<prefix>`
159    /// uniquely identifies the resolver (e.g. `moss-embed-ipynb`,
160    /// `moss-embed-table`, `moss-embed-plugin-<plugin-name>`) and
161    /// `<target>` is the body the resolver parses (commonly a path,
162    /// optionally with `?query#fragment|alias`).
163    ///
164    /// The resolver lives in src-tauri (where async and I/O are allowed).
165    /// Built-in prefixes are exported as pub const: [`MARKER_MARKDOWN`],
166    /// [`MARKER_IPYNB`], [`MARKER_TABLE`]. Plugin-registered renderers
167    /// emit `moss-embed-plugin-<plugin-name>:` — see
168    /// [`super::registry`] for the full two-pass dispatch design.
169    Deferred { marker: String },
170}
171
172/// A single dimension with a unit.
173#[cfg_attr(feature = "specta", derive(specta::Type))]
174#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
175pub enum Dim {
176    Px(u32),
177    Percent(f32),
178    Vh(f32),
179}
180
181impl Dim {
182    /// Render this dimension as a CSS length string.
183    pub fn to_css(self) -> String {
184        match self {
185            Dim::Px(n) => format!("{}px", n),
186            Dim::Percent(v) => {
187                if v.fract() == 0.0 {
188                    format!("{}%", v as i64)
189                } else {
190                    format!("{}%", v)
191                }
192            }
193            Dim::Vh(v) => {
194                if v.fract() == 0.0 {
195                    format!("{}vh", v as i64)
196                } else {
197                    format!("{}vh", v)
198                }
199            }
200        }
201    }
202
203    /// Parse one dimension. Accepts: `200`, `200px`, `100%`, `80vh`.
204    /// Returns None on any parse failure.
205    fn parse(s: &str) -> Option<Self> {
206        let s = s.trim();
207        if s.is_empty() {
208            return None;
209        }
210        if let Some(rest) = s.strip_suffix('%') {
211            return rest.trim().parse::<f32>().ok().map(Dim::Percent);
212        }
213        if let Some(rest) = s.strip_suffix("vh") {
214            return rest.trim().parse::<f32>().ok().map(Dim::Vh);
215        }
216        if let Some(rest) = s.strip_suffix("px") {
217            return rest.trim().parse::<u32>().ok().map(Dim::Px);
218        }
219        s.parse::<u32>().ok().map(Dim::Px)
220    }
221}
222
223/// Parsed `|WxH` sizing hint from a wikilink pipe segment.
224#[cfg_attr(feature = "specta", derive(specta::Type))]
225#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
226pub enum Sizing {
227    /// `|200` or `|100%` — width only.
228    Width(Dim),
229    /// `|200x150` or `|100%x600` — width × height.
230    Box(Dim, Dim),
231}
232
233impl Sizing {
234    /// Parse a pipe segment. Returns None if the string does not look like a
235    /// sizing hint — callers can then fall through to their own parser
236    /// (e.g. image display keywords).
237    pub fn parse(s: &str) -> Option<Self> {
238        let s = s.trim();
239        if s.is_empty() {
240            return None;
241        }
242        if let Some((w, h)) = s.split_once('x') {
243            let wd = Dim::parse(w)?;
244            let hd = Dim::parse(h)?;
245            return Some(Sizing::Box(wd, hd));
246        }
247        Dim::parse(s).map(Sizing::Width)
248    }
249}
250
251/// A renderer converts a `ParsedEmbed` into its rendered form.
252pub trait EmbedRenderer: std::fmt::Debug + Send + Sync {
253    /// Extensions this renderer claims (lowercase, without leading dot).
254    fn extensions(&self) -> &[&'static str];
255
256    /// Render the embed. Must be pure; moss-core is I/O-free.
257    fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed;
258
259    /// Page-level HTML fragments this renderer needs in `<head>`, injected
260    /// once per page that contains at least one embed from this renderer.
261    ///
262    /// Example: `ModelViewerRenderer` returns a `<script type="module">` tag
263    /// so that `<model-viewer>` custom elements work. The build pipeline
264    /// collects and deduplicates these across all embeds on a page.
265    ///
266    /// Default: empty. Renderers with no page-level assets don't override.
267    fn head_assets(&self) -> &[&'static str] {
268        &[]
269    }
270}
271
272/// Built-in renderer registry. Initialized lazily on first lookup.
273///
274/// Each renderer is a unit struct, so the pointer is to a zero-size `'static`
275/// — no heap allocation ever. Future renderers (notebook, 3d, table, plugins)
276/// get appended here as they ship.
277///
278/// Extension sets across renderers are currently disjoint. Adding overlap
279/// (e.g., if a future renderer claims `.ogg` for video) would require
280/// tie-break logic here; first-match-wins is the only implicit rule today.
281fn registry() -> &'static [&'static dyn EmbedRenderer] {
282    static INIT: OnceLock<Vec<&'static dyn EmbedRenderer>> = OnceLock::new();
283    INIT.get_or_init(|| {
284        vec![
285            &MarkdownEmbedRenderer as &'static dyn EmbedRenderer,
286            &IframeRenderer as &'static dyn EmbedRenderer,
287            &PdfRenderer as &'static dyn EmbedRenderer,
288            &AudioRenderer as &'static dyn EmbedRenderer,
289            &VideoRenderer as &'static dyn EmbedRenderer,
290            &NotebookRenderer as &'static dyn EmbedRenderer,
291            &ModelViewerRenderer as &'static dyn EmbedRenderer,
292            &TableRenderer as &'static dyn EmbedRenderer,
293        ]
294    })
295}
296
297/// Look up a renderer by file extension (case-insensitive, no leading dot).
298pub fn lookup_renderer(ext: &str) -> Option<&'static dyn EmbedRenderer> {
299    if ext.is_empty() {
300        return None;
301    }
302    registry()
303        .iter()
304        .copied()
305        .find(|r| r.extensions().iter().any(|e| e.eq_ignore_ascii_case(ext)))
306}
307
308// ---------------------------------------------------------------------------
309// ImageRenderer
310// ---------------------------------------------------------------------------
311
312use crate::heading::anchor::obsidian_heading_anchor;
313
314use super::title_params::TitleParams;
315
316/// Image file extensions recognized by `ImageRenderer`.
317pub(crate) const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "svg", "webp", "avif"];
318
319/// Map an `AlignSide` to its canonical title-param keyword (`"left"` or
320/// `"right"`). Stage 2 reverses this via `AlignSide::from_keyword`-style
321/// recognition (it accepts both `left` and `align-left`).
322fn align_keyword(side: crate::media::AlignSide) -> &'static str {
323    match side {
324        crate::media::AlignSide::Left => "left",
325        crate::media::AlignSide::Right => "right",
326    }
327}
328
329/// Push `class` into `acc` only if not already present. Used when merging
330/// pipe-alias passthrough classes with Pandoc attribute-block classes
331/// (Decision #11: class lists union and dedupe).
332fn add_class_dedup(acc: &mut Vec<String>, class: &str) {
333    if !acc.iter().any(|c| c == class) {
334        acc.push(class.to_string());
335    }
336}
337
338/// Shared attribute-block fold for non-image renderers (iframe, pdf, audio,
339/// video, 3D). Mirrors the ImageRenderer logic at a smaller scope: extract
340/// recognized vocabulary (align) into typed params; pass through everything
341/// else as `classes` + extra key=value attrs.
342///
343/// Lives here so all `render_link_markdown` consumers stay in lockstep when
344/// Decision #11 (attribute-block-wins, class lists union+dedupe) evolves.
345///
346/// `pub(super)` for sibling-module use within `resolve/`. Originally exposed
347/// so the Stage 1 native-markdown sweep (`wikilinks::stage1_sweep`) could
348/// fold trailing `{...}` attribute blocks into native-image rewrites;
349/// that sweep retired in Phase 3 PR2, but the visibility stays the same
350/// shape so plugin-side callers can still reach it.
351pub(super) fn fold_attrs_into_params(
352    block: &crate::ast::attrs::AttrBlock,
353    params: &mut TitleParams,
354) {
355    let mut classes: Vec<String> = Vec::new();
356    let mut consumed_class_kv = false;
357
358    for class in &block.classes {
359        if let Some(side) = crate::media::AlignSide::from_keyword(class) {
360            params.insert("align", align_keyword(side));
361        } else {
362            add_class_dedup(&mut classes, class);
363        }
364    }
365
366    if let Some(w) = block.width {
367        // Non-image renderers expose width on the wrapper as `data-width`
368        // (see `render_link_markdown`), matching the pipe-alias path.
369        params.insert("data-width", w);
370    }
371
372    for (k, v) in &block.kvs {
373        if k == "class" {
374            consumed_class_kv = true;
375            for c in v.split_whitespace() {
376                if let Some(side) = crate::media::AlignSide::from_keyword(c) {
377                    params.insert("align", align_keyword(side));
378                } else {
379                    add_class_dedup(&mut classes, c);
380                }
381            }
382        }
383    }
384    if !classes.is_empty() {
385        params.insert("classes", classes.join(" "));
386    }
387    for (k, v) in &block.kvs {
388        if consumed_class_kv && k == "class" {
389            continue;
390        }
391        params.insert(k.clone(), v.clone());
392    }
393}
394
395/// Escape alt text for markdown `![...](url)` syntax.
396///
397/// Brackets MUST be escaped; HTML entities are NOT needed (pulldown-cmark
398/// handles `<` `>` `&` per CommonMark rules when alt text is rendered).
399fn markdown_escape_alt(s: &str) -> String {
400    s.replace('\\', "\\\\")
401        .replace('[', "\\[")
402        .replace(']', "\\]")
403}
404
405/// Shared Stage 1 emitter for the five non-image renderers (iframe, pdf,
406/// audio, video, 3D). Produces a CommonMark link:
407///
408/// ```text
409/// [filename](url "moss:kind=<kind> <params>")
410/// ```
411///
412/// pulldown-cmark parses this as `Tag::Link`; Phase 1's Stage 2 dispatcher
413/// keys off `moss:kind=` to choose the right HTML synthesizer.
414///
415/// `kind` is the canonical kind name (`iframe`, `pdf`, `audio`, `video`,
416/// `3d`). `extra` is invoked to inject renderer-specific params; the helper
417/// pre-fills `kind=` so callers only handle their own grammar.
418fn render_link_markdown(
419    embed: &ParsedEmbed<'_>,
420    kind: &'static str,
421    extra: impl FnOnce(&ParsedEmbed<'_>, &mut TitleParams),
422) -> String {
423    let url = embed.pinned_url;
424    // The `moss:kind=…` markdown-title channel is retired: params (kind /
425    // data-width / extras / attribute block) are no longer round-tripped
426    // through a title. The accumulation stays as a no-op so the `extra` and
427    // `fold_attrs_into_params` plumbing keeps being exercised. Anything
428    // threading typed params into iframe / pdf / video / audio / 3D dispatch
429    // must bypass this round-trip — `EmitKind::Inline` is the wrong channel
430    // for typed structural data.
431    let mut params = TitleParams::default();
432    params.insert("kind", kind);
433    if let Some(w) = embed.width {
434        params.insert("data-width", w);
435    }
436    extra(embed, &mut params);
437    if let Some(block) = &embed.attrs {
438        fold_attrs_into_params(block, &mut params);
439    }
440    let _ = params;
441    let name = file_stem(embed.resolved_path);
442    format!("[{}]({})", markdown_escape_alt(&name), url)
443}
444
445// file_stem now lives in common.rs — imported via common::file_stem below.
446
447// ---------------------------------------------------------------------------
448// MarkdownEmbedRenderer
449// ---------------------------------------------------------------------------
450
451/// Renderer for markdown transclusion: `![[file.md]]` → `<!-- moss-embed:path -->`.
452///
453/// The marker comment is resolved later by src-tauri's embed resolver, which
454/// reads the target file's content and splices it inline. This renderer does
455/// not perform I/O.
456#[derive(Debug)]
457pub struct MarkdownEmbedRenderer;
458
459impl EmbedRenderer for MarkdownEmbedRenderer {
460    fn extensions(&self) -> &[&'static str] {
461        &["md"]
462    }
463
464    fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
465        let anchor = build_embed_anchor(embed.section);
466        RenderedEmbed::Deferred {
467            marker: format!(
468                "<!-- {}:{}{} -->",
469                MARKER_MARKDOWN, embed.resolved_path, anchor
470            ),
471        }
472    }
473}
474
475/// Build the anchor fragment for a markdown embed marker.
476///
477/// Preserves the `^` prefix on block references so the downstream embed
478/// resolver can distinguish them from headings.
479fn build_embed_anchor(section: Option<&str>) -> String {
480    match section {
481        None => String::new(),
482        Some(s) if s.is_empty() => String::new(),
483        Some(s) => {
484            if s.starts_with('^') {
485                format!("#{}", s)
486            } else {
487                format!("#{}", obsidian_heading_anchor(s))
488            }
489        }
490    }
491}
492
493// ---------------------------------------------------------------------------
494// IframeRenderer
495// ---------------------------------------------------------------------------
496
497/// Renderer for local HTML embeds: `![[file.html?query#frag|WxH]]` → `<iframe>`.
498///
499/// - `?query` is appended to the iframe `src` as URL query.
500/// - `#fragment` is appended as URL fragment (order: path?query#frag).
501/// - `|W` or `|WxH` becomes iframe width/height attributes via [`Sizing`].
502/// - No sandbox attribute is set by default — noted as a follow-up.
503#[derive(Debug)]
504pub struct IframeRenderer;
505
506impl EmbedRenderer for IframeRenderer {
507    fn extensions(&self) -> &[&'static str] {
508        &["html", "htm"]
509    }
510
511    fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
512        RenderedEmbed::Inline(render_link_markdown(embed, "iframe", iframe_extra_params))
513    }
514}
515
516/// Iframe-specific param extraction:
517///
518/// - `?query` and `#fragment` from the wikilink fold into a `src` param so
519///   Stage 2 can reconstruct the URL it serves on the iframe element. They
520///   are NOT re-inserted into the markdown URL slot because pulldown-cmark
521///   would percent-encode them, which would break iframe `src` semantics
522///   downstream.
523/// - `|WxH` sizing in alias becomes `width=`/`height=` params.
524/// - Non-sizing alias text becomes the `title=` param (used today as
525///   accessible name / tooltip on the iframe).
526fn iframe_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
527    if let Some(q) = embed.query {
528        params.insert("query", q);
529    }
530    if let Some(f) = embed.section {
531        params.insert("fragment", f);
532    }
533    let Some(alias) = embed.alias else {
534        return;
535    };
536    match Sizing::parse(alias) {
537        Some(Sizing::Width(w)) => {
538            params.insert("width", w.to_css());
539        }
540        Some(Sizing::Box(w, h)) => {
541            params.insert("width", w.to_css());
542            params.insert("height", h.to_css());
543        }
544        None => {
545            // Non-sizing text alias → iframe title.
546            params.insert("title", alias);
547        }
548    }
549}
550
551// build_src, dim_attrs, html_escape_attr now live in common.rs — imported above.
552
553// ---------------------------------------------------------------------------
554// PdfRenderer
555// ---------------------------------------------------------------------------
556
557/// Renderer for PDF embeds: `![[report.pdf]]` → `<object type="application/pdf">`.
558///
559/// `<object>` has better keyboard navigation than `<iframe>` for PDFs and
560/// supports inline fallback content for browsers that can't render PDFs natively.
561#[derive(Debug)]
562pub struct PdfRenderer;
563
564impl EmbedRenderer for PdfRenderer {
565    fn extensions(&self) -> &[&'static str] {
566        &["pdf"]
567    }
568
569    fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
570        RenderedEmbed::Inline(render_link_markdown(embed, "pdf", pdf_extra_params))
571    }
572}
573
574/// PDF-specific params: viewer fragment (`#page=5`), sizing.
575fn pdf_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
576    if let Some(q) = embed.query {
577        params.insert("query", q);
578    }
579    if let Some(f) = embed.section {
580        params.insert("fragment", f);
581    }
582    if let Some(alias) = embed.alias {
583        match Sizing::parse(alias) {
584            Some(Sizing::Width(w)) => {
585                params.insert("width", w.to_css());
586            }
587            Some(Sizing::Box(w, h)) => {
588                params.insert("width", w.to_css());
589                params.insert("height", h.to_css());
590            }
591            None => {}
592        }
593    }
594}
595
596// ---------------------------------------------------------------------------
597// AudioRenderer
598// ---------------------------------------------------------------------------
599
600const AUDIO_EXTENSIONS: &[&str] = &["mp3", "wav", "ogg", "flac", "m4a", "opus", "aac"];
601
602/// Renderer for audio embeds: `![[song.mp3]]` → `<audio controls>`.
603///
604/// `preload=metadata` so the browser fetches duration/sample-rate but not the
605/// full payload until the user presses play.
606///
607/// Output form: `<audio><source src="..." type="..."></audio>` (HTML5
608/// multi-source). This is safe today because no audio extension rewriter
609/// exists in src-tauri — audio files pass through unchanged. If a future
610/// converter is introduced (e.g., `.flac→.mp3` for size, `.m4a→.opus` for
611/// browser parity, see #504), this renderer must switch to the single
612/// `src=` form for the same reason `VideoRenderer` did: the
613/// `add_*_placeholder_attributes` regex pattern in
614/// `src-tauri/src/build/media/placeholder.rs` matches `<tag\s+[^>]*?src=>`,
615/// not nested `<source>` children. See #593 and the docstring on
616/// `VideoRenderer` for the full failure mode.
617#[derive(Debug)]
618pub struct AudioRenderer;
619
620impl EmbedRenderer for AudioRenderer {
621    fn extensions(&self) -> &[&'static str] {
622        AUDIO_EXTENSIONS
623    }
624
625    fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
626        RenderedEmbed::Inline(render_link_markdown(embed, "audio", audio_extra_params))
627    }
628}
629
630/// Audio-specific params: source extension (for downstream MIME selection).
631/// The historical author grammar exposed no per-embed audio flags, so today
632/// the only extra param is the file extension. Future flags (`controls`,
633/// `loop`, `autoplay`, `muted`) extend here.
634fn audio_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
635    let ext = path_extension_lower(embed.resolved_path);
636    if !ext.is_empty() {
637        params.insert("ext", ext);
638    }
639}
640
641// MIME-type selection for audio embeds now lives downstream (Phase 1
642// Stage 2 picks the MIME from the `ext=` title param when synthesizing the
643// `<audio><source>` HTML). moss-core's Stage 1 emits the file extension
644// directly via `audio_extra_params`; the legacy `audio_mime_for_ext` helper
645// is no longer needed here.
646
647// ---------------------------------------------------------------------------
648// VideoRenderer
649// ---------------------------------------------------------------------------
650
651const VIDEO_EXTENSIONS: &[&str] = &["mp4", "webm", "mov", "m4v"];
652
653/// Renderer for video embeds: `![[clip.mp4]]` → `<video src="..." controls>`.
654///
655/// `|WxH` becomes width/height attrs. `preload=metadata` so the browser
656/// fetches duration/dimensions but not the full payload until play.
657///
658/// Output form: single `src=` attribute on `<video>` (no `<source>` child),
659/// no `type=` attribute. Two coupled reasons:
660///
661/// 1. **`type=` would go stale.** The downstream rewriter
662///    `src-tauri/src/build/media/placeholder.rs::add_video_placeholder_attributes`
663///    rewrites the `src` extension from `.mov` to `.mp4` after the renderer
664///    runs (moss converts `.mov` source files to `.mp4` during build, so a
665///    raw `.mov` reference would 404). Any explicit `type="video/quicktime"`
666///    emitted here would survive the rewrite as a lie. Browser sniffing
667///    from the rewritten URL extension is more reliable than a stale type
668///    hint.
669///
670/// 2. **The rewriter regex requires single-`src=` form.** It matches
671///    `<video\s+[^>]*?src="...">` — `src=` must be on the `<video>` tag
672///    itself, not on a nested `<source>` child. With nested `<source>`,
673///    the regex no-ops and the `.mov→.mp4` rewrite + `data-placeholder-src`
674///    + `poster` + `data-thumb-src` injection all silently drop. This
675///    constraint is load-bearing; see #592 for an integration test that
676///    pins it across the cross-crate boundary, and #593 for the audio
677///    asymmetry. This shape also matches the historical moss output that
678///    liu-guo.com still ships.
679///
680/// Note: `.mov` is codec-dependent at the source. Safari plays QuickTime
681/// natively; Chrome/Firefox accept the MIME but decode only if the
682/// container's video codec is supported (usually H.264). The `.mov→.mp4`
683/// rewriter solves this in practice — served files end as `.mp4`.
684#[derive(Debug)]
685pub struct VideoRenderer;
686
687impl EmbedRenderer for VideoRenderer {
688    fn extensions(&self) -> &[&'static str] {
689        VIDEO_EXTENSIONS
690    }
691
692    fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
693        RenderedEmbed::Inline(render_link_markdown(embed, "video", video_extra_params))
694    }
695}
696
697/// Video-specific params: sizing from alias. Author flags (controls, loop,
698/// autoplay, muted, poster) extend here when wired up.
699fn video_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
700    if let Some(alias) = embed.alias {
701        match Sizing::parse(alias) {
702            Some(Sizing::Width(w)) => {
703                params.insert("width", w.to_css());
704            }
705            Some(Sizing::Box(w, h)) => {
706                params.insert("width", w.to_css());
707                params.insert("height", h.to_css());
708            }
709            None => {}
710        }
711    }
712}
713
714// ---------------------------------------------------------------------------
715// NotebookRenderer
716// ---------------------------------------------------------------------------
717
718/// Renderer for Jupyter notebooks: `![[file.ipynb]]` → deferred marker.
719///
720/// Emits `<!-- moss-embed-ipynb:PATH -->` (with optional `?query` appended).
721/// The real rendering happens in src-tauri via nbconvert or equivalent —
722/// src-tauri resolves the marker post-pass.
723#[derive(Debug)]
724pub struct NotebookRenderer;
725
726impl EmbedRenderer for NotebookRenderer {
727    fn extensions(&self) -> &[&'static str] {
728        &["ipynb"]
729    }
730
731    fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
732        // NOTE: embed.width is intentionally dropped at the marker boundary.
733        // Notebook wrappers are emitted in src-tauri post-passes that don't
734        // currently read width from the marker target. Track when those
735        // switch to data-width emission — file a follow-up issue if needed.
736        let target = match embed.query {
737            Some(q) => format!("{}?{}", embed.resolved_path, q),
738            None => embed.resolved_path.to_string(),
739        };
740        RenderedEmbed::Deferred {
741            marker: format!("<!-- {}:{} -->", MARKER_IPYNB, target),
742        }
743    }
744}
745
746// ---------------------------------------------------------------------------
747// ModelViewerRenderer (3D)
748// ---------------------------------------------------------------------------
749
750/// Page-level script import needed for `<model-viewer>` to work.
751///
752/// Loaded from Google's CDN. Pinned to a major version for stability.
753/// If this URL becomes unavailable, self-host and update this constant.
754const MODEL_VIEWER_SCRIPT: &str = "<script type=\"module\" src=\"https://ajax.googleapis.com/ajax/libs/model-viewer/3.4.0/model-viewer.min.js\"></script>";
755
756/// Renderer for 3D model embeds: `![[model.glb|400x400]]` → `<model-viewer>`.
757///
758/// Requires the `<model-viewer>` custom element script, injected via
759/// `head_assets` once per page that contains any `.glb`/`.gltf` embed.
760#[derive(Debug)]
761pub struct ModelViewerRenderer;
762
763impl EmbedRenderer for ModelViewerRenderer {
764    fn extensions(&self) -> &[&'static str] {
765        &["glb", "gltf"]
766    }
767
768    fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
769        RenderedEmbed::Inline(render_link_markdown(embed, "3d", model_viewer_extra_params))
770    }
771
772    fn head_assets(&self) -> &[&'static str] {
773        &[MODEL_VIEWER_SCRIPT]
774    }
775}
776
777/// 3D-viewer-specific params: sizing from alias. Author flags (`auto-rotate`,
778/// `camera-controls`, `ar`) extend here when surfaced in the wikilink grammar.
779fn model_viewer_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
780    if let Some(alias) = embed.alias {
781        match Sizing::parse(alias) {
782            Some(Sizing::Width(w)) => {
783                params.insert("width", w.to_css());
784            }
785            Some(Sizing::Box(w, h)) => {
786                params.insert("width", w.to_css());
787                params.insert("height", h.to_css());
788            }
789            None => {}
790        }
791    }
792}
793
794// ---------------------------------------------------------------------------
795// TableRenderer
796// ---------------------------------------------------------------------------
797
798/// Renderer for tabular data: `![[data.csv]]` → deferred marker.
799///
800/// Emits `<!-- moss-embed-table:PATH -->`. src-tauri reads the CSV/TSV file
801/// and calls `moss_core::csv_table::render` (a pure renderer) in a post-pass.
802#[derive(Debug)]
803pub struct TableRenderer;
804
805impl EmbedRenderer for TableRenderer {
806    fn extensions(&self) -> &[&'static str] {
807        &["csv", "tsv"]
808    }
809
810    fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
811        // NOTE: embed.width is intentionally dropped at the marker boundary.
812        // Table wrappers are emitted in src-tauri post-passes (csv_table) that
813        // don't currently read width from the marker target. Track when those
814        // switch to data-width emission — file a follow-up issue if needed.
815        RenderedEmbed::Deferred {
816            marker: format!("<!-- {}:{} -->", MARKER_TABLE, embed.resolved_path),
817        }
818    }
819}
820
821#[cfg(test)]
822#[path = "embed_renderer_tests.rs"]
823mod tests;