itsybitsy/wap/src/lib.rs

482 lines
18 KiB
Rust

//! 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.
#[cfg(feature = "wml")]
mod deck;
mod escape;
#[cfg(feature = "wml")]
mod wml;
mod xhtmlmp;
use itsybitsy_core::Error;
use itsybitsy_core::ir::Doc;
use itsybitsy_core::render::{RenderCtx, Rendered, Renderer};
/// XHTML Mobile Profile, for WAP 2.0 clients: well-formed XML with the prolog.
pub struct XhtmlMp;
impl Renderer for XhtmlMp {
fn id(&self) -> &'static str {
"xhtmlmp"
}
fn media_type(&self) -> &'static str {
"application/vnd.wap.xhtml+xml; charset=utf-8"
}
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true).into_bytes()))
}
}
/// The same markup served as `text/html`, for an ordinary browser.
pub struct Html;
impl Renderer for Html {
fn id(&self) -> &'static str {
"html"
}
fn media_type(&self) -> &'static str {
"text/html; charset=utf-8"
}
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false).into_bytes()))
}
}
/// WML 1.3 decks, for WAP 1.x handsets.
///
/// The only output that paginates: a handset has a hard per-card byte budget and
/// refuses a deck that exceeds it, so a long page becomes a chain of screens.
#[cfg(feature = "wml")]
pub struct Wml;
#[cfg(feature = "wml")]
impl Renderer for Wml {
fn id(&self) -> &'static str {
"wml"
}
fn media_type(&self) -> &'static str {
"text/vnd.wap.wml; charset=utf-8"
}
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
Ok(deck::render(doc, ctx))
}
}
#[cfg(test)]
mod tests {
use itsybitsy_core::config::PageSettings;
use itsybitsy_core::parse;
use super::*;
fn render(renderer: &dyn Renderer, markdown: &str) -> String {
let doc = parse::markdown(markdown).unwrap();
let settings = PageSettings::default();
let ctx = RenderCtx { url: "/x", title: "The Title", settings: &settings, width: None };
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
}
fn body(markdown: &str) -> String {
let out = render(&Html, markdown);
let start = out.find("<body>\n").unwrap() + "<body>\n".len();
let end = out.find("</body>").unwrap();
out[start..end].to_string()
}
#[test]
fn the_html_form_carries_no_xml_declaration() {
// A browser parsing this as text/html tolerates the declaration as a
// bogus comment and renders the page, but logs a warning for it.
let out = render(&Html, "# a\n");
assert!(!out.starts_with("<?xml"));
assert!(out.starts_with("<!DOCTYPE html"));
}
#[test]
fn the_xhtml_form_keeps_it_because_wap_clients_need_valid_xml() {
assert!(render(&XhtmlMp, "# a\n").starts_with("<?xml version=\"1.0\""));
}
#[test]
fn the_two_forms_differ_only_in_that_line() {
let xml = render(&XhtmlMp, "# a\n\ntext\n");
let html = render(&Html, "# a\n\ntext\n");
assert_eq!(&xml[xml.find("<!DOCTYPE").unwrap()..], html);
}
#[test]
fn the_resolved_title_reaches_the_head() {
assert!(render(&Html, "# ignored\n").contains("<title>The Title</title>"));
}
#[test]
fn blocks_become_their_markup() {
assert_eq!(body("# a\n"), "<h1>a</h1>\n");
assert_eq!(body("###### f\n"), "<h6>f</h6>\n");
assert_eq!(body("text\n"), "<p>text</p>\n");
assert_eq!(body("---\n"), "<hr/>\n");
assert_eq!(body("> q\n"), "<blockquote>\n<p>q</p>\n</blockquote>\n");
assert_eq!(body("```\nx\n```\n"), "<pre>x</pre>\n");
}
#[test]
fn lists_keep_a_single_paragraph_item_inline() {
assert_eq!(body("- a\n- b\n"), "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n");
assert_eq!(body("1. a\n"), "<ol>\n<li>a</li>\n</ol>\n");
}
#[test]
fn inline_markup_becomes_elements() {
assert_eq!(
body("a *b* **c** `d`\n"),
"<p>a <em>b</em> <strong>c</strong> <code>d</code></p>\n"
);
}
#[test]
fn strikethrough_keeps_its_text_since_the_profile_has_no_element_for_it() {
assert_eq!(body("~~gone~~\n"), "<p>gone</p>\n");
}
#[test]
fn links_and_images_carry_their_attributes() {
assert_eq!(body("[label](/x \"T\")\n"), "<p><a href=\"/x\" title=\"T\">label</a></p>\n");
assert_eq!(body("![alt](/i.png)\n"), "<p><img src=\"/i.png\" alt=\"alt\"/></p>\n");
}
#[test]
fn markup_characters_in_text_are_escaped() {
assert_eq!(body("a < b & c\n"), "<p>a &lt; b &amp; c</p>\n");
assert_eq!(body("[x](/a?b=1&c=2)\n"), "<p><a href=\"/a?b=1&amp;c=2\">x</a></p>\n");
}
#[test]
fn tables_become_tables() {
assert_eq!(
body("| h |\n| --- |\n| c |\n"),
"<table>\n<tr><th>h</th></tr>\n<tr><td>c</td></tr>\n</table>\n"
);
}
#[test]
fn art_keeps_its_label_as_a_title() {
let doc = Doc {
blocks: vec![itsybitsy_core::ir::Block::Art {
alt: "Dragon".into(),
lines: vec!["/\\".into()],
}],
first_h1: None,
};
let settings = PageSettings::default();
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
assert!(out.contains("<pre title=\"Dragon\">/\\</pre>"), "{out}");
}
#[test]
fn raw_html_is_passed_through_because_it_is_already_the_right_language() {
assert_eq!(body("<div>x</div>\n"), "<div>x</div>\n");
}
#[test]
fn a_card_divider_emits_nothing() {
let doc = Doc {
blocks: vec![itsybitsy_core::ir::Block::CardBreak { title: Some("W".into()) }],
first_h1: None,
};
let settings = PageSettings::default();
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
assert!(out.contains("<body>\n</body>"), "{out}");
}
}
#[cfg(all(test, feature = "wml"))]
mod deck_tests {
use itsybitsy_core::config::{MenuStyle, PageSettings};
use itsybitsy_core::parse;
use super::*;
/// The fixture wapdown's output was captured from, so the assertions below
/// can be compared against it directly.
pub const TRAIL: &str = "Intro line.\n\n<!-- card Weather -->\n\n\
Cold and clear. Permit fee: $5.\n\n\
<!-- card Status -->\n\n- North: open\n";
fn render_with(settings: &PageSettings, markdown: &str) -> Rendered {
// 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 };
Wml.render(&doc, &ctx).unwrap()
}
/// Parse, then turn `<!-- card X -->` comments into dividers, which is what
/// `directives::apply` does for a real request.
pub fn with_card_breaks(markdown: &str) -> Doc {
let mut doc = parse::markdown(markdown).unwrap();
doc.blocks = doc
.blocks
.into_iter()
.map(|block| match &block {
itsybitsy_core::ir::Block::Html(html) if html.contains("card") => {
let title = html
.trim()
.trim_start_matches("<!--")
.trim_end_matches("-->")
.trim()
.strip_prefix("card")
.map(|rest| rest.trim().to_string())
.filter(|title| !title.is_empty());
itsybitsy_core::ir::Block::CardBreak { title }
}
_ => block,
})
.collect();
doc
}
fn body(rendered: &Rendered) -> String {
String::from_utf8(rendered.body.clone()).unwrap()
}
#[test]
fn a_deck_carries_the_wml_prolog_and_doctype() {
let out = body(&render_with(&PageSettings::default(), TRAIL));
assert!(out.starts_with("<?xml version=\"1.0\"?>\n<!DOCTYPE wml PUBLIC"), "{out}");
assert!(out.contains("wml13.dtd"), "{out}");
assert!(out.ends_with("</wml>\n"), "{out}");
}
#[test]
fn menu_mode_puts_the_intro_and_the_links_on_a_hub_card() {
let out = body(&render_with(&PageSettings::default(), TRAIL));
assert!(out.contains("<card id=\"menu\" title=\"Trail\">"), "{out}");
assert!(out.contains("<p>Intro line.</p>"), "{out}");
assert!(out.contains("<p><a href=\"#card1\">Weather</a></p>"), "{out}");
assert!(out.contains("<p><a href=\"#card2\">Status</a></p>"), "{out}");
assert!(out.contains("<card id=\"card1\" title=\"Weather\">"), "{out}");
assert!(out.contains("<card id=\"card2\" title=\"Status\">"), "{out}");
}
#[test]
fn a_literal_dollar_survives_into_the_card() {
// WML reads a single `$` as a variable reference.
let out = body(&render_with(&PageSettings::default(), TRAIL));
assert!(out.contains("Permit fee: $$5."), "{out}");
}
#[test]
fn shared_navigation_is_hoisted_into_a_template() {
let out = body(&render_with(&PageSettings::default(), TRAIL));
assert!(
out.contains("<template>\n<do type=\"prev\" label=\"Back\"><prev/></do>\n</template>"),
"{out}"
);
// The entry card has nothing to go back to, so it cancels the softkey.
assert!(out.contains("<do type=\"prev\"><noop/></do>"), "{out}");
}
#[test]
fn without_a_template_each_card_carries_its_own_navigation() {
let settings = PageSettings { template_nav: false, ..Default::default() };
let out = body(&render_with(&settings, TRAIL));
assert!(!out.contains("<template>"), "{out}");
assert!(!out.contains("<noop/>"), "{out}");
assert_eq!(out.matches("<do type=\"prev\" label=\"Back\">").count(), 2, "{out}");
}
#[test]
fn linear_mode_chains_the_cards_and_merges_the_intro() {
let settings = PageSettings { menu: false, ..Default::default() };
let out = body(&render_with(&settings, TRAIL));
assert!(!out.contains("id=\"menu\""), "{out}");
// The first card takes the page's title and the intro content.
assert!(out.contains("<card id=\"card1\" title=\"Trail\">"), "{out}");
assert!(out.contains("<p>Intro line.</p>"), "{out}");
assert!(
out.contains("<do type=\"accept\" label=\"More\"><go href=\"#card2\"/></do>"),
"{out}"
);
}
#[test]
fn a_select_menu_is_one_pickable_list() {
let settings = PageSettings { menu_style: MenuStyle::Select, ..Default::default() };
let out = body(&render_with(&settings, TRAIL));
assert!(out.contains("<select title=\"Trail\">"), "{out}");
assert!(out.contains("<option onpick=\"#card1\">Weather</option>"), "{out}");
assert!(!out.contains("<a href=\"#card1\">"), "{out}");
}
#[test]
fn a_home_label_adds_an_options_softkey() {
let settings = PageSettings { home_label: Some("Home".into()), ..Default::default() };
let out = body(&render_with(&settings, TRAIL));
assert!(
out.contains("<do type=\"options\" label=\"Home\"><go href=\"#menu\"/></do>"),
"{out}"
);
// Cancelled on the menu card, which is where it would point.
assert!(out.contains("<do type=\"options\"><noop/></do>"), "{out}");
}
#[test]
fn an_untitled_divider_still_gets_a_menu_entry() {
let out = body(&render_with(&PageSettings::default(), "A.\n\n---\n\nB.\n"));
assert!(out.contains("Untitled"), "{out}");
}
#[test]
fn a_document_with_no_divider_is_one_linear_card() {
let out = body(&render_with(&PageSettings::default(), "Just one paragraph.\n"));
assert!(!out.contains("id=\"menu\""), "{out}");
assert!(out.contains("<card id=\"card1\" title=\"Trail\">"), "{out}");
}
#[test]
fn headings_split_the_deck_when_asked_to() {
let settings = PageSettings { split_level: 2, ..Default::default() };
let out = body(&render_with(&settings, "Intro.\n\n## Alpha\n\nA.\n\n## Beta\n\nB.\n"));
assert!(out.contains("<card id=\"card1\" title=\"Alpha\">"), "{out}");
assert!(out.contains("<card id=\"card2\" title=\"Beta\">"), "{out}");
}
#[test]
fn a_rule_splits_the_deck_unless_turned_off() {
let source = "A.\n\n---\n\nB.\n";
let split = body(&render_with(&PageSettings::default(), source));
assert!(split.contains("id=\"menu\""), "{split}");
assert!(split.contains("id=\"card1\""), "{split}");
let settings = PageSettings { split_on_rule: false, ..Default::default() };
let whole = body(&render_with(&settings, source));
// One card, with the rule rendered as content instead of dividing.
assert!(!whole.contains("id=\"menu\""), "{whole}");
assert!(whole.contains("<p>---</p>"), "{whole}");
}
// -- Byte budget -------------------------------------------------------
fn long_page() -> String {
let para: String =
(0..60).map(|i| format!("word{i} ")).collect::<String>().trim_end().to_string();
format!("{para}\n\n{para}\n\n{para}\n")
}
#[test]
fn an_over_budget_card_is_split_into_pages() {
let settings = PageSettings { max_card_bytes: 400, ..Default::default() };
let out = body(&render_with(&settings, &long_page()));
// Continuation pages are numbered and carry no title of their own.
assert!(out.contains("<card id=\"card1-p2\">"), "{out}");
assert!(out.contains("<card id=\"card1-p3\">"), "{out}");
assert!(
out.contains("<do type=\"accept\" label=\"More\"><go href=\"#card1-p2\"/></do>"),
"{out}"
);
// Going back a page is a different idea from going back a card.
assert!(out.contains("<do type=\"prev\" label=\"Prev\"><prev/></do>"), "{out}");
}
#[test]
fn the_last_page_has_nowhere_further_to_go() {
let settings = PageSettings { max_card_bytes: 400, ..Default::default() };
let out = body(&render_with(&settings, &long_page()));
let last = out.rfind("<card id=\"card1-p3\">").unwrap();
assert!(!out[last..].contains("type=\"accept\""), "{}", &out[last..]);
}
#[test]
fn a_zero_budget_means_no_pagination() {
let settings = PageSettings { max_card_bytes: 0, ..Default::default() };
let out = body(&render_with(&settings, &long_page()));
assert!(!out.contains("-p2"), "{out}");
}
#[test]
fn each_page_stays_within_the_budget_where_a_block_allows_it() {
let settings = PageSettings { max_card_bytes: 1400, ..Default::default() };
let source: String = (0..40).map(|i| format!("Paragraph number {i}.\n\n")).collect();
let out = body(&render_with(&settings, &source));
for card in out.split("<card ").skip(1) {
let content = card.split_once('>').map(|(_, rest)| rest).unwrap_or("");
let body = content.split("</card>").next().unwrap_or("");
assert!(body.len() <= 1400, "a page is {} bytes:\n{body}", body.len());
}
}
// -- Deck per card -----------------------------------------------------
#[test]
fn deck_per_card_puts_each_card_in_its_own_deck() {
let settings = PageSettings { deck_per_card: true, ..Default::default() };
let rendered = render_with(&settings, TRAIL);
let index = body(&rendered);
// The index holds only the menu, linking out to each card's own URL.
assert!(index.contains("<card id=\"menu\" title=\"Trail\">"), "{index}");
assert!(!index.contains("id=\"card1\""), "{index}");
assert!(index.contains("<a href=\"/trail/weather#card1\">Weather</a>"), "{index}");
let slugs: Vec<&str> = rendered.parts.iter().map(|part| part.slug.as_str()).collect();
assert_eq!(slugs, ["weather", "status"]);
let weather = String::from_utf8(rendered.parts[0].body.clone()).unwrap();
assert!(weather.contains("<card id=\"card1\" title=\"Weather\">"), "{weather}");
assert!(weather.contains("Permit fee: $$5."), "{weather}");
}
#[test]
fn deck_per_card_is_ignored_without_a_menu_to_split() {
let settings = PageSettings { deck_per_card: true, menu: false, ..Default::default() };
assert!(render_with(&settings, TRAIL).parts.is_empty());
}
#[test]
fn slugs_are_url_safe_and_unique() {
let settings = PageSettings { deck_per_card: true, ..Default::default() };
let source = "Intro.\n\n<!-- card A & B! -->\n\nx\n\n<!-- card A & B! -->\n\ny\n";
let rendered = render_with(&settings, source);
let slugs: Vec<&str> = rendered.parts.iter().map(|part| part.slug.as_str()).collect();
assert_eq!(slugs, ["a-b", "a-b-2"]);
}
}
/// Writes this crate's WML output to `/tmp` so it can be diffed against the
/// reference capture from wapdown. Run with `--ignored` when changing the deck.
#[cfg(all(test, feature = "wml"))]
mod oracle_dump {
use itsybitsy_core::config::{MenuStyle, PageSettings};
use super::deck_tests::*;
use super::*;
#[test]
#[ignore = "writes files for manual comparison against the reference capture"]
fn dump_for_comparison() {
let cases: Vec<(&str, PageSettings)> = vec![
("default", PageSettings::default()),
("linear", PageSettings { menu: false, ..Default::default() }),
("select", PageSettings { menu_style: MenuStyle::Select, ..Default::default() }),
("notemplate", PageSettings { template_nav: false, ..Default::default() }),
("home", PageSettings { home_label: Some("Home".into()), ..Default::default() }),
];
for (name, settings) in cases {
let doc = with_card_breaks(TRAIL);
let ctx = RenderCtx { url: "/trail", title: "Trail", settings: &settings, width: None };
let out = Wml.render(&doc, &ctx).unwrap();
std::fs::write(format!("/tmp/mine-{name}.wml"), out.body).unwrap();
}
}
}