//! XHTML Mobile Profile markup, and the `text/html` body derived from it. //! //! One document serves both: a WAP 2.0 client needs well-formed XML with the //! prolog and the Mobile Profile doctype, while a browser parsing the same bytes //! as `text/html` tolerates the leading `` as a bogus comment but //! logs a warning for it. So the HTML form is the same markup with that one line //! removed. use itsybitsy_core::config::normalize_host; use itsybitsy_core::ir::{Block, Doc, Inline}; use crate::escape; /// The XML declaration, which only the XHTML-MP form carries. const PROLOG: &str = "\n"; /// The ordinary doctype, used whenever a site has not opted into Mews Profile /// compliance. const DOCTYPE_1_0: &str = "\n"; /// Mews Profile pages carry the XHTML-MP 1.2 doctype instead (mews.page/spec /// 3.1), which is what a Mews DTD or validator checks a page against. const DOCTYPE_1_2: &str = "\n"; /// The conformance marker (3.2), viewport tag (3.3) and default stylesheet /// link (5.2), pinned to spec version 0.1: a site opts into the profile as a /// whole, not into picking its own version or stylesheet path. const MEWS_HEAD_EXTRA: &str = "\n\ \n\ \n"; /// Build the whole document. `prolog` distinguishes the two media types. /// `mews_hosts` is `Some` with the site's own hostnames when it has opted into /// Mews Profile compliance, and `None` otherwise. pub fn document(doc: &Doc, title: &str, prolog: bool, mews_hosts: Option<&[String]>) -> String { let mut out = String::new(); if prolog { out.push_str(PROLOG); } out.push_str(if mews_hosts.is_some() { DOCTYPE_1_2 } else { DOCTYPE_1_0 }); out.push_str("\n"); if mews_hosts.is_some() { out.push_str(&format!( "\n{}\n{MEWS_HEAD_EXTRA}\n", escape::text(title) )); } else { out.push_str(&format!("{}\n", escape::text(title))); } out.push_str("\n"); blocks(&doc.blocks, mews_hosts, &mut out); out.push_str("\n\n"); out } fn blocks(blocks: &[Block], mews_hosts: Option<&[String]>, out: &mut String) { for block in blocks { self_block(block, mews_hosts, out); } } fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) { match block { Block::Heading { level, inline } => { let level = (*level).clamp(1, 6); out.push_str(&format!("{}\n", inlines(inline, mews_hosts))); } Block::Paragraph(inline) => { out.push_str(&format!("

{}

\n", inlines(inline, mews_hosts))) } Block::CodeBlock { lines, .. } => { out.push_str("
");
            out.push_str(&escape::text(&lines.join("\n")));
            out.push_str("
\n"); } Block::Art { alt, lines } => { // The label is the only description a client that cannot show the // art would have, so it is kept as the title attribute. out.push_str(&format!("
", escape::attr(alt)));
            out.push_str(&escape::text(&lines.join("\n")));
            out.push_str("
\n"); } Block::BlockQuote(inner) => { out.push_str("
\n"); blocks(inner, mews_hosts, out); out.push_str("
\n"); } Block::List { ordered, start, items } => { if *ordered { // XHTML-MP has no `start` attribute, so a list beginning // elsewhere than 1 cannot be expressed; the numbers restart. out.push_str("
    \n"); } else { out.push_str("
\n" } else { "\n" }); } Block::Table { head, rows, .. } => { out.push_str("\n"); if !head.is_empty() { out.push_str(""); for cell in head { out.push_str(&format!("", inlines(cell, mews_hosts))); } out.push_str("\n"); } for row in rows { out.push_str(""); for cell in row { out.push_str(&format!("", inlines(cell, mews_hosts))); } out.push_str("\n"); } out.push_str("
{}
{}
\n"); } Block::Rule => out.push_str("
\n"), // Raw HTML is ordinarily passed through, since this is the one family // of formats where it is already in the right language. A Mews page // cannot make that guarantee — the markup might use an element or // attribute the profile excludes — so it is dropped instead. Block::Html(html) => { if mews_hosts.is_none() { out.push_str(html.trim_end()); out.push('\n'); } } // Alignment reaches only the fixed-width text formats. Pagination is a // WML concern; a browser scrolls one document. Block::Aligned { block, .. } => self_block(block, mews_hosts, out), // Gates are filtered out before rendering; keeping the content is the // harmless reading if one ever arrives here. Block::Gated { block, .. } => self_block(block, mews_hosts, out), Block::CardBreak { .. } => {} } } fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String { let mut out = String::new(); for item in inline { match item { Inline::Text(text) => out.push_str(&escape::text(text)), Inline::Code(code) => out.push_str(&format!("{}", escape::text(code))), Inline::Emph(inner) => { out.push_str(&format!("{}", inlines(inner, mews_hosts))) } Inline::Strong(inner) => { out.push_str(&format!("{}", inlines(inner, mews_hosts))) } // XHTML-MP 1.0 has no , and is not in the profile, so // the text survives without its markup rather than being dropped. Inline::Strike(inner) => out.push_str(&inlines(inner, mews_hosts)), Inline::Link { href, title, label } => { let title = title .as_deref() .map(|t| format!(" title=\"{}\"", escape::attr(t))) .unwrap_or_default(); out.push_str(&format!( "{}", escape::attr(href), inlines(label, mews_hosts) )); } Inline::Image { src, title, alt } => { // A Mews page's image must point at the same site and never be // a `data:` URI (SPEC.md 4.2); one that does not is dropped to // its alt text rather than rendered non-conformant. if mews_hosts.is_some_and(|hosts| !same_site_image(src, hosts)) { out.push_str(&escape::text(&Doc::plain_text(alt))); continue; } let title = title .as_deref() .map(|t| format!(" title=\"{}\"", escape::attr(t))) .unwrap_or_default(); out.push_str(&format!( "\"{}\"{title}/", escape::attr(src), escape::attr(&Doc::plain_text(alt)) )); } Inline::SoftBreak => out.push('\n'), Inline::HardBreak => out.push_str("
\n"), // See the Block::Html arm above: unverifiable in a Mews page. Inline::Html(html) => { if mews_hosts.is_none() { out.push_str(html); } } } } out } /// Whether an image `src` satisfies the Mews same-site rule (SPEC.md 4.2). A /// `data:` URI never qualifies. A relative or root-relative URL always does, /// since it resolves against the page's own origin by definition. An absolute /// or protocol-relative URL qualifies when its host equals one of the site's /// own hostnames or is a subdomain of one — a looser stand-in for "the same /// registered domain" that needs no public-suffix list, adequate for hosts /// the site itself configured. fn same_site_image(src: &str, hosts: &[String]) -> bool { if src.len() >= 5 && src.as_bytes()[..5].eq_ignore_ascii_case(b"data:") { return false; } let Some(raw_host) = url_host(src) else { return true }; let Some(host) = normalize_host(raw_host) else { return false }; hosts.iter().any(|allowed| host == *allowed || host.ends_with(&format!(".{allowed}"))) } /// The host component of an absolute (`scheme://host/...`) or /// protocol-relative (`//host/...`) URL, or `None` for anything else: a bare /// or root-relative path has no host of its own, so it always resolves /// against the page's own site. fn url_host(src: &str) -> Option<&str> { let rest = match src.split_once("://") { Some((_scheme, rest)) => rest, None => src.strip_prefix("//")?, }; Some(rest.split(['/', '?', '#']).next().unwrap_or(rest)) } #[cfg(test)] mod tests { use super::*; fn empty() -> Doc { Doc { blocks: Vec::new(), first_h1: None } } #[test] fn the_prolog_is_present_only_when_asked_for() { let doc = empty(); assert!(document(&doc, "T", true, None).starts_with(""), "{out}"); assert!(out.contains(""), "{out}"); assert!( out.contains(""), "{out}" ); } #[test] fn a_mews_page_drops_raw_html_it_cannot_vouch_for() { let hosts = vec!["example.test".to_string()]; let doc = Doc { blocks: vec![Block::Html("
x
".into())], first_h1: None }; let out = document(&doc, "T", false, Some(&hosts)); assert!(!out.contains("
"), "{out}"); let plain = document(&doc, "T", false, None); assert!(plain.contains("
x
"), "{plain}"); } fn image_doc(src: &str) -> Doc { Doc { blocks: vec![Block::Paragraph(vec![Inline::Image { src: src.into(), title: None, alt: vec![Inline::Text("a photo".into())], }])], first_h1: None, } } #[test] fn a_mews_page_keeps_a_relative_or_same_site_image() { let hosts = vec!["example.test".to_string()]; for src in ["/i.png", "i.png", "https://example.test/i.png", "//img.example.test/i.png"] { let out = document(&image_doc(src), "T", false, Some(&hosts)); assert!(out.contains(&format!("src=\"{src}\"")), "{src}: {out}"); } } #[test] fn a_mews_page_drops_an_off_site_or_data_image_to_its_alt_text() { let hosts = vec!["example.test".to_string()]; for src in ["https://elsewhere.test/i.png", "data:image/png;base64,AAAA"] { let out = document(&image_doc(src), "T", false, Some(&hosts)); assert!(!out.contains("