Skip to main content

moss_core/render/
audio.rs

1//! Audio embed synthesizer.
2//!
3//! Receives a [`TitleParams`] (Stage 2 dispatcher already parsed it), the
4//! source URL, and an [`AssetSnapshot`]. Emits final `<audio>` HTML —
5//! preserving the shape moss-core's `AudioRenderer` used to emit before
6//! Phase 0's Stage 1 migration.
7//!
8//! Pre-Phase-0 reference shape (from `crates/moss-core/src/resolve/embed_renderer.rs`
9//! at commit `8f9b6b55a`):
10//!
11//! ```html
12//! <audio class="moss-embed moss-embed-audio" controls preload="metadata">
13//!   <source src="{url}" type="{mime}">
14//!   Your browser does not support the audio tag.
15//! </audio>
16//! ```
17//!
18//! The `AudioRenderer` always emitted `controls` and `preload="metadata"`,
19//! with a `<source type="{mime}">` derived from the asset extension. Phase 1
20//! preserves that exact byte shape; `TitleParams` (`controls`, `loop`,
21//! `autoplay`, `muted`, `preload`) currently surface no behavioral overrides
22//! because the markdown emitter does not author them — they are reserved for
23//! future param plumbing without changing the dispatcher contract.
24
25use crate::asset_snapshot::AssetSnapshot;
26use crate::path_ext::path_extension_lower;
27use crate::resolve::embed_renderer::html_escape_attr;
28use crate::resolve::title_params::TitleParams;
29
30/// Synthesize audio embed HTML for `Tag::Link` with `moss:kind=audio` title.
31///
32/// Always emits a `<audio>` element with `controls preload="metadata"` and
33/// a `<source>` child carrying the URL and a MIME type derived from the
34/// extension. Matches the pre-Phase-0 `AudioRenderer` byte shape one-for-one
35/// so existing snapshot tests / fixtures remain valid after the Stage 2
36/// dispatcher routes here.
37///
38/// Uses the canonical 4-char attribute escaper (`& < > "`); apostrophe is
39/// safe inside `"..."` attributes per HTML5. The pre-Phase-0 path called
40/// `moss_core::media::html_escape` (5 chars, also escapes `'` → `&#39;`),
41/// which over-escaped attribute values relative to pdf / iframe / model.
42/// This byte-shape change aligns audio/video with their siblings.
43#[allow(unused_variables)]
44pub fn synthesize_audio_html(
45    params: &TitleParams,
46    src: &str,
47    assets: &AssetSnapshot,
48) -> String {
49    let ext = path_extension_lower(src);
50    let mime = audio_mime_for_ext(&ext);
51    let escaped = html_escape_attr(src);
52
53    format!(
54        "<audio class=\"moss-embed moss-embed-audio\" controls preload=\"metadata\"><source src=\"{}\" type=\"{}\">Your browser does not support the audio tag.</audio>",
55        escaped, mime,
56    )
57}
58
59
60/// Map a lowercased audio extension to the MIME emitted on `<source type="…">`.
61///
62/// Mirrors the table in moss-core's pre-Phase-0 `AudioRenderer` so the byte
63/// shape of `type="…"` stays identical. Unknown extensions fall back to
64/// `application/octet-stream` (browsers ignore the unknown type and probe
65/// the response Content-Type — graceful degradation).
66fn audio_mime_for_ext(ext: &str) -> &'static str {
67    match ext {
68        "mp3" => "audio/mpeg",
69        "wav" => "audio/wav",
70        "ogg" => "audio/ogg",
71        "flac" => "audio/flac",
72        "m4a" => "audio/mp4",
73        "opus" => "audio/opus",
74        _ => "application/octet-stream",
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    fn empty_snapshot() -> AssetSnapshot {
83        AssetSnapshot::new()
84    }
85
86    fn params_with(kvs: &[(&str, &str)]) -> TitleParams {
87        let mut p = TitleParams::default();
88        for (k, v) in kvs {
89            p.insert(*k, *v);
90        }
91        p
92    }
93
94    #[test]
95    fn audio_basic_shape() {
96        let p = params_with(&[("kind", "audio")]);
97        let out = synthesize_audio_html(&p, "track.mp3", &empty_snapshot());
98        assert!(out.contains("<audio"), "got: {}", out);
99        assert!(out.contains(r#"src="track.mp3""#));
100    }
101
102    #[test]
103    fn audio_with_controls() {
104        let p = params_with(&[("kind", "audio"), ("controls", "true")]);
105        let out = synthesize_audio_html(&p, "track.mp3", &empty_snapshot());
106        assert!(out.contains("controls"));
107    }
108
109    #[test]
110    fn audio_preserves_extension() {
111        // Audio kinds: .mp3, .ogg, .wav, .m4a, .flac, ...
112        let p = params_with(&[("kind", "audio")]);
113        let mp3 = synthesize_audio_html(&p, "track.mp3", &empty_snapshot());
114        let ogg = synthesize_audio_html(&p, "track.ogg", &empty_snapshot());
115        assert!(mp3.contains(r#"src="track.mp3""#));
116        assert!(ogg.contains(r#"src="track.ogg""#));
117    }
118
119    #[test]
120    fn audio_escapes_url() {
121        let p = params_with(&[("kind", "audio")]);
122        let out = synthesize_audio_html(&p, r#"file with "quotes".mp3"#, &empty_snapshot());
123        assert!(out.contains(r#"&quot;quotes&quot;"#), "got: {}", out);
124    }
125
126    #[test]
127    fn audio_emits_pre_phase_0_class_and_attrs() {
128        // Lock in the byte shape: class + controls + preload="metadata" + fallback text.
129        let p = params_with(&[("kind", "audio")]);
130        let out = synthesize_audio_html(&p, "track.mp3", &empty_snapshot());
131        assert!(
132            out.starts_with(r#"<audio class="moss-embed moss-embed-audio" controls preload="metadata">"#),
133            "got: {}",
134            out,
135        );
136        assert!(out.contains("Your browser does not support the audio tag."));
137        assert!(out.ends_with("</audio>"));
138    }
139
140    #[test]
141    fn audio_mime_per_extension() {
142        let p = params_with(&[("kind", "audio")]);
143        let snap = empty_snapshot();
144        let cases = [
145            ("track.mp3", "audio/mpeg"),
146            ("track.wav", "audio/wav"),
147            ("track.ogg", "audio/ogg"),
148            ("track.flac", "audio/flac"),
149            ("track.m4a", "audio/mp4"),
150            ("track.opus", "audio/opus"),
151        ];
152        for (src, mime) in cases {
153            let out = synthesize_audio_html(&p, src, &snap);
154            let expected = format!(r#"type="{}""#, mime);
155            assert!(out.contains(&expected), "src={}, want {}, got: {}", src, expected, out);
156        }
157    }
158
159    #[test]
160    fn audio_unknown_extension_falls_back_to_octet_stream() {
161        let p = params_with(&[("kind", "audio")]);
162        let out = synthesize_audio_html(&p, "mystery.xyz", &empty_snapshot());
163        assert!(out.contains(r#"type="application/octet-stream""#), "got: {}", out);
164    }
165
166    #[test]
167    fn audio_extension_lookup_ignores_query_and_fragment() {
168        let p = params_with(&[("kind", "audio")]);
169        let out = synthesize_audio_html(&p, "track.mp3?v=2#t=10", &empty_snapshot());
170        // Extension still parsed as mp3 → audio/mpeg, full URL preserved as src.
171        assert!(out.contains(r#"type="audio/mpeg""#), "got: {}", out);
172        assert!(out.contains(r#"src="track.mp3?v=2#t=10""#), "got: {}", out);
173    }
174}