feat: let a site render its xhtmlmp/html output as a Mews Profile page
A new mews_profile site setting switches the XHTML-MP 1.2 doctype, the conformance marker, the viewport tag and the default stylesheet link, and drops raw HTML passthrough and off-site or data: images that the profile's permitted-element list cannot vouch for.
This commit is contained in:
parent
1b47145522
commit
c9bde15f5c
9 changed files with 357 additions and 70 deletions
|
|
@ -6,6 +6,7 @@
|
|||
//! 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;
|
||||
|
|
@ -13,37 +14,62 @@ use crate::escape;
|
|||
/// The XML declaration, which only the XHTML-MP form carries.
|
||||
const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
|
||||
|
||||
const DOCTYPE: &str = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\" \
|
||||
\"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\">\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.
|
||||
pub fn document(doc: &Doc, title: &str, prolog: bool) -> String {
|
||||
/// `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(DOCTYPE);
|
||||
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");
|
||||
out.push_str(&format!("<head><title>{}</title></head>\n", escape::text(title)));
|
||||
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, &mut out);
|
||||
blocks(&doc.blocks, mews_hosts, &mut out);
|
||||
out.push_str("</body>\n</html>\n");
|
||||
out
|
||||
}
|
||||
|
||||
fn blocks(blocks: &[Block], out: &mut String) {
|
||||
fn blocks(blocks: &[Block], mews_hosts: Option<&[String]>, out: &mut String) {
|
||||
for block in blocks {
|
||||
self_block(block, out);
|
||||
self_block(block, mews_hosts, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn self_block(block: &Block, out: &mut String) {
|
||||
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)));
|
||||
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::Paragraph(inline) => out.push_str(&format!("<p>{}</p>\n", inlines(inline))),
|
||||
Block::CodeBlock { lines, .. } => {
|
||||
out.push_str("<pre>");
|
||||
out.push_str(&escape::text(&lines.join("\n")));
|
||||
|
|
@ -58,7 +84,7 @@ fn self_block(block: &Block, out: &mut String) {
|
|||
}
|
||||
Block::BlockQuote(inner) => {
|
||||
out.push_str("<blockquote>\n");
|
||||
blocks(inner, out);
|
||||
blocks(inner, mews_hosts, out);
|
||||
out.push_str("</blockquote>\n");
|
||||
}
|
||||
Block::List { ordered, start, items } => {
|
||||
|
|
@ -74,10 +100,10 @@ fn self_block(block: &Block, out: &mut String) {
|
|||
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)),
|
||||
[Block::Paragraph(inline)] => out.push_str(&inlines(inline, mews_hosts)),
|
||||
blocks_in_item => {
|
||||
out.push('\n');
|
||||
blocks(blocks_in_item, out);
|
||||
blocks(blocks_in_item, mews_hosts, out);
|
||||
}
|
||||
}
|
||||
out.push_str("</li>\n");
|
||||
|
|
@ -89,47 +115,55 @@ fn self_block(block: &Block, out: &mut String) {
|
|||
if !head.is_empty() {
|
||||
out.push_str("<tr>");
|
||||
for cell in head {
|
||||
out.push_str(&format!("<th>{}</th>", inlines(cell)));
|
||||
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)));
|
||||
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 passed through: this is the one family of formats where it
|
||||
// is already in the right language.
|
||||
// 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) => {
|
||||
out.push_str(html.trim_end());
|
||||
out.push('\n');
|
||||
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, out),
|
||||
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, out),
|
||||
Block::Gated { block, .. } => self_block(block, mews_hosts, out),
|
||||
Block::CardBreak { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn inlines(inline: &[Inline]) -> String {
|
||||
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))),
|
||||
Inline::Strong(inner) => out.push_str(&format!("<strong>{}</strong>", inlines(inner))),
|
||||
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)),
|
||||
Inline::Strike(inner) => out.push_str(&inlines(inner, mews_hosts)),
|
||||
Inline::Link { href, title, label } => {
|
||||
let title = title
|
||||
.as_deref()
|
||||
|
|
@ -138,10 +172,17 @@ fn inlines(inline: &[Inline]) -> String {
|
|||
out.push_str(&format!(
|
||||
"<a href=\"{}\"{title}>{}</a>",
|
||||
escape::attr(href),
|
||||
inlines(label)
|
||||
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)))
|
||||
|
|
@ -154,20 +195,124 @@ fn inlines(inline: &[Inline]) -> String {
|
|||
}
|
||||
Inline::SoftBreak => out.push('\n'),
|
||||
Inline::HardBreak => out.push_str("<br/>\n"),
|
||||
Inline::Html(html) => out.push_str(html),
|
||||
// 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 = Doc { blocks: Vec::new(), first_h1: None };
|
||||
assert!(document(&doc, "T", true).starts_with("<?xml"));
|
||||
assert!(document(&doc, "T", false).starts_with("<!DOCTYPE html"));
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue