//! 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 } 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!( "", 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!( "", 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!( "\"{title}\"", 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!( "\"\"", 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("