use super::*;
pub const MEDIA_EXTS: &[(&str, &str)] = &[
("mp4", "video"),
("webm", "video"),
("mov", "video"),
("m4v", "video"),
("ogv", "video"),
("mp3", "audio"),
("wav", "audio"),
("m4a", "audio"),
("ogg", "audio"),
("oga", "audio"),
("flac", "audio"),
("opus", "audio"),
("aac", "audio"),
];
pub fn media_kind(ext: &str) -> Option<&'static str> {
MEDIA_EXTS.iter().find(|(e, _)| *e == ext).map(|(_, k)| *k)
}
pub fn media_body(src_url: &str, ext: &str) -> String {
let src = html_escape::encode_double_quoted_attribute(src_url);
let kind = media_kind(ext).unwrap_or("audio");
format!(
"<p class=\"doc-image doc-media\">{}</p>",
player(kind, &src, "")
)
}
pub fn player(kind: &str, src: &str, title: &str) -> String {
let label = if title.is_empty() {
file_label(src)
} else {
title.to_string()
};
let titled = if title.is_empty() {
String::new()
} else {
format!(" title=\"{title}\"")
};
let (el, look, face) = if kind == "video" {
(
"video",
"display:block;width:100%;aspect-ratio:16/9;max-height:70vh;object-fit:contain;\
background:#000;border-radius:var(--radius)",
"display:flex;align-items:center;justify-content:center;gap:10px;width:100%;\
aspect-ratio:16/9;max-height:70vh;background:#000;color:#fff;border:0;\
border-radius:var(--radius)",
)
} else {
(
"audio",
"display:block;width:100%;height:54px",
"display:flex;align-items:center;gap:12px;width:100%;height:54px;padding:0 18px;\
box-sizing:border-box;border:1px solid var(--rule-2);border-radius:27px;\
background:var(--bg-raise);color:var(--fg)",
)
};
format!(
"<button type=\"button\" class=\"doc-play\" data-w=\"play\" aria-label=\"Play {label}\" \
style=\"{face};font:inherit;text-align:left;cursor:pointer\">\
<span aria-hidden=\"true\">\u{25b6}</span>\
<span style=\"min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\">{label}</span>\
</button><template><{el} controls preload=\"auto\" src=\"{src}\"{titled} style=\"{look}\"></{el}></template>"
)
}
fn file_label(src: &str) -> String {
let src = src
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&");
let path = src.split(['?', '#']).next().unwrap_or("");
let name = path.rsplit('/').next().unwrap_or(path);
let bytes = name.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = |b: u8| (b as char).to_digit(16);
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
out.push((h * 16 + l) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
html_escape::encode_double_quoted_attribute(&String::from_utf8_lossy(&out)).into_owned()
}
pub fn lazy_players(html: &str) -> std::borrow::Cow<'_, str> {
const OLD: [(&str, &str); 2] = [
("audio", "<audio controls preload=\"metadata\" src=\""),
("video", "<video controls preload=\"metadata\" src=\""),
];
if !OLD.iter().any(|(_, o)| html.contains(o)) {
return std::borrow::Cow::Borrowed(html);
}
let mut out = String::with_capacity(html.len());
let mut rest = html;
loop {
let next = OLD
.iter()
.filter_map(|(k, o)| rest.find(o).map(|i| (i, *k, *o)))
.min_by_key(|(i, ..)| *i);
let Some((i, kind, open)) = next else { break };
let tail = &rest[i..];
let close = format!("</{kind}>");
let (Some(end), Some(gt)) = (tail.find(&close), tail.find('>')) else {
break;
};
let tag = &tail[..gt];
let src = &tag[open.len()..];
let src = &src[..src.find('"').unwrap_or(src.len())];
let title = tag
.find(" title=\"")
.map(|t| &tag[t + 8..])
.map(|t| &t[..t.find('"').unwrap_or(t.len())])
.unwrap_or("");
out.push_str(&rest[..i]);
out.push_str(&player(kind, src, title));
rest = &tail[end + close.len()..];
}
out.push_str(rest);
std::borrow::Cow::Owned(out)
}
pub(super) fn players(html: String, files: Option<(&str, &Path)>) -> String {
const IMG: &str = "<img src=\"";
if !html.contains(IMG) {
return html;
}
let attr = |tag: &str, name: &str| -> Option<String> {
let at = tag.find(&format!(" {name}=\""))? + name.len() + 3;
let end = tag[at..].find('"')?;
Some(tag[at..at + end].to_string())
};
let mut out = String::with_capacity(html.len());
let mut rest = html.as_str();
while let Some(i) = rest.find(IMG) {
out.push_str(&rest[..i]);
let tail = &rest[i..];
let Some(close) = tail.find('>') else {
break;
};
let tag = &tail[..=close];
let src = attr(tag, "src").unwrap_or_default();
let path = src.split(['?', '#']).next().unwrap_or("");
match media_kind(&ext_of(path)) {
None => match files.and_then(|(base, dir)| sized(tag, path, base, dir)) {
Some(t) => out.push_str(&t),
None => out.push_str(tag),
},
Some(kind) => out.push_str(&player(kind, &src, &attr(tag, "alt").unwrap_or_default())),
}
rest = &tail[close + 1..];
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_video_or_a_song_in_markdown_plays_in_place() {
let r = Renderer::new();
let md = |src: &str| r.render_with_base(Kind::Markdown, None, src, Some("/files/abc/"));
for (file, tag) in [
(
"take2.mp4",
"<template><video controls preload=\"auto\" src=\"/files/abc/take2.mp4\"",
),
(
"cut.webm",
"<template><video controls preload=\"auto\" src=\"/files/abc/cut.webm\"",
),
(
"song.mp3",
"<template><audio controls preload=\"auto\" src=\"/files/abc/song.mp3\"",
),
(
"bed.ogg",
"<template><audio controls preload=\"auto\" src=\"/files/abc/bed.ogg\"",
),
] {
let html = md(&format!("Here:\n\n\n"));
assert!(html.contains(tag), "{file}: {html}");
assert!(
html.contains("title=\"take "2"\""),
"{file}: {html}"
);
assert!(!html.contains("<img"), "{file}: {html}");
assert!(
html.contains("data-w=\"play\" aria-label=\"Play take "2"\""),
"{file}: {html}"
);
let outside = html.split("<template>").next().unwrap_or("");
assert!(
!outside.contains("<audio") && !outside.contains("<video"),
"{file}: {html}"
);
}
assert!(md("").contains("aspect-ratio:16/9"));
assert!(md("").contains("<img src=\"/files/abc/a.png\""));
assert!(md("").contains("<video"));
let raw =
md("<b>hi</b>\n\n\n\n<video src=\"x.mp4\" onplay=\"alert(1)\"></video>");
assert_eq!(raw.matches("<video").count(), 1, "{raw}");
assert!(!raw.contains("onplay"), "{raw}");
}
#[test]
fn an_old_player_is_a_button_as_it_is_served() {
let old = "<p>a</p><audio controls preload=\"metadata\" src=\"/files/x/my%20take.mp3\" \
style=\"display:block;width:100%;height:54px\"></audio>\
<p class=\"doc-image doc-media\"><video controls preload=\"metadata\" src=\"/files/x/cut.mp4\" title=\"Cut & run\" \
style=\"max-width:100%\"></video></p><p>b</p>";
let new = lazy_players(old);
assert!(new.starts_with("<p>a</p><button"), "{new}");
assert!(new.ends_with("</template></p><p>b</p>"), "{new}");
assert!(new.contains("aria-label=\"Play my take.mp3\""), "{new}");
assert!(new.contains("aria-label=\"Play Cut & run\""), "{new}");
assert!(new.contains("<template><video controls preload=\"auto\" src=\"/files/x/cut.mp4\" title=\"Cut & run\""), "{new}");
assert!(!new.contains("preload=\"metadata\""), "{new}");
assert!(matches!(
lazy_players("<p>none</p>"),
std::borrow::Cow::Borrowed(_)
));
let one = media_body("/files/x/a%3Cb.ogg", "ogg");
assert!(one.contains("aria-label=\"Play a<b.ogg\""), "{one}");
assert!(one.contains("<template><audio controls"), "{one}");
}
}