feat: declare a Mews page's feed, keep its prolog, and lift link-only list items
This commit is contained in:
parent
3e659df9dd
commit
c08b03db7b
9 changed files with 223 additions and 116 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue