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