//! embed HTML 渲染。
//!
//! 生成的所有属性值都经过 HTML 转义;`src` 只可能是注册表模板拼接的结果,
//! 或者经过校验的 http(s) 原始 URL,因此不会有脚本或事件属性注入。
use crate::{EmbedKind, Resolved};
/// HTML 属性/文本转义。
pub fn escape_html(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 8);
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
fn host_of(url: &str) -> String {
url::Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(str::to_string))
.unwrap_or_default()
}
/// 平台标识:`data-*` 上给前端留钩子。
fn data_attrs(resolved: &Resolved) -> String {
format!(
" data-platform=\"{}\" data-category=\"{}\"",
escape_html(resolved.platform),
resolved.category.as_str(),
)
}
/// iframe 的尺寸。
///
/// 平台声明了固定像素高时用它(Spotify 单曲 152px、网易云外链 66px、
/// Reddit 500px…),否则才按宽高比撑开。`background:#000` 只给视频类——
/// 推文、音乐、地图这些是浅色卡片,套黑底会在加载前露出一块黑矩形。
fn frame_style(resolved: &Resolved) -> String {
let base = "width:100%;max-width:640px;border:0;border-radius:12px;display:block";
let backdrop = if is_dark_player(resolved) {
";background:#000"
} else {
""
};
match resolved.height {
Some(height) => format!("{base};height:{height}px{backdrop}"),
None => format!("{base};aspect-ratio:{}{backdrop}", resolved.aspect.css()),
}
}
/// 视频类播放器才用黑底:其余嵌入是浅色卡片,不该有黑框。
fn is_dark_player(resolved: &Resolved) -> bool {
matches!(resolved.kind, EmbedKind::Video | EmbedKind::Audio)
|| matches!(
resolved.category,
crate::Category::Video | crate::Category::Live
)
}
const MEDIA_STYLE: &str =
"width:100%;max-width:640px;border-radius:12px;background:#000;display:block";
const IMAGE_STYLE: &str =
"max-width:100%;max-height:480px;border-radius:12px;display:block;object-fit:contain";
const CARD_STYLE: &str = "display:flex;align-items:center;gap:10px;max-width:100%;padding:10px 12px;border:1px solid rgba(127,127,127,.35);border-radius:12px;text-decoration:none;font-size:13px;line-height:1.4;color:inherit";
/// 把一个解析结果渲染成可注入消息流的 HTML 片段。
pub fn render_html(resolved: &Resolved) -> String {
let attrs = data_attrs(resolved);
let title = escape_html(&resolved.title);
match resolved.kind {
EmbedKind::Iframe => {
let Some(src) = resolved.src.as_deref() else {
return render_card(resolved, &attrs);
};
format!(
"",
src = escape_html(src),
style = frame_style(resolved),
)
}
EmbedKind::Video => {
let src = resolved.src.as_deref().unwrap_or(&resolved.url);
format!(
"",
src = escape_html(src),
style = MEDIA_STYLE,
)
}
EmbedKind::Audio => {
let src = resolved.src.as_deref().unwrap_or(&resolved.url);
format!(
"",
src = escape_html(src),
)
}
EmbedKind::Image => {
let src = resolved.src.as_deref().unwrap_or(&resolved.url);
format!(
"
",
src = escape_html(src),
style = IMAGE_STYLE,
)
}
EmbedKind::Link => render_card(resolved, &attrs),
}
}
fn render_card(resolved: &Resolved, attrs: &str) -> String {
let url = escape_html(&resolved.url);
let name = escape_html(resolved.platform_name);
let host = escape_html(&host_of(&resolved.url));
let raw_title = resolved.title.trim();
let headline = if raw_title.is_empty() || raw_title == resolved.platform_name {
name.clone()
} else {
escape_html(raw_title)
};
let thumb = match resolved.image_url.as_deref() {
Some(image) if !image.is_empty() => format!(
"
",
src = escape_html(image),
),
_ => String::new(),
};
let description = match resolved.description.as_deref().map(str::trim) {
Some(text) if !text.is_empty() => format!(
"{}",
escape_html(text),
),
_ => String::new(),
};
format!(
"\
{thumb}\
\
{headline}\
{description}\
{name} · {host}\
\
",
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_attribute_breakers() {
assert_eq!(escape_html(r#"a"b&'"#), "a"b<c>&'");
}
/// 卡片类嵌入不该带黑底,视频类才带;声明了固定高度就用它。
#[test]
fn frame_style_matches_the_embed_shape() {
// X 只出卡片:跨域 iframe 会停在 about:blank,不能再留 520px 的空框。
let tweet = crate::parse("https://x.com/u/status/1234567890").expect("tweet resolves");
let html = render_html(&tweet);
assert!(html.contains("gd-embed-link"), "{html}");
assert!(!html.contains("