What a pasted link unfurls as: Open Graph and Twitter card tags for a title, a description,
a still picture and, optionally, a looping video. A site with a video gets the combination
reported to make Discord animate it (MediaCMS discussion 1183; PeerTube issue 5040): an
animated GIF as og:image shows only its first frame there, and Discord makes embeds only
for files under about 8 MB. A site without one gets a large still card.
Preview::tags refuses what a scraper would reject (a relative address, a video of odd
size), so a bad card cannot be built; missing checks rendered HTML for the tags that
matter, which is how a site's tests and checks read the live page.
Moved here from whiskers' site::preview (2026-10-05), where it was written; lmjtfy's own
tags in view.rs are the same job done by hand and are to be replaced by this.
14use std::fmt;
Where the site is: https://host or, for local runs, http://localhost[:port].
Not https://host (or http://localhost), or it has a path, query or trailing slash.
23 Origin(String),
A path that does not start with / or has spaces or a query.
25 Path(String),
A picture's query that is not unreserved or percent-encoded characters.
27 Query(String),
H.264 in yuv420p needs even dimensions; neither may be zero.
29 VideoSize(u32, u32),
A card smaller than this is not shown large.
36impl fmt::Display for Refused { 37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 38 match self { 39 Refused::Origin(o) => write!(f, "origin {o:?} is not https://host (or http://localhost)"), 40 Refused::Path(p) => write!(f, "path {p:?} is not an absolute path without a query"), 41 Refused::Query(q) => write!(f, "query {q:?} is not unreserved or percent-encoded characters"), 42 Refused::VideoSize(w, h) => write!(f, "video {w}x{h} needs even, non-zero sides"), 43 Refused::ImageSize(w, h) => write!(f, "image {w}x{h} is too small for a large card"), 44 Refused::Empty(what) => write!(f, "{what} is empty"), 45 Refused::Long(what, n) => write!(f, "{what} is {n} characters, too long to show whole"), 46 } 47 } 48} 49 50impl Origin { 51 pub fn parse(text: &str) -> Result<Origin, Refused> { 52 let bad = || Refused::Origin(text.to_owned()); 53 let (scheme, host) = text.split_once("://").ok_or_else(bad)?; 54 let local = host == "localhost" || host.starts_with("localhost:") || host == "127.0.0.1" || host.starts_with("127.0.0.1:"); 55 let ok_scheme = scheme == "https" || (scheme == "http" && local); 56 let ok_host = !host.is_empty() && host.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b':'); 57 if ok_scheme && ok_host { Ok(Origin(text.to_owned())) } else { Err(bad()) } 58 } 59 60 pub fn url(&self, path: &str) -> Result<String, Refused> { 61 if path.starts_with('/') && !path.contains(['?', '#', ' ']) { Ok(format!("{}{path}", self.0)) } else { Err(Refused::Path(path.to_owned())) } 62 }
url, with a query on it.
65 pub fn url_with_query(&self, path: &str, query: &str) -> Result<String, Refused> { 66 let safe = !query.is_empty() && query.bytes().all(|b| b.is_ascii_alphanumeric() || b"-._~%=&".contains(&b)); 67 if !safe { 68 return Err(Refused::Query(query.to_owned())); 69 } 70 Ok(format!("{}?{query}", self.url(path)?)) 71 }
A query that names which picture, for a site that draws one per page
(lmjtfy's /card.png?q=…): a=b&c=d in unreserved or percent-encoded
characters only, so it needs no escaping to sit in an attribute.
None is a still card (summary_large_image); Some is a player card.
104 pub video: Option<Video<'a>>,
Whether a tag is keyed by property (Open Graph) or name (everything else).
Open Graph titles are cut by the apps that show them; keep ours whole.
127impl Preview<'_> { 128 pub fn tags(&self) -> Result<Vec<Tag>, Refused> { 129 for (what, text, max) in [("title", self.title, MAX_TITLE), ("description", self.description, MAX_DESCRIPTION)] { 130 if text.trim().is_empty() { 131 return Err(Refused::Empty(what)); 132 } 133 if text.chars().count() > max { 134 return Err(Refused::Long(what, text.chars().count())); 135 } 136 } 137 if let Some(video) = &self.video { 138 let (vw, vh) = (video.width, video.height); 139 if vw == 0 || vh == 0 || vw % 2 != 0 || vh % 2 != 0 { 140 return Err(Refused::VideoSize(vw, vh)); 141 } 142 } 143 let (iw, ih) = (self.picture.width, self.picture.height); 144 // Twitter and Discord show a large card for at least 300 by 157. 145 if iw < 300 || ih < 157 { 146 return Err(Refused::ImageSize(iw, ih)); 147 } 148 let page = self.origin.url(self.page)?; 149 let image = match self.picture.query { 150 Some(query) => self.origin.url_with_query(self.picture.path, query)?, 151 None => self.origin.url(self.picture.path)?, 152 }; 153 let prop = |name, content: &str| Tag { key: Key::Property, name, content: content.to_owned() }; 154 let named = |name, content: &str| Tag { key: Key::Name, name, content: content.to_owned() }; 155 let mut tags = vec![ 156 named("description", self.description), 157 named("theme-color", self.theme_color), 158 prop("og:type", "website"), 159 prop("og:site_name", self.site_name), 160 prop("og:url", &page), 161 prop("og:title", self.title), 162 prop("og:description", self.description), 163 prop("og:image", &image), 164 prop("og:image:type", "image/png"), 165 prop("og:image:width", &iw.to_string()), 166 prop("og:image:height", &ih.to_string()), 167 prop("og:image:alt", self.picture.alt), 168 ]; 169 match &self.video { 170 Some(video) => { 171 let url = self.origin.url(video.path)?; 172 tags.extend([ 173 prop("og:video", &url), 174 prop("og:video:url", &url), 175 prop("og:video:secure_url", &url), 176 prop("og:video:type", "video/mp4"), 177 prop("og:video:width", &video.width.to_string()), 178 prop("og:video:height", &video.height.to_string()), 179 named("twitter:card", "player"), 180 named("twitter:title", self.title), 181 named("twitter:description", self.description), 182 named("twitter:image", &image), 183 named("twitter:player", &url), 184 named("twitter:player:stream", &url), 185 named("twitter:player:stream:content_type", "video/mp4"), 186 named("twitter:player:width", &video.width.to_string()), 187 named("twitter:player:height", &video.height.to_string()), 188 ]); 189 } 190 None => tags.extend([ 191 // The picture is shown large, under the text. 192 named("twitter:card", "summary_large_image"), 193 named("twitter:title", self.title), 194 named("twitter:description", self.description), 195 named("twitter:image", &image), 196 ]), 197 } 198 Ok(tags) 199 }
The tags as HTML for the page's <head>, one per line.
207impl Tag { 208 pub fn html(&self) -> String { 209 let key = match self.key { 210 Key::Property => "property", 211 Key::Name => "name", 212 }; 213 format!("<meta {key}=\"{}\" content=\"{}\">", self.name, escape(&self.content)) 214 } 215} 216 217fn escape(text: &str) -> String { 218 text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) 219}
Which card a page is meant to unfurl as.
Every tag a scraper needs for a large still card.
229pub const REQUIRED_STILL: [&str; 7] = ["og:title", "og:description", "og:image", "og:image:type", "og:image:width", "og:image:height", "twitter:image"];
What a video card needs besides the still's.
232pub const REQUIRED_VIDEO_ADDS: [&str; 10] = [ 233 "og:video", 234 "og:video:type", 235 "og:video:width", 236 "og:video:height", 237 "twitter:player", 238 "twitter:player:stream", 239 "twitter:player:stream:content_type", 240 "twitter:player:width", 241 "twitter:player:height", 242 "twitter:card", 243];
Every tag a scraper needs for kind, by name (twitter:card is checked for its value too).
The (name, content) of every <meta property|name=… content=…> in some HTML, unescaped.
Reads the shape this crate writes (and maud's), not arbitrary HTML.
257pub fn read_meta(html: &str) -> Vec<(String, String)> { 258 let mut found = Vec::new(); 259 let mut rest = html; 260 while let Some(at) = rest.find("<meta ") { 261 let tag = &rest[at..]; 262 let end = tag.find('>').map_or(tag.len(), |i| i + 1); 263 let tag_text = &tag[..end]; 264 if let (Some(name), Some(content)) = (attr(tag_text, "property").or_else(|| attr(tag_text, "name")), attr(tag_text, "content")) { 265 found.push((name, content)); 266 } 267 rest = &rest[end..]; 268 } 269 found 270}
What is wrong with a page's tags: a required one absent or empty, an address that is not absolute, or a card of the wrong kind. Empty means a scraper will be satisfied.
280pub fn missing(html: &str, kind: Kind) -> Vec<String> { 281 let meta = read_meta(html); 282 let get = |name: &str| meta.iter().find(|(n, _)| n == name).map(|(_, c)| c.as_str()); 283 let mut problems = Vec::new(); 284 for name in required(kind) { 285 match get(name) { 286 None => problems.push(format!("{name} is missing")), 287 Some("") => problems.push(format!("{name} is empty")), 288 Some(_) => {} 289 } 290 } 291 for name in ["og:image", "og:video", "twitter:image", "twitter:player", "twitter:player:stream"] { 292 if let Some(url) = get(name) 293 && !(url.starts_with("https://") || url.starts_with("http://")) 294 { 295 problems.push(format!("{name} is not an absolute address: {url}")); 296 } 297 } 298 let card = match kind { 299 Kind::Still => "summary_large_image", 300 Kind::Video => "player", 301 }; 302 if get("twitter:card").is_some_and(|got| got != card) { 303 problems.push(format!("twitter:card is not {card}")); 304 } 305 if get("og:video:type").is_some_and(|kind| kind != "video/mp4") { 306 problems.push("og:video:type is not video/mp4".to_owned()); 307 } 308 problems 309}
311#[cfg(test)] 312mod tests { 313 use super::*; 314 315 fn origin() -> Origin { 316 Origin::parse("https://whiskers.example").unwrap() 317 } 318 319 fn preview(origin: &Origin) -> Preview<'_> { 320 Preview { 321 origin, 322 site_name: "Whiskers", 323 title: "Whiskers, a talking cat", 324 description: "A cat for a young child & her \"grown-ups\".", 325 page: "/", 326 picture: Picture { path: "/preview/card.png", query: None, width: 1200, height: 630, alt: "The cat" }, 327 video: Some(Video { path: "/preview/loop.mp4", width: 1200, height: 630 }), 328 theme_color: "#f386a1", 329 } 330 } 331 332 fn still(origin: &Origin) -> Preview<'_> { 333 Preview { video: None, ..preview(origin) } 334 } 335 336 #[test] 337 fn the_page_carries_every_tag_a_scraper_needs() { 338 let origin = origin(); 339 let html = preview(&origin).html().unwrap(); 340 assert_eq!(missing(&html, Kind::Video), Vec::<String>::new()); 341 let meta = read_meta(&html); 342 let get = |name| meta.iter().find(|(n, _)| n == name).unwrap().1.clone(); 343 assert_eq!(get("og:image"), "https://whiskers.example/preview/card.png"); 344 assert_eq!(get("og:video"), "https://whiskers.example/preview/loop.mp4"); 345 assert_eq!(get("twitter:player"), get("og:video")); 346 assert_eq!(get("twitter:player:stream"), get("og:video")); 347 assert_eq!(get("twitter:card"), "player"); 348 assert_eq!(get("og:video:type"), "video/mp4"); 349 assert_eq!((get("og:video:width"), get("og:video:height")), ("1200".into(), "630".into())); 350 } 351 352 #[test] 353 fn a_site_without_a_video_gets_a_large_still_and_no_video_tags() { 354 let origin = origin(); 355 let html = still(&origin).html().unwrap(); 356 assert_eq!(missing(&html, Kind::Still), Vec::<String>::new()); 357 let meta = read_meta(&html); 358 assert!(meta.contains(&("twitter:card".to_owned(), "summary_large_image".to_owned()))); 359 assert!(meta.iter().all(|(name, _)| !name.contains("video") && !name.contains("player"))); 360 // The same page, read as a video card, is what is wrong with it. 361 assert!(missing(&html, Kind::Video).contains(&"og:video is missing".to_owned())); 362 } 363 364 #[test] 365 fn a_picture_that_is_drawn_per_page_may_carry_a_safe_query() { 366 let origin = origin(); 367 let mut p = still(&origin); 368 p.picture.query = Some("q=is%20it%20ok&a=1f2e3d4c"); 369 let meta = read_meta(&p.html().unwrap()); 370 let get = |name| meta.iter().find(|(n, _)| n == name).unwrap().1.clone(); 371 assert_eq!(get("og:image"), "https://whiskers.example/preview/card.png?q=is%20it%20ok&a=1f2e3d4c"); 372 assert_eq!(get("twitter:image"), get("og:image")); 373 for bad in ["q=a b", "q=\"", "q=<", "a#b", ""] { 374 let mut p = still(&origin); 375 p.picture.query = Some(bad); 376 assert!(matches!(p.tags(), Err(Refused::Query(_))), "{bad:?}"); 377 } 378 } 379 380 #[test] 381 fn text_is_escaped_and_reads_back() { 382 let origin = origin(); 383 let html = preview(&origin).html().unwrap(); 384 assert!(html.contains("A cat for a young child & her "grown-ups".")); 385 let meta = read_meta(&html); 386 assert!(meta.contains(&("og:description".to_owned(), "A cat for a young child & her \"grown-ups\".".to_owned()))); 387 } 388 389 #[test] 390 fn no_tag_is_written_twice() { 391 let origin = origin(); 392 for p in [preview(&origin), still(&origin)] { 393 let tags = p.tags().unwrap(); 394 let mut names: Vec<_> = tags.iter().map(|t| t.name).collect(); 395 names.sort_unstable(); 396 let n = names.len(); 397 names.dedup(); 398 assert_eq!(names.len(), n); 399 } 400 } 401 402 #[test] 403 fn what_a_scraper_would_reject_cannot_be_built() { 404 let origin = origin(); 405 let mut p = preview(&origin); 406 p.video.as_mut().unwrap().width = 1201; 407 assert_eq!(p.tags().unwrap_err(), Refused::VideoSize(1201, 630)); 408 let mut p = preview(&origin); 409 p.video.as_mut().unwrap().height = 0; 410 assert!(matches!(p.tags(), Err(Refused::VideoSize(..)))); 411 let mut p = preview(&origin); 412 p.picture.width = 100; 413 assert!(matches!(p.tags(), Err(Refused::ImageSize(..)))); 414 let mut p = preview(&origin); 415 p.picture.path = "preview/card.png"; 416 assert!(matches!(p.tags(), Err(Refused::Path(_)))); 417 let mut p = preview(&origin); 418 p.video.as_mut().unwrap().path = "/loop.mp4?x=1"; 419 assert!(matches!(p.tags(), Err(Refused::Path(_)))); 420 let mut p = preview(&origin); 421 p.title = ""; 422 assert_eq!(p.tags().unwrap_err(), Refused::Empty("title")); 423 let long = "x".repeat(71); 424 let mut p = preview(&origin); 425 p.title = &long; 426 assert!(matches!(p.tags(), Err(Refused::Long("title", 71)))); 427 } 428 429 #[test] 430 fn origins_are_https_hosts_or_localhost() { 431 for ok in ["https://whiskers.lmjtfy.fun", "http://localhost:8787", "http://127.0.0.1:8787", "https://a-b.c"] { 432 assert!(Origin::parse(ok).is_ok(), "{ok}"); 433 } 434 for bad in ["http://whiskers.lmjtfy.fun", "https://x/", "https://x/path", "whiskers.lmjtfy.fun", "https://", "https://x y", "javascript://x", "https://x?a=1"] { 435 assert!(Origin::parse(bad).is_err(), "{bad}"); 436 } 437 } 438 439 #[test] 440 fn missing_names_each_problem() { 441 let problems = missing( 442 "<meta property=\"og:title\" content=\"x\"><meta name=\"twitter:card\" content=\"summary\"><meta property=\"og:image\" content=\"/card.png\">", 443 Kind::Video, 444 ); 445 assert!(problems.contains(&"og:video is missing".to_owned())); 446 assert!(problems.contains(&"twitter:card is not player".to_owned())); 447 assert!(problems.iter().any(|p| p.starts_with("og:image is not an absolute address"))); 448 } 449 450 #[test] 451 fn the_required_names_are_what_the_tags_write() { 452 let origin = origin(); 453 for (p, kind) in [(preview(&origin), Kind::Video), (still(&origin), Kind::Still)] { 454 let written: Vec<_> = p.tags().unwrap().iter().map(|t| t.name).collect(); 455 for name in required(kind) { 456 assert!(written.contains(&name), "{name}"); 457 } 458 } 459 } 460}