feat: declare a Mews page's feed, keep its prolog, and lift link-only list items
All checks were successful
CI / check (push) Successful in 26s
CI / release (push) Successful in 1m1s

This commit is contained in:
randogoth 2026-10-11 16:18:05 +03:00
parent 3e659df9dd
commit c08b03db7b
9 changed files with 223 additions and 116 deletions

View file

@ -1,9 +1,9 @@
//! The WAP family: XHTML Mobile Profile, and the `text/html` body derived from it.
//!
//! One crate rather than one per format, because the HTML form is the XHTML-MP
//! bytes with the XML declaration removed, and because WML's deck configuration
//! is shared with nothing outside this family. Splitting them would duplicate the
//! escaper and the markup builder.
//! bytes, less the XML declaration on a page that is not a Mews page, and because
//! WML's deck configuration is shared with nothing outside this family. Splitting
//! them would duplicate the escaper and the markup builder.
#[cfg(feature = "wml")]
mod deck;
@ -29,7 +29,7 @@ impl Renderer for XhtmlMp {
}
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true, ctx.mews_hosts).into_bytes()))
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true, ctx.mews).into_bytes()))
}
}
@ -46,7 +46,7 @@ impl Renderer for Html {
}
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false, ctx.mews_hosts).into_bytes()))
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false, ctx.mews).into_bytes()))
}
}
@ -87,7 +87,7 @@ mod tests {
title: "The Title",
settings: &settings,
width: None,
mews_hosts: None,
mews: None,
};
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
}
@ -184,8 +184,7 @@ mod tests {
first_h1: None,
};
let settings = PageSettings::default();
let ctx =
RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None };
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None };
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
assert!(out.contains("<pre title=\"Dragon\">/\\</pre>"), "{out}");
}
@ -202,8 +201,7 @@ mod tests {
first_h1: None,
};
let settings = PageSettings::default();
let ctx =
RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None };
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None };
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
assert!(out.contains("<body>\n</body>"), "{out}");
}
@ -226,8 +224,7 @@ mod deck_tests {
// Card dividers are comment directives, so the IR is built by hand here
// the way `parse::document` would produce it.
let doc = with_card_breaks(markdown);
let ctx =
RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews_hosts: None };
let ctx = RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews: None };
Wml.render(&doc, &ctx).unwrap()
}
@ -488,7 +485,7 @@ mod oracle_dump {
title: "Trail",
settings: &settings,
width: None,
mews_hosts: None,
mews: None,
};
let out = Wml.render(&doc, &ctx).unwrap();
std::fs::write(format!("/tmp/mine-{name}.wml"), out.body).unwrap();

View file

@ -3,15 +3,18 @@
//! 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.
//! logs a warning for it. So an ordinary page's HTML form is the same markup
//! with that one line removed. A Mews page keeps it in both forms: see
//! [`document`].
use itsybitsy_core::config::normalize_host;
use itsybitsy_core::ir::{Block, Doc, Inline};
use itsybitsy_core::render::Mews;
use crate::escape;
/// The XML declaration, which only the XHTML-MP form carries.
/// The XML declaration, which every form of a Mews page carries and only the
/// XHTML-MP form of an ordinary one does.
const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
/// The ordinary doctype, used whenever a site has not opted into Mews Profile
@ -32,44 +35,55 @@ const MEWS_HEAD_EXTRA: &str = "<meta name=\"mews-profile\" content=\"0.1\" />\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 {
/// `mews` is `Some` when the site has opted into Mews Profile compliance, and
/// `None` otherwise.
pub fn document(doc: &Doc, title: &str, prolog: bool, mews: Option<Mews<'_>>) -> String {
let mut out = String::new();
if prolog {
// A Mews page carries the declaration whatever its media type: 3.1 requires
// it, and a validator or directory checker judges the bytes it was handed,
// which for anything fetching the way a browser does is the `text/html`
// form. The cost is the console warning an HTML parser logs for a comment
// it did not expect; it reads the line as a comment before the doctype, so
// nothing about how the page renders changes.
if prolog || mews.is_some() {
out.push_str(PROLOG);
}
out.push_str(if mews_hosts.is_some() { DOCTYPE_1_2 } else { DOCTYPE_1_0 });
out.push_str(if mews.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(&format!("<head>\n<title>{}</title>\n", escape::text(title)));
if mews.is_some() {
out.push_str(MEWS_HEAD_EXTRA);
}
// The feed is declared here rather than linked from a page body, which is
// the only place a client reading the head can find it (6.2). The label is
// the spec example's, since the attribute names the feed rather than
// whichever page happens to carry the declaration.
if let Some(feed) = mews.and_then(|mews| mews.feed) {
out.push_str(&format!(
"<link rel=\"alternate\" type=\"application/atom+xml\" href=\"{}\" title=\"Posts\" />\n",
escape::attr(feed)
));
}
out.push_str("</head>\n");
out.push_str("<body>\n");
blocks(&doc.blocks, mews_hosts, &mut out);
blocks(&doc.blocks, mews, &mut out);
out.push_str("</body>\n</html>\n");
out
}
fn blocks(blocks: &[Block], mews_hosts: Option<&[String]>, out: &mut String) {
fn blocks(blocks: &[Block], mews: Option<Mews<'_>>, out: &mut String) {
for block in blocks {
self_block(block, mews_hosts, out);
self_block(block, mews, out);
}
}
fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
fn self_block(block: &Block, mews: Option<Mews<'_>>, 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)))
out.push_str(&format!("<h{level}>{}</h{level}>\n", inlines(inline, mews)));
}
Block::Paragraph(inline) => out.push_str(&format!("<p>{}</p>\n", inlines(inline, mews))),
Block::CodeBlock { lines, .. } => {
out.push_str("<pre>");
out.push_str(&escape::text(&lines.join("\n")));
@ -84,7 +98,7 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
}
Block::BlockQuote(inner) => {
out.push_str("<blockquote>\n");
blocks(inner, mews_hosts, out);
blocks(inner, mews, out);
out.push_str("</blockquote>\n");
}
Block::List { ordered, start, items } => {
@ -100,10 +114,10 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, 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, mews_hosts)),
[Block::Paragraph(inline)] => out.push_str(&inlines(inline, mews)),
blocks_in_item => {
out.push('\n');
blocks(blocks_in_item, mews_hosts, out);
blocks(blocks_in_item, mews, out);
}
}
out.push_str("</li>\n");
@ -115,14 +129,14 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
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(&format!("<th>{}</th>", inlines(cell, mews)));
}
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(&format!("<td>{}</td>", inlines(cell, mews)));
}
out.push_str("</tr>\n");
}
@ -134,36 +148,34 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
// 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() {
if mews.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),
Block::Aligned { block, .. } => self_block(block, mews, 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::Gated { block, .. } => self_block(block, mews, out),
Block::CardBreak { .. } => {}
}
}
fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String {
fn inlines(inline: &[Inline], mews: Option<Mews<'_>>) -> 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::Emph(inner) => out.push_str(&format!("<em>{}</em>", inlines(inner, mews))),
Inline::Strong(inner) => {
out.push_str(&format!("<strong>{}</strong>", inlines(inner, mews_hosts)))
out.push_str(&format!("<strong>{}</strong>", inlines(inner, mews)))
}
// 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::Strike(inner) => out.push_str(&inlines(inner, mews)),
Inline::Link { href, title, label } => {
let title = title
.as_deref()
@ -172,14 +184,14 @@ fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String {
out.push_str(&format!(
"<a href=\"{}\"{title}>{}</a>",
escape::attr(href),
inlines(label, mews_hosts)
inlines(label, mews)
));
}
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)) {
if mews.is_some_and(|mews| !same_site_image(src, mews.hosts)) {
out.push_str(&escape::text(&Doc::plain_text(alt)));
continue;
}
@ -197,7 +209,7 @@ fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String {
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() {
if mews.is_none() {
out.push_str(html);
}
}
@ -242,13 +254,50 @@ mod tests {
Doc { blocks: Vec::new(), first_h1: None }
}
/// A site that has opted into the profile and declares no feed.
fn mews(hosts: &[String]) -> Mews<'_> {
Mews { hosts, feed: None }
}
#[test]
fn the_prolog_is_present_only_when_asked_for() {
fn an_ordinary_page_carries_the_prolog_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_mews_page_carries_the_prolog_in_both_forms() {
// The text/html form is what a validator fetching like a browser is
// handed, and 3.1 is a MUST on the bytes it reads.
let hosts = vec!["example.test".to_string()];
for xml in [true, false] {
let out = document(&empty(), "T", xml, Some(mews(&hosts)));
assert!(out.starts_with("<?xml version=\"1.0\" encoding=\"utf-8\"?>"), "{out}");
}
}
#[test]
fn the_feed_is_declared_in_the_head_when_the_site_names_one() {
let hosts = vec!["example.test".to_string()];
let feed = Mews { hosts: &hosts, feed: Some("https://example.test/feed.xml") };
let out = document(&empty(), "T", false, Some(feed));
assert!(
out.contains(
"<link rel=\"alternate\" type=\"application/atom+xml\" \
href=\"https://example.test/feed.xml\" title=\"Posts\" />"
),
"{out}"
);
assert!(
out.split("</head>").next().is_some_and(|head| head.contains("alternate")),
"{out}"
);
let none = document(&empty(), "T", false, Some(mews(&hosts)));
assert!(!none.contains("alternate"), "{none}");
}
#[test]
fn a_plain_page_keeps_the_1_0_doctype_and_no_mews_head() {
let out = document(&empty(), "T", false, None);
@ -259,7 +308,7 @@ mod tests {
#[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));
let out = document(&empty(), "T", false, Some(mews(&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}");
@ -273,7 +322,7 @@ mod tests {
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));
let out = document(&doc, "T", false, Some(mews(&hosts)));
assert!(!out.contains("<div>"), "{out}");
let plain = document(&doc, "T", false, None);
@ -295,7 +344,7 @@ mod tests {
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));
let out = document(&image_doc(src), "T", false, Some(mews(&hosts)));
assert!(out.contains(&format!("src=\"{src}\"")), "{src}: {out}");
}
}
@ -304,7 +353,7 @@ mod tests {
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));
let out = document(&image_doc(src), "T", false, Some(mews(&hosts)));
assert!(!out.contains("<img"), "{src}: {out}");
assert!(out.contains("a photo"), "{src}: {out}");
}