//! 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 { 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 { 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 { 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("\n").unwrap() + "\n".len(); let end = out.find("").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("The Title")); } #[test] fn blocks_become_their_markup() { assert_eq!(body("# a\n"), "

a

\n"); assert_eq!(body("###### f\n"), "
f
\n"); assert_eq!(body("text\n"), "

text

\n"); assert_eq!(body("---\n"), "
\n"); assert_eq!(body("> q\n"), "
\n

q

\n
\n"); assert_eq!(body("```\nx\n```\n"), "
x
\n"); } #[test] fn lists_keep_a_single_paragraph_item_inline() { assert_eq!(body("- a\n- b\n"), "
    \n
  • a
  • \n
  • b
  • \n
\n"); assert_eq!(body("1. a\n"), "
    \n
  1. a
  2. \n
\n"); } #[test] fn inline_markup_becomes_elements() { assert_eq!( body("a *b* **c** `d`\n"), "

a b c d

\n" ); } #[test] fn strikethrough_keeps_its_text_since_the_profile_has_no_element_for_it() { assert_eq!(body("~~gone~~\n"), "

gone

\n"); } #[test] fn links_and_images_carry_their_attributes() { assert_eq!(body("[label](/x \"T\")\n"), "

label

\n"); assert_eq!(body("![alt](/i.png)\n"), "

\"alt\"/

\n"); } #[test] fn markup_characters_in_text_are_escaped() { assert_eq!(body("a < b & c\n"), "

a < b & c

\n"); assert_eq!(body("[x](/a?b=1&c=2)\n"), "

x

\n"); } #[test] fn tables_become_tables() { assert_eq!( body("| h |\n| --- |\n| c |\n"), "\n\n\n
h
c
\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("
/\\
"), "{out}"); } #[test] fn raw_html_is_passed_through_because_it_is_already_the_right_language() { assert_eq!(body("
x
\n"), "
x
\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("\n"), "{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\n\n\ Cold and clear. Permit fee: $5.\n\n\ \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 `` 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() .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("\n\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(""), "{out}"); assert!(out.contains("

Intro line.

"), "{out}"); assert!(out.contains("

Weather

"), "{out}"); assert!(out.contains("

Status

"), "{out}"); assert!(out.contains(""), "{out}"); assert!(out.contains(""), "{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(""), "{out}" ); // The entry card has nothing to go back to, so it cancels the softkey. assert!(out.contains(""), "{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("