//! 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"), "\nq
\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");
assert_eq!(body("1. a\n"), "\n- a
\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("\n"), "
\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"
);
}
#[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("