itsybitsy/wap/src/xhtmlmp.rs

319 lines
13 KiB
Rust
Raw Normal View History

//! 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 `<?xml ...?>` 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 = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
/// The ordinary doctype, used whenever a site has not opted into Mews Profile
/// compliance.
const DOCTYPE_1_0: &str = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\" \
\"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\">\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 = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.2//EN\" \
\"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd\">\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 = "<meta name=\"mews-profile\" content=\"0.1\" />\n\
<meta name=\"viewport\" content=\"width=device-width\" />\n\
<link rel=\"stylesheet\" type=\"text/css\" href=\"mews-0.1.css\" />\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("<html xmlns=\"http://www.w3.org/1999/xhtml\">\n");
if mews_hosts.is_some() {
out.push_str(&format!(
"<head>\n<title>{}</title>\n{MEWS_HEAD_EXTRA}</head>\n",
escape::text(title)
));
} else {
out.push_str(&format!("<head><title>{}</title></head>\n", escape::text(title)));
}
out.push_str("<body>\n");
blocks(&doc.blocks, mews_hosts, &mut out);
out.push_str("</body>\n</html>\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!("<h{level}>{}</h{level}>\n", inlines(inline, mews_hosts)));
}
Block::Paragraph(inline) => {
out.push_str(&format!("<p>{}</p>\n", inlines(inline, mews_hosts)))
}
Block::CodeBlock { lines, .. } => {
out.push_str("<pre>");
out.push_str(&escape::text(&lines.join("\n")));
out.push_str("</pre>\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!("<pre title=\"{}\">", escape::attr(alt)));
out.push_str(&escape::text(&lines.join("\n")));
out.push_str("</pre>\n");
}
Block::BlockQuote(inner) => {
out.push_str("<blockquote>\n");
blocks(inner, mews_hosts, out);
out.push_str("</blockquote>\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("<ol>\n");
} else {
out.push_str("<ul>\n");
}
let _ = start;
for item in items {
out.push_str("<li>");
// A single paragraph needs no block wrapper inside the item.
match item.as_slice() {
[Block::Paragraph(inline)] => out.push_str(&inlines(inline, mews_hosts)),
blocks_in_item => {
out.push('\n');
blocks(blocks_in_item, mews_hosts, out);
}
}
out.push_str("</li>\n");
}
out.push_str(if *ordered { "</ol>\n" } else { "</ul>\n" });
}
Block::Table { head, rows, .. } => {
out.push_str("<table>\n");
if !head.is_empty() {
out.push_str("<tr>");
for cell in head {
out.push_str(&format!("<th>{}</th>", inlines(cell, mews_hosts)));
}
out.push_str("</tr>\n");
}
for row in rows {
out.push_str("<tr>");
for cell in row {
out.push_str(&format!("<td>{}</td>", inlines(cell, mews_hosts)));
}
out.push_str("</tr>\n");
}
out.push_str("</table>\n");
}
Block::Rule => out.push_str("<hr/>\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!("<code>{}</code>", escape::text(code))),
Inline::Emph(inner) => {
out.push_str(&format!("<em>{}</em>", inlines(inner, mews_hosts)))
}
Inline::Strong(inner) => {
out.push_str(&format!("<strong>{}</strong>", inlines(inner, mews_hosts)))
}
// XHTML-MP 1.0 has no <del>, and <strike> 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!(
"<a href=\"{}\"{title}>{}</a>",
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!(
"<img src=\"{}\" alt=\"{}\"{title}/>",
escape::attr(src),
escape::attr(&Doc::plain_text(alt))
));
}
Inline::SoftBreak => out.push('\n'),
Inline::HardBreak => out.push_str("<br/>\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("<?xml"));
assert!(document(&doc, "T", false, None).starts_with("<!DOCTYPE html"));
}
#[test]
fn a_plain_page_keeps_the_1_0_doctype_and_no_mews_head() {
let out = document(&empty(), "T", false, None);
assert!(out.contains("XHTML Mobile 1.0"), "{out}");
assert!(!out.contains("mews-profile"), "{out}");
}
#[test]
fn a_mews_page_carries_the_1_2_doctype_and_the_conformance_marker() {
let hosts = vec!["example.test".to_string()];
let out = document(&empty(), "T", false, Some(&hosts));
assert!(out.contains("XHTML Mobile 1.2"), "{out}");
assert!(out.contains("<meta name=\"mews-profile\" content=\"0.1\" />"), "{out}");
assert!(out.contains("<meta name=\"viewport\" content=\"width=device-width\" />"), "{out}");
assert!(
out.contains("<link rel=\"stylesheet\" type=\"text/css\" href=\"mews-0.1.css\" />"),
"{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("<div>x</div>".into())], first_h1: None };
let out = document(&doc, "T", false, Some(&hosts));
assert!(!out.contains("<div>"), "{out}");
let plain = document(&doc, "T", false, None);
assert!(plain.contains("<div>x</div>"), "{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("<img"), "{src}: {out}");
assert!(out.contains("a photo"), "{src}: {out}");
}
}
#[test]
fn a_plain_page_keeps_every_image_regardless_of_its_host() {
let out = document(&image_doc("https://elsewhere.test/i.png"), "T", false, None);
assert!(out.contains("<img src=\"https://elsewhere.test/i.png\""), "{out}");
}
}