moss-core 0.11.0

Pure-Rust content engine for moss: AST, render, resolve, validate, frontmatter, schema.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
//! Video embed synthesizer.
//!
//! Receives a [`TitleParams`] (Stage 2 dispatcher already parsed it), the
//! source URL, and an [`AssetSnapshot`]. Emits final `<video>` HTML at the
//! typed-data boundary — including every attribute the legacy
//! `add_video_placeholder_attributes` regex used to inject.
//!
//! # Authoritative byte shape (Phase 2E parity)
//!
//! ```html
//! <video class="moss-embed moss-embed-video" src="URL.mp4"
//!        data-placeholder-src="URL.original" poster="URL.thumb.jpg"
//!        data-thumb-src="URL.thumb.jpg" controls preload="metadata"
//!        { width="W"}?{ height="H"}?></video>
//! ```
//!
//! - Single `src=` attribute on `<video>` (no nested `<source>` child) —
//!   load-bearing for the surviving `add_video_placeholder_attributes`
//!   regex pass (preserved until PR3 of Phase 2E). The regex's skip guard
//!   at `placeholder.rs:435` triggers on `data-placeholder-src`, making
//!   the post-pass a no-op for synthesizer-emitted videos.
//!   Source: moss-core pre-Phase-0 `VideoRenderer` at
//!   `crates/moss-core/src/resolve/embed_renderer.rs:509-571` (commit `efb834a3e`).
//! - `controls preload="metadata"` emitted on the **default** branch. The
//!   ambient loop branch (`![[clip.mp4|loop]]`) instead emits
//!   `autoplay muted loop playsinline preload="metadata"` with no `controls`
//!   and adds `data-loop` for JS/CSS targeting. See `AMBIENT_PLAYBACK_ATTRS`.
//! - Width/height priority: (1) `TitleParams` `|WxH` sizing alias wins
//!   when present (with units like `640px`). (2) `AssetSnapshot.dimensions`
//!   lookup as fallback, emitted unitless (e.g. `width="1920"`). (3) Both
//!   attributes omitted when the snapshot dims are `(0, 0)` — that is the
//!   sentinel for "unknown," NOT a real dimension. Both omitted when no
//!   entry is in the snapshot.
//! - `data-placeholder-src` is the ORIGINAL src (e.g. `clip.mov`). The
//!   iframe-bridge swaps `src` to the `.mp4` payload once the transcode
//!   completes (`frontend/bridge/iframe-bridge.ts:666`).
//! - `poster` and `data-thumb-src` both reference `to_thumb(original_src)`.
//!   The iframe-bridge listens for `moss-thumb-ready` and swaps `poster`
//!   in when the thumbnail lands.
//!
//! # `.mov` → `.mp4` source-extension swap (moved from placeholder.rs)
//!
//! moss converts `.mov` source files to `.mp4` during build, so a raw
//! `.mov` reference in the rendered HTML would 404. Pre-Phase-0 this swap
//! lived in `placeholder.rs::add_video_placeholder_attributes` as a regex
//! post-pass. Phase 1's typed-data-boundary architecture moves it here:
//! the synthesizer is the single source of truth for the URL that ends up
//! in `<video src=>`.
//!
//! # Multi-source HLS form (NOT emitted here)
//!
//! This synthesizer never emits `<video><source src=…></video>` (multi-
//! source / adaptive-bitrate form). That shape is reserved for HLS / DASH
//! streams that don't need transcode-pending hydration. The regex pass
//! also skips it (`video_re` requires `src="…"` directly on `<video>`),
//! and the `data-placeholder-src` / `poster` / `data-thumb-src` injection
//! only applies to the single-src form by design. If a future callsite
//! wants the multi-source form, it must NOT route through this
//! synthesizer.

use crate::asset_paths::{to_mp4, to_thumb};
use crate::asset_snapshot::AssetSnapshot;
use crate::resolve::embed_renderer::html_escape_attr;
use crate::resolve::title_params::TitleParams;
use std::path::PathBuf;

/// Core playback attributes shared by the ambient loop branch and cover.rs.
///
/// The loop branch prepends `autoplay` to this. cover.rs intentionally omits
/// `autoplay` (covers are hover-played, not auto) — if cover.rs can't import
/// this const directly, keep its literal with:
/// `// keep in sync with render::video::AMBIENT_PLAYBACK_ATTRS (covers omit autoplay)`
pub const AMBIENT_PLAYBACK_ATTRS: &str = r#"muted loop playsinline preload="metadata""#;

/// Synthesize video embed HTML for `Tag::Link` with `moss:kind=video` title.
///
/// Owns the `.mov` → `.mp4` source-extension swap at the typed-data
/// boundary (moved from `placeholder.rs` per the unified-emission
/// migration). Emits the full attribute set the surviving regex pass
/// would have injected — `data-placeholder-src`, `poster`,
/// `data-thumb-src`, and snapshot-derived `width`/`height` — so the
/// post-pass becomes a no-op for synthesizer-emitted videos. See module
/// docs for the authoritative byte-shape contract.
pub fn synthesize_video_html(
    params: &TitleParams,
    src: &str,
    assets: &AssetSnapshot,
) -> String {
    // .mov → .mp4 source-extension swap (idempotent for .mp4 / .webm).
    // Pre-Phase-0 lived in placeholder.rs; moved here because the typed-
    // data layer is the single source of truth for the served URL.
    let converted_src = to_mp4(src);

    // Thumbnail path — used for both `poster` (initial frame before
    // transcode lands) and `data-thumb-src` (iframe-bridge swap target
    // when `moss-thumb-ready` fires). Mirrors the regex behaviour at
    // `placeholder.rs:444-446`.
    let thumb = to_thumb(src);

    // Width/height priority:
    //   (1) TitleParams alias (e.g. `|640x360` → `width="640px" height="360px"`)
    //   (2) AssetSnapshot.dimensions lookup (unitless ints)
    //   (3) omit both
    // `(0, 0)` from the snapshot is a sentinel for unknown — omit both
    // when we see it, per the Phase 2E design (the regex emits `width="0"`
    // in that case; the synthesizer corrects that long-standing bug).
    let (width_attr, height_attr) = if let (Some(w), Some(h)) = (params.get("width"), params.get("height")) {
        (
            format!(r#" width="{}""#, html_escape_attr(w)),
            format!(r#" height="{}""#, html_escape_attr(h)),
        )
    } else if let Some(w) = params.get("width") {
        // Width-only alias is unusual but historically supported.
        (
            format!(r#" width="{}""#, html_escape_attr(w)),
            String::new(),
        )
    } else {
        match assets.dims(&PathBuf::from(src)) {
            Some((w, h)) if w > 0 && h > 0 => (
                format!(r#" width="{}""#, w),
                format!(r#" height="{}""#, h),
            ),
            _ => (String::new(), String::new()),
        }
    };

    // Ambient loop branch: `![[clip.mp4|loop]]` → autoplay + ambient set,
    // no controls, data-loop JS/CSS hook. Default branch keeps controls.
    let is_loop = params.get("loop").is_some();
    let (playback, data_loop) = if is_loop {
        (
            format!("autoplay {}", AMBIENT_PLAYBACK_ATTRS),
            r#" data-loop"#,
        )
    } else {
        (r#"controls preload="metadata""#.to_string(), "")
    };

    format!(
        r#"<video class="moss-embed moss-embed-video" data-type="video"{data_loop} src="{src}" data-placeholder-src="{orig}" poster="{thumb}" data-thumb-src="{thumb}" {playback}{w}{h}></video>"#,
        data_loop = data_loop,
        src = html_escape_attr(&converted_src),
        orig = html_escape_attr(src),
        thumb = html_escape_attr(&thumb),
        playback = playback,
        w = width_attr,
        h = height_attr,
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    fn empty_snapshot() -> AssetSnapshot {
        AssetSnapshot::new()
    }

    fn params_with(kvs: &[(&str, &str)]) -> TitleParams {
        let mut p = TitleParams::default();
        for (k, v) in kvs {
            p.insert(*k, *v);
        }
        p
    }

    // --- byte-shape parity with pre-Phase-0 VideoRenderer ---

    #[test]
    fn video_basic_shape() {
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.contains("<video"), "got: {}", out);
        assert!(out.contains(r#"src="clip.mp4""#), "got: {}", out);
    }

    #[test]
    fn video_emits_moss_embed_classes() {
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(
            out.contains(r#"class="moss-embed moss-embed-video""#),
            "got: {}",
            out
        );
    }

    #[test]
    fn video_emits_closing_tag() {
        // Pre-Phase-0 VideoRenderer ended with `</video>` (not self-closing).
        // The downstream rewriter regex matches `<video … src="…">` and
        // expects a separate closing tag.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.ends_with("</video>"), "got: {}", out);
    }

    #[test]
    fn video_emits_controls_on_default_path() {
        // The default wikilink `![[clip.mp4]]` (no `loop` param) emits
        // `controls preload="metadata"`. The loop branch is the one exception
        // (it emits the ambient set instead). Relaxed from the original
        // "unconditionally" test name — loop is the opt-in departure.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.contains(" controls"), "default path must emit controls, got: {}", out);
    }

    #[test]
    fn video_emits_preload_metadata() {
        // `preload="metadata"` is the historical default: browser fetches
        // duration/dimensions but defers the full payload until play.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.contains(r#"preload="metadata""#), "got: {}", out);
    }

    #[test]
    fn video_single_src_no_nested_source() {
        // Single src= on <video>, NOT a nested <source> child. Load-bearing:
        // the surviving add_video_placeholder_attributes regex matches
        // `<video\s+[^>]*?src="…">`. With a nested <source>, the regex
        // no-ops and the entire post-pass silently drops. See pre-Phase-0
        // VideoRenderer doc comment at embed_renderer.rs:519-545.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(!out.contains("<source"), "must not emit <source>: {}", out);
    }

    // --- .mov → .mp4 source-extension swap ---

    #[test]
    fn video_mov_extension_swaps_to_mp4() {
        // moss transcodes .mov source files to .mp4 during build; the
        // emitted URL must reference the .mp4 output. Pre-Phase-0 the
        // regex pass in placeholder.rs owned this swap. Phase 1 moves it
        // to the synthesizer at the typed-data boundary (Decision #6:
        // zero carve-outs).
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mov", &empty_snapshot());
        assert!(
            out.contains(r#"src="clip.mp4""#),
            "expected .mov swapped to .mp4, got: {}",
            out
        );
        // The `src=` attribute (preceded by space, not `-src=` from
        // data-placeholder-src) must point at the .mp4. Using a leading
        // space disambiguates `src=` from `data-placeholder-src=`, where
        // `.mov` legitimately survives as the original-src attribute that
        // the iframe-bridge listens to (`iframe-bridge.ts:666`).
        assert!(
            !out.contains(r#" src="clip.mov""#),
            "raw .mov must not appear in src= after swap, got: {}",
            out
        );
    }

    #[test]
    fn video_uppercase_mov_extension_swaps_to_mp4() {
        // .MOV (uppercase) is the macOS QuickTime export default and must
        // also swap. Mirrors the to_mp4 helper's case-insensitive contract.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.MOV", &empty_snapshot());
        assert!(
            out.contains(r#"src="clip.mp4""#),
            "expected .MOV swapped to .mp4, got: {}",
            out
        );
    }

    #[test]
    fn video_mp4_extension_pass_through() {
        // .mp4 is the served form; pass through unchanged.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "../assets/clip.mp4", &empty_snapshot());
        assert!(
            out.contains(r#"src="../assets/clip.mp4""#),
            "got: {}",
            out
        );
    }

    #[test]
    fn video_webm_extension_pass_through() {
        // .webm is not transcoded; pass through.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.webm", &empty_snapshot());
        assert!(out.contains(r#"src="clip.webm""#), "got: {}", out);
    }

    // --- width/height from TitleParams (Stage 1 lifted |WxH alias) ---

    #[test]
    fn video_emits_width_param_when_present() {
        let p = params_with(&[("kind", "video"), ("width", "640px")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.contains(r#"width="640px""#), "got: {}", out);
    }

    #[test]
    fn video_emits_height_param_when_present() {
        let p = params_with(&[("kind", "video"), ("width", "640px"), ("height", "360px")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.contains(r#"width="640px""#), "got: {}", out);
        assert!(out.contains(r#"height="360px""#), "got: {}", out);
    }

    #[test]
    fn video_no_width_or_height_attrs_when_snapshot_empty() {
        // Phase 2E: width/height priority is (1) TitleParams alias, then
        // (2) AssetSnapshot.dims lookup, then (3) omit. With no alias and
        // no snapshot entry, the synthesizer omits both — matching the
        // "no fallback dims" decision in the Phase 2E design (the regex's
        // 800x600 fallback was a long-standing band-aid; Phase 2E drops it).
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(!out.contains("width="), "got: {}", out);
        assert!(!out.contains("height="), "got: {}", out);
    }

    // --- HTML-escape contract ---

    #[test]
    fn video_src_is_html_escaped() {
        // & must escape to &amp; in attribute values. The URL Q&A is
        // contrived but exercises the html_escape pass.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "q&a.mp4", &empty_snapshot());
        assert!(out.contains(r#"src="q&amp;a.mp4""#), "got: {}", out);
    }

    // --- Phase 2E: regex-parity emission ---

    #[test]
    fn synth_video_mov_to_mp4() {
        // `![[clip.mov]]` → `src="clip.mp4"` (transcoded payload) plus
        // `data-placeholder-src="clip.mov"` (original src — what the
        // iframe-bridge listens to when transcode-ready fires). Mirrors
        // the regex contract at `placeholder.rs:440-490` so the post-pass
        // becomes a no-op for synthesizer output.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mov", &empty_snapshot());
        assert!(
            out.contains(r#"src="clip.mp4""#),
            "expected src= to point at transcoded mp4, got: {}",
            out
        );
        assert!(
            out.contains(r#"data-placeholder-src="clip.mov""#),
            "expected data-placeholder-src= to point at original mov, got: {}",
            out
        );
    }

    #[test]
    fn synth_video_poster_and_thumb() {
        // `poster` carries the thumbnail URL so the first frame paints
        // immediately; `data-thumb-src` is the iframe-bridge's swap-target
        // when `moss-thumb-ready` fires (covers the case where the thumb
        // arrives after the page renders). Both reference `to_thumb(src)`,
        // mirroring `placeholder.rs:444-446`.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mov", &empty_snapshot());
        assert!(
            out.contains(r#"poster="clip.thumb.jpg""#),
            "expected poster= from to_thumb(src), got: {}",
            out
        );
        assert!(
            out.contains(r#"data-thumb-src="clip.thumb.jpg""#),
            "expected data-thumb-src= from to_thumb(src), got: {}",
            out
        );
    }

    #[test]
    fn synth_video_dims_from_snapshot() {
        // When TitleParams has no |WxH alias but the AssetSnapshot has
        // (w, h) for the source path, the synthesizer emits unitless
        // width/height. Matches the regex's `lookup.get(original_src)`
        // behaviour (placeholder.rs:449).
        let p = params_with(&[("kind", "video")]);
        let mut snap = AssetSnapshot::new();
        snap.dimensions.insert(PathBuf::from("clip.mov"), (1920, 1080));
        let out = synthesize_video_html(&p, "clip.mov", &snap);
        assert!(out.contains(r#"width="1920""#), "got: {}", out);
        assert!(out.contains(r#"height="1080""#), "got: {}", out);
    }

    #[test]
    fn synth_video_omits_zero_dims() {
        // `(0, 0)` in AssetSnapshot.dimensions is a sentinel for "unknown"
        // (e.g. probe failed). The regex emits `width="0" height="0"`
        // which is invalid; the synthesizer corrects that by omitting both.
        let p = params_with(&[("kind", "video")]);
        let mut snap = AssetSnapshot::new();
        snap.dimensions.insert(PathBuf::from("clip.mov"), (0, 0));
        let out = synthesize_video_html(&p, "clip.mov", &snap);
        assert!(
            !out.contains("width="),
            "(0, 0) sentinel must NOT produce width=, got: {}",
            out
        );
        assert!(
            !out.contains("height="),
            "(0, 0) sentinel must NOT produce height=, got: {}",
            out
        );
    }

    // Phase 2E v5 PR5 (2026-05-26) retired the Stage 3 regex post-pass; the
    // video synthesizer in this module is now the sole emitter of width /
    // height / poster / data-thumb-src / .mov→.mp4 src rewriting for
    // moss-emitted <video> tags. The two regex-parity tests at
    // `src-tauri/tests/video_synth_regex_parity.rs` were deleted alongside
    // the regex.

    // --- |loop ambient-video attribute (spec §3.6) -----------------------

    #[test]
    fn loop_keyword_emits_autoplay_muted_loop_playsinline_no_controls() {
        // `![[clip.mp4|loop]]` must emit the ambient playback set and must
        // NOT emit `controls` (the chrome-free ambient branch has no control bar).
        let p = params_with(&[("kind", "video"), ("loop", "1")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.contains(" autoplay"), "missing autoplay, got: {}", out);
        assert!(out.contains(" muted"), "missing muted, got: {}", out);
        assert!(out.contains(" loop"), "missing loop, got: {}", out);
        assert!(out.contains(" playsinline"), "missing playsinline, got: {}", out);
        assert!(!out.contains(" controls"), "controls must be absent on loop branch, got: {}", out);
    }

    #[test]
    fn loop_keyword_emits_data_type_and_data_loop() {
        // data-type="video" (drift fix) and data-loop (JS/CSS hook) must both
        // be present on the loop branch.
        let p = params_with(&[("kind", "video"), ("loop", "1")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.contains(r#"data-type="video""#), "missing data-type=video, got: {}", out);
        assert!(out.contains(" data-loop"), "missing data-loop attribute, got: {}", out);
    }

    #[test]
    fn default_path_emits_controls() {
        // The default `![[clip.mp4]]` (no loop param) must still emit `controls`.
        // Relaxed from the previous test name "video_emits_controls_unconditionally"
        // — the loop branch is the one exception.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.contains(" controls"), "default path must emit controls, got: {}", out);
    }

    #[test]
    fn loop_with_size_emits_width_height_and_loop_set() {
        // `![[clip.mp4|640x360 loop]]` must set width AND height AND the loop
        // ambient attribute set. The parser arm is tested end-to-end here via
        // the synthesizer (width/height come from TitleParams the parser sets).
        let p = params_with(&[("kind", "video"), ("loop", "1"), ("width", "640px"), ("height", "360px")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.contains(r#"width="640px""#), "missing width, got: {}", out);
        assert!(out.contains(r#"height="360px""#), "missing height, got: {}", out);
        assert!(out.contains(" autoplay"), "missing autoplay, got: {}", out);
        assert!(out.contains(" loop"), "missing loop, got: {}", out);
        assert!(!out.contains(" controls"), "controls must be absent on loop branch, got: {}", out);
    }

    #[test]
    fn data_type_video_emitted_on_default_branch() {
        // data-type="video" must be on the default (non-loop) branch too — this
        // is the drift fix bundled with the loop feature.
        let p = params_with(&[("kind", "video")]);
        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
        assert!(out.contains(r#"data-type="video""#), "missing data-type=video on default branch, got: {}", out);
    }
}