Skip to content
Clone
8923bc0
181 lines 7.7 KB Rust
//! 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("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => out.push(ch),
}
}
out
}
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 = {
format!(
" data-platform=\"{}\" data-category=\"{}\"",
escape_html(resolved.platform),
resolved.category.as_str(),
)
};
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!(
"<iframe class=\"gd-embed gd-embed-frame\" src=\"{src}\" title=\"{title}\" loading=\"lazy\" \
referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen \
allow=\"accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share;fullscreen\" \
sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation\" \
style=\"{style}\"{attrs}></iframe>",
src = escape_html(src),
style = {
let base =
"width:100%;max-width:640px;border:0;border-radius:12px;display:block";
let backdrop = if matches!(resolved.kind, EmbedKind::Video | EmbedKind::Audio)
|| matches!(
resolved.category,
crate::Category::Video | crate::Category::Live
) {
";background:#000"
} else {
""
};
match resolved.height {
Some(height) => format!("{base};height:{height}px{backdrop}"),
None => format!("{base};aspect-ratio:{}{backdrop}", resolved.aspect.css()),
}
},
)
}
EmbedKind::Video => {
let src = resolved.src.as_deref().unwrap_or(&resolved.url);
format!(
"<video class=\"gd-embed gd-embed-video\" src=\"{src}\" controls preload=\"metadata\" \
playsinline style=\"{style}\"{attrs}></video>",
src = escape_html(src),
style = MEDIA_STYLE,
)
}
EmbedKind::Audio => {
let src = resolved.src.as_deref().unwrap_or(&resolved.url);
format!(
"<audio class=\"gd-embed gd-embed-audio\" src=\"{src}\" controls preload=\"metadata\" \
style=\"width:100%;max-width:640px;display:block\"{attrs}></audio>",
src = escape_html(src),
)
}
EmbedKind::Image => {
let src = resolved.src.as_deref().unwrap_or(&resolved.url);
format!(
"<img class=\"gd-embed gd-embed-image\" src=\"{src}\" alt=\"{title}\" loading=\"lazy\" \
style=\"{style}\"{attrs} />",
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(&{
let url = &resolved.url;
url::Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(str::to_string))
.unwrap_or_default()
});
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!(
"<img class=\"gd-embed-thumb\" src=\"{src}\" alt=\"\" loading=\"lazy\" \
style=\"width:48px;height:48px;border-radius:8px;object-fit:cover;flex:none\" />",
src = escape_html(image),
),
_ => String::new(),
};
let description = match resolved.description.as_deref().map(str::trim) {
Some(text) if !text.is_empty() => format!(
"<span class=\"gd-embed-desc\" style=\"font-size:12px;opacity:.75;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden\">{}</span>",
escape_html(text),
),
_ => String::new(),
};
format!(
"<a class=\"gd-embed gd-embed-link\" href=\"{url}\" target=\"_blank\" \
rel=\"noopener noreferrer nofollow\" style=\"{CARD_STYLE}\"{attrs}>\
{thumb}\
<span class=\"gd-embed-meta\" style=\"display:flex;flex-direction:column;gap:2px;min-width:0\">\
<span class=\"gd-embed-title\" style=\"font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\">{headline}</span>\
{description}\
<span class=\"gd-embed-host\" style=\"font-size:12px;opacity:.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\">{name} · {host}</span>\
</span>\
</a>",
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_attribute_breakers() {
assert_eq!(escape_html(r#"a"b<c>&'"#), "a&quot;b&lt;c&gt;&amp;&#39;");
}
/// 卡片类嵌入不该带黑底,视频类才带;声明了固定高度就用它。
#[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("<iframe"), "{html}");
let video = crate::parse("https://www.youtube.com/watch?v=dQw4w9WgXcQ").expect("video");
let html = render_html(&video);
assert!(html.contains("aspect-ratio:16 / 9"), "{html}");
assert!(
html.contains("background:#000"),
"视频播放器保留黑底: {html}"
);
let song = crate::parse("https://music.163.com/#/song?id=123").expect("song");
let html = render_html(&song);
assert!(html.contains("height:66px"), "{html}");
// 固定高度仍然生效(Reddit oEmbed 声明 500px)。
let thread =
crate::parse("https://www.reddit.com/r/rust/comments/abc/title").expect("reddit");
let html = render_html(&thread);
assert!(html.contains("height:500px"), "{html}");
}
}