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: `` 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 `` 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;