feat: render wml decks with paginated card sub-documents

This commit is contained in:
randogoth 2026-10-04 21:04:49 +03:00
parent 36b868e094
commit 1de4674b09
17 changed files with 1519 additions and 5 deletions

View file

@ -7,8 +7,15 @@ description = "XHTML-MP and HTML output for itsybitsy"
license = "Apache-2.0"
publish = false
[features]
default = []
# WML 1.3 decks for WAP 1.x handsets. Off by default: it is the only output that
# paginates, and most deployments have no such client.
wml = []
[dependencies]
itsybitsy-core = { path = "../core" }
log = "0.4"
[lints.rust]
unsafe_code = "forbid"

447
wap/src/deck.rs Normal file
View file

@ -0,0 +1,447 @@
//! Assembling cards into a WML deck.
//!
//! WML is the one output that paginates, because a WAP 1.x handset has a hard
//! per-card byte budget and will refuse a deck that exceeds it. So a document
//! becomes a sequence of cards, each card is packed to fit the budget, and
//! navigation is wired between them.
//!
//! Two topologies: hub-and-spoke, where a menu card links to each of the others,
//! and linear, where each card chains to the next. A single-section document is
//! always linear — there is nothing for a menu to choose between.
use itsybitsy_core::config::{MenuStyle, PageSettings};
use itsybitsy_core::ir::{Block, Doc};
use itsybitsy_core::render::{Part, RenderCtx, Rendered};
use crate::escape;
use crate::wml;
const PROLOG: &str = "<?xml version=\"1.0\"?>\n";
const DOCTYPE: &str = "<!DOCTYPE wml PUBLIC \"-//WAPFORUM//DTD WML 1.3//EN\" \
\"http://www.wapforum.org/DTD/wml13.dtd\">\n";
/// Bytes held back from the card budget for navigation markup. A template holds
/// the shared part once, so only the per-card overrides have to be allowed for.
const NAV_RESERVE: u32 = 160;
const TEMPLATED_NAV_RESERVE: u32 = 24;
/// Longest slug a card's own URL will carry.
const MAX_SLUG: usize = 40;
/// Content between dividers.
struct Section {
title: Option<String>,
blocks: Vec<Block>,
}
/// How one deck is laid out.
#[derive(Clone, Copy)]
struct Shape {
/// The deck opens with a menu card linking to the others.
hub: bool,
/// Wire each card forward to the next. A deck holding one card has nowhere
/// to chain to.
chain: bool,
/// This deck is the page's front, so its opening card carries the page's
/// title and has nothing to go back to. A card's own deck is not the front:
/// the menu it came from is.
front: bool,
}
/// One card, already paginated to fit the byte budget.
struct Card {
id: String,
title: Option<String>,
slug: String,
/// Markup per page; more than one means the card was over budget.
pages: Vec<String>,
}
pub fn render(doc: &Doc, ctx: &RenderCtx<'_>) -> Rendered {
let settings = ctx.settings;
let (intro, sections) = split_into_sections(doc, settings);
// A document with no divider at all is one card, so there is nothing for a
// menu to choose between; one divider is already enough, since the intro
// becomes the hub and the section becomes an entry on it.
let hub = settings.menu && !sections.is_empty();
let cards = build_cards(intro, sections, hub, settings);
if settings.deck_per_card && hub {
return per_card_decks(&cards, ctx);
}
Rendered::body(one_deck(&cards, hub, ctx).into_bytes())
}
// -- Sectioning ----------------------------------------------------------
/// Split a document at its dividers.
///
/// Three things divide: an explicit `<!-- card -->`, a thematic break when
/// `split_on_rule`, and a heading at `split_level`. All three apply together
/// rather than by precedence, so a document that mixes them splits at every
/// marker it carries. Content before the first divider is the intro.
fn split_into_sections(doc: &Doc, settings: &PageSettings) -> (Vec<Block>, Vec<Section>) {
let mut intro = Vec::new();
let mut sections: Vec<Section> = Vec::new();
for block in &doc.blocks {
let divider = match block {
Block::CardBreak { title } => Some(title.clone()),
Block::Rule if settings.split_on_rule => Some(None),
Block::Heading { level, inline }
if settings.split_level > 0 && *level == settings.split_level =>
{
Some(Some(Doc::plain_text(inline)))
}
_ => None,
};
match divider {
Some(title) => sections.push(Section { title, blocks: Vec::new() }),
None => match sections.last_mut() {
Some(section) => section.blocks.push(block.clone()),
None => intro.push(block.clone()),
},
}
}
// A divider with nothing after it is not a card.
sections.retain(|section| !section.blocks.is_empty() || section.title.is_some());
(intro, sections)
}
/// A section's title: its divider's label, else its first heading.
fn section_title(section: &Section) -> Option<String> {
if let Some(title) = &section.title {
return Some(title.clone());
}
section.blocks.iter().find_map(|block| match block {
Block::Heading { inline, .. } => Some(Doc::plain_text(inline)),
_ => None,
})
}
// -- Card construction ---------------------------------------------------
fn build_cards(
intro: Vec<Block>,
sections: Vec<Section>,
hub: bool,
settings: &PageSettings,
) -> Vec<Card> {
let mut cards = Vec::new();
let mut slugs: Vec<String> = Vec::new();
if hub {
// The menu card holds the intro and the links, which are added later once
// every card's id is known.
cards.push(Card {
id: "menu".to_string(),
title: None,
slug: "menu".to_string(),
pages: vec![wml::content(&intro, settings)],
});
}
for (index, section) in sections.iter().enumerate() {
let title = section_title(section);
// In linear mode the intro belongs to the first card, since there is no
// menu card to hold it.
let mut blocks = Vec::new();
if !hub && index == 0 {
blocks.extend(intro.iter().cloned());
}
blocks.extend(section.blocks.iter().cloned());
let id = format!("card{}", index + 1);
let slug = unique_slug(title.as_deref().unwrap_or(&id), &mut slugs);
let pages = paginate(&blocks, &id, settings);
cards.push(Card { id, title, slug, pages });
}
// A document with no dividers at all still needs one card to live in.
if cards.is_empty() {
cards.push(Card {
id: "card1".to_string(),
title: None,
slug: "card1".to_string(),
pages: paginate(&intro, "card1", settings),
});
}
cards
}
/// Split a card's markup into pages that each fit the byte budget.
///
/// Blocks are kept whole: a paragraph split across a page boundary would read as
/// two paragraphs. A single block larger than the whole budget becomes its own
/// page, because the alternative is a deck a handset refuses outright.
fn paginate(blocks: &[Block], id: &str, settings: &PageSettings) -> Vec<String> {
let markup: Vec<String> =
blocks.iter().map(|block| wml::content(std::slice::from_ref(block), settings)).collect();
if settings.max_card_bytes == 0 {
return vec![markup.concat()];
}
let reserve = if settings.template_nav { TEMPLATED_NAV_RESERVE } else { NAV_RESERVE };
let budget = settings.max_card_bytes.saturating_sub(reserve).max(1) as usize;
let mut pages = Vec::new();
let mut page = String::new();
for block in markup {
if block.len() > budget {
if !page.is_empty() {
pages.push(std::mem::take(&mut page));
}
log::warn!(
"card '{id}': a single block is {} bytes, over the {budget}-byte budget \
(max_card_bytes {} less {reserve} reserved for navigation); \
emitting it as its own page",
block.len(),
settings.max_card_bytes
);
pages.push(block);
continue;
}
if !page.is_empty() && page.len() + block.len() > budget {
pages.push(std::mem::take(&mut page));
}
page.push_str(&block);
}
if !page.is_empty() || pages.is_empty() {
pages.push(page);
}
pages
}
/// A URL-safe slug, unique within the deck.
fn unique_slug(title: &str, taken: &mut Vec<String>) -> String {
let mut slug = String::new();
for ch in title.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
} else if !slug.ends_with('-') {
slug.push('-');
}
}
let base: String = slug.trim_matches('-').chars().take(MAX_SLUG).collect();
let base = if base.is_empty() { "card".to_string() } else { base };
let mut candidate = base.clone();
let mut suffix = 2;
while taken.contains(&candidate) {
candidate = format!("{base}-{suffix}");
suffix += 1;
}
taken.push(candidate.clone());
candidate
}
// -- Deck assembly -------------------------------------------------------
/// Every card in one deck, with intra-deck links.
fn one_deck(cards: &[Card], hub: bool, ctx: &RenderCtx<'_>) -> String {
let links = menu_links(cards, hub, ctx, None);
assemble(cards, Shape { hub, chain: true, front: true }, ctx, &links)
}
/// A deck per card, plus an index deck holding the menu.
///
/// The card decks are addressed under the page's own URL, which is the URL space
/// a paginating format gets to claim.
fn per_card_decks(cards: &[Card], ctx: &RenderCtx<'_>) -> Rendered {
let links = menu_links(cards, true, ctx, Some(ctx.url));
let index = assemble(&cards[..1], Shape { hub: true, chain: false, front: true }, ctx, &links);
let shape = Shape { hub: false, chain: false, front: false };
let parts = cards[1..]
.iter()
.map(|card| Part {
slug: card.slug.clone(),
body: assemble(std::slice::from_ref(card), shape, ctx, "").into_bytes(),
})
.collect();
Rendered { body: index.into_bytes(), parts }
}
/// The menu card's list of the cards it links to.
///
/// `base` makes each link point at a separate deck under that URL rather than at
/// a card in this one.
fn menu_links(cards: &[Card], hub: bool, ctx: &RenderCtx<'_>, base: Option<&str>) -> String {
if !hub {
return String::new();
}
let settings = ctx.settings;
let entries: Vec<(String, String)> = cards[1..]
.iter()
.map(|card| {
let href = match base {
Some(base) => format!("{}#{}", join(base, &card.slug), card.id),
None => format!("#{}", card.id),
};
(href, card.title.clone().unwrap_or_else(|| "Untitled".to_string()))
})
.collect();
match settings.menu_style {
MenuStyle::Links => entries
.iter()
.map(|(href, label)| {
format!(
"<p><a href=\"{}\">{}</a></p>\n",
escape::wml_attr(href),
escape::wml(label)
)
})
.collect(),
MenuStyle::Select => {
// One select a keypad can pick from, rather than a list of links.
let options: String = entries
.iter()
.map(|(href, label)| {
format!(
"<option onpick=\"{}\">{}</option>",
escape::wml_attr(href),
escape::wml(label)
)
})
.collect();
format!("<p><select title=\"{}\">{options}</select></p>\n", escape::wml_attr(ctx.title))
}
}
}
/// Write out a deck: prolog, optional template, then the cards.
fn assemble(cards: &[Card], shape: Shape, ctx: &RenderCtx<'_>, menu: &str) -> String {
let settings = ctx.settings;
let mut out = String::with_capacity(512);
out.push_str(PROLOG);
out.push_str(DOCTYPE);
out.push_str("<wml>\n");
if settings.template_nav {
out.push_str("<template>\n");
out.push_str(&format!(
"<do type=\"prev\" label=\"{}\"><prev/></do>\n",
escape::wml_attr(&settings.nav_back_label)
));
if let Some(home) = &settings.home_label {
out.push_str(&format!(
"<do type=\"options\" label=\"{}\"><go href=\"#menu\"/></do>\n",
escape::wml_attr(home)
));
}
out.push_str("</template>\n");
}
for (index, card) in cards.iter().enumerate() {
let first_card = index == 0;
for (page, markup) in card.pages.iter().enumerate() {
let first_page = page == 0;
// The page a card's second and later screens live on has no title of
// its own: it is a continuation, not a new section.
let id = page_id(card, page);
let title = if !first_page {
// A continuation page is not a new section, so it has no title.
None
} else if first_card && shape.front {
// The opening screen is the page's front and carries its title:
// the menu card has none of its own, and in linear mode the
// first section's title gives way to it.
Some(ctx.title.to_string())
} else {
card.title.clone()
};
match &title {
Some(title) => out.push_str(&format!(
"<card id=\"{id}\" title=\"{}\">\n",
escape::wml_attr(title)
)),
None => out.push_str(&format!("<card id=\"{id}\">\n")),
}
out.push_str(&card_nav(cards, card, index, page, shape, settings));
if first_page && shape.hub && first_card {
out.push_str(markup);
out.push_str(menu);
} else {
out.push_str(markup);
}
out.push_str("</card>\n");
}
}
out.push_str("</wml>\n");
out
}
/// The navigation for one page of one card.
fn card_nav(
cards: &[Card],
card: &Card,
index: usize,
page: usize,
shape: Shape,
settings: &PageSettings,
) -> String {
let mut out = String::new();
let first_page = page == 0;
let entry = shape.front && index == 0 && first_page;
if settings.template_nav {
if entry {
// Nothing to go back to from the first screen, so the template's
// softkey is cancelled rather than left pointing nowhere.
out.push_str("<do type=\"prev\"><noop/></do>\n");
if settings.home_label.is_some() {
out.push_str("<do type=\"options\"><noop/></do>\n");
}
} else if !first_page {
// A continuation page goes back a page, which is a different idea
// from going back a card, so it says so.
out.push_str(&format!(
"<do type=\"prev\" label=\"{}\"><prev/></do>\n",
escape::wml_attr(&settings.nav_prev_label)
));
}
} else if !entry {
let label = if first_page { &settings.nav_back_label } else { &settings.nav_prev_label };
out.push_str(&format!(
"<do type=\"prev\" label=\"{}\"><prev/></do>\n",
escape::wml_attr(label)
));
if settings.home_label.is_some() {
out.push_str(&format!(
"<do type=\"options\" label=\"{}\"><go href=\"#menu\"/></do>\n",
escape::wml_attr(settings.home_label.as_deref().unwrap_or_default())
));
}
}
// Forward: the next page of this card, else the next card when chaining.
let next = if page + 1 < card.pages.len() {
Some(page_id(card, page + 1))
} else if shape.chain && !shape.hub {
cards.get(index + 1).map(|next| page_id(next, 0))
} else {
None
};
if let Some(next) = next {
out.push_str(&format!(
"<do type=\"accept\" label=\"{}\"><go href=\"#{next}\"/></do>\n",
escape::wml_attr(&settings.nav_next_label)
));
}
out
}
fn page_id(card: &Card, page: usize) -> String {
if page == 0 { card.id.clone() } else { format!("{}-p{}", card.id, page + 1) }
}
/// Join a page URL and a slug, without doubling the separator at the root.
fn join(base: &str, slug: &str) -> String {
if base.ends_with('/') { format!("{base}{slug}") } else { format!("{base}/{slug}") }
}

View file

@ -29,6 +29,21 @@ pub fn attr(value: &str) -> String {
out
}
/// Escape text for WML element content.
#[cfg(feature = "wml")]
///
/// WML reads `$` as the start of a variable reference, so a literal one has to be
/// doubled; otherwise a handset would substitute something or fail to parse.
pub fn wml(value: &str) -> String {
text(value).replace('$', "$$")
}
/// Escape text for a WML attribute value.
#[cfg(feature = "wml")]
pub fn wml_attr(value: &str) -> String {
attr(value).replace('$', "$$")
}
#[cfg(test)]
mod tests {
use super::*;
@ -41,6 +56,17 @@ mod tests {
assert_eq!(attr("say \"hi\" & bye"), "say &quot;hi&quot; &amp; bye");
}
#[test]
#[cfg(feature = "wml")]
fn wml_doubles_a_literal_dollar() {
assert_eq!(wml("Permit fee: $5"), "Permit fee: $$5");
assert_eq!(wml("a & $b"), "a &amp; $$b");
assert_eq!(wml_attr("/x?a=1&b=$2"), "/x?a=1&amp;b=$$2");
// The doubling runs after the markup escape, so an escaped entity is not
// itself rewritten.
assert_eq!(wml("<$>"), "&lt;$$&gt;");
}
#[test]
fn leaves_ordinary_text_alone() {
assert_eq!(text("plain text 123"), "plain text 123");

View file

@ -5,7 +5,11 @@
//! 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;
@ -46,6 +50,28 @@ impl Renderer for Html {
}
}
/// 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;
@ -174,3 +200,283 @@ mod tests {
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);
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();
}
}
}

240
wap/src/wml.rs Normal file
View file

@ -0,0 +1,240 @@
//! WML 1.3 markup for one card's content.
//!
//! WML is much narrower than XHTML: there are no list elements at all, and `$`
//! introduces a variable reference, so a literal one has to be doubled. Both
//! constraints shape what the blocks below turn into.
use itsybitsy_core::config::{Images, PageSettings};
use itsybitsy_core::ir::{Block, Doc, Inline};
use crate::escape;
/// Render blocks as the inside of a `<card>`.
pub fn content(blocks: &[Block], settings: &PageSettings) -> String {
let mut out = String::new();
for block in blocks {
block_markup(block, settings, &mut out);
}
out
}
fn block_markup(block: &Block, settings: &PageSettings, out: &mut String) {
match block {
// WML has no heading elements, so a heading is a paragraph set in bold.
// Level is not expressible; the text is what survives.
Block::Heading { inline, .. } => {
out.push_str(&format!("<p><b>{}</b></p>\n", inlines(inline, settings)));
}
Block::Paragraph(inline) => {
let markup = inlines(inline, settings);
if !markup.trim().is_empty() {
out.push_str(&format!("<p>{markup}</p>\n"));
}
}
// No preformatted element either: `<br/>` keeps the line structure, which
// is the part of a code block that carries meaning.
Block::CodeBlock { lines, .. } | Block::Art { lines, .. } => {
let body = lines.iter().map(|line| escape::wml(line)).collect::<Vec<_>>().join("<br/>");
out.push_str(&format!("<p>{body}</p>\n"));
}
// A quote is marked with a prefix, since there is nothing to indent with.
Block::BlockQuote(inner) => {
let mut quoted = String::new();
for block in inner {
block_markup(block, settings, &mut quoted);
}
out.push_str(&quoted.replace("<p>", "<p>| "));
}
Block::List { ordered, start, items } => {
let mut lines = Vec::new();
for (index, item) in items.iter().enumerate() {
let marker =
if *ordered { format!("{}. ", start + index as u64) } else { "- ".to_string() };
lines.push(format!("{marker}{}", item_markup(item, settings)));
}
if !lines.is_empty() {
out.push_str(&format!("<p>{}</p>\n", lines.join("<br/>")));
}
}
Block::Table { head, rows, .. } => {
let columns = head.len().max(rows.iter().map(Vec::len).max().unwrap_or(0));
if columns == 0 {
return;
}
out.push_str(&format!("<table columns=\"{columns}\">\n"));
for row in std::iter::once(head).filter(|head| !head.is_empty()).chain(rows) {
out.push_str("<tr>");
for index in 0..columns {
let cell =
row.get(index).map(|cell| inlines(cell, settings)).unwrap_or_default();
out.push_str(&format!("<td>{cell}</td>"));
}
out.push_str("</tr>\n");
}
out.push_str("</table>\n");
}
// WML has no horizontal rule.
Block::Rule => out.push_str("<p>---</p>\n"),
// Alignment is a fixed-width concern; a handset lays out its own screen.
Block::Aligned { block, .. } => block_markup(block, settings, out),
// Pagination here is the deck's own job, driven by the byte budget.
Block::CardBreak { .. } => {}
// Raw HTML is not WML and would not parse on a handset.
Block::Html(_) => {}
}
}
/// A list item flattened onto one line: WML cannot nest block content in a list.
fn item_markup(blocks: &[Block], settings: &PageSettings) -> String {
let mut parts = Vec::new();
for block in blocks {
match block {
Block::Paragraph(inline) | Block::Heading { inline, .. } => {
parts.push(inlines(inline, settings))
}
Block::List { items, .. } => {
// A nested list is flattened with its own markers rather than
// dropped, since there is no nesting to express it with.
for item in items {
parts.push(format!("- {}", item_markup(item, settings)));
}
}
Block::Aligned { block, .. } => {
parts.push(item_markup(std::slice::from_ref(block), settings))
}
_ => {}
}
}
parts.join(" ")
}
pub fn inlines(inline: &[Inline], settings: &PageSettings) -> String {
let mut out = String::new();
for item in inline {
match item {
Inline::Text(text) => out.push_str(&escape::wml(text)),
// No `<code>` in WML; monospace is not available either.
Inline::Code(code) => out.push_str(&escape::wml(code)),
Inline::Emph(inner) => out.push_str(&format!("<em>{}</em>", inlines(inner, settings))),
Inline::Strong(inner) => {
out.push_str(&format!("<strong>{}</strong>", inlines(inner, settings)))
}
// No strikethrough element; the words survive without it.
Inline::Strike(inner) => out.push_str(&inlines(inner, settings)),
Inline::Link { href, label, .. } => {
out.push_str(&format!(
"<a href=\"{}\">{}</a>",
escape::wml_attr(href),
inlines(label, settings)
));
}
Inline::Image { src, alt, .. } => {
let alt_text = escape::wml_attr(&Doc::plain_text(alt));
match settings.images {
Images::Keep => out.push_str(&format!(
"<img src=\"{}\" alt=\"{alt_text}\"/>",
escape::wml_attr(src)
)),
Images::Alt => out.push_str(&escape::wml(&Doc::plain_text(alt))),
Images::Drop => {}
}
}
Inline::SoftBreak => out.push(' '),
Inline::HardBreak => out.push_str("<br/>"),
Inline::Html(_) => {}
}
}
out
}
#[cfg(test)]
mod tests {
use itsybitsy_core::parse;
use super::*;
fn render(markdown: &str) -> String {
let doc = parse::markdown(markdown);
content(&doc.blocks, &PageSettings::default())
}
#[test]
fn a_literal_dollar_is_doubled_because_wml_reads_it_as_a_variable() {
assert_eq!(render("Permit fee: $5.\n"), "<p>Permit fee: $$5.</p>\n");
}
#[test]
fn headings_become_bold_paragraphs_since_wml_has_none() {
assert_eq!(render("# Title\n"), "<p><b>Title</b></p>\n");
assert_eq!(render("###### Deep\n"), "<p><b>Deep</b></p>\n");
}
#[test]
fn lists_become_one_paragraph_of_marked_lines() {
// WML has no list element at all.
assert_eq!(render("- a\n- b\n"), "<p>- a<br/>- b</p>\n");
assert_eq!(render("3. a\n4. b\n"), "<p>3. a<br/>4. b</p>\n");
}
#[test]
fn a_nested_list_is_flattened_rather_than_dropped() {
let out = render("- a\n - b\n");
assert!(out.contains("- a - b"), "{out}");
}
#[test]
fn tables_are_rendered_because_wml_has_them() {
// wapdown's own parser produces no tables, so this is new output.
assert_eq!(
render("| a | b |\n| --- | --- |\n| 1 | 2 |\n"),
"<table columns=\"2\">\n<tr><td>a</td><td>b</td></tr>\n<tr><td>1</td><td>2</td></tr>\n</table>\n"
);
}
#[test]
fn code_keeps_its_line_structure_through_breaks() {
assert_eq!(
render("```\nlet x = 1;\nlet y = 2;\n```\n"),
"<p>let x = 1;<br/>let y = 2;</p>\n"
);
}
#[test]
fn a_quote_is_marked_with_a_prefix() {
assert_eq!(render("> quoted\n"), "<p>| quoted</p>\n");
}
#[test]
fn inline_markup_uses_the_elements_wml_has() {
assert_eq!(render("a *b* **c**\n"), "<p>a <em>b</em> <strong>c</strong></p>\n");
// No element for these, so the words survive unmarked.
assert_eq!(render("~~gone~~\n"), "<p>gone</p>\n");
assert_eq!(render("`code`\n"), "<p>code</p>\n");
}
#[test]
fn links_are_escaped_in_both_text_and_attribute() {
assert_eq!(
render("[a $ b](/x?y=1&z=$2)\n"),
"<p><a href=\"/x?y=1&amp;z=$$2\">a $$ b</a></p>\n"
);
}
#[test]
fn images_follow_the_configured_policy() {
let doc = parse::markdown("![a photo](/i.png)\n");
for (policy, expected) in [
(Images::Keep, "<p><img src=\"/i.png\" alt=\"a photo\"/></p>\n"),
(Images::Alt, "<p>a photo</p>\n"),
(Images::Drop, ""),
] {
let settings = PageSettings { images: policy, ..Default::default() };
assert_eq!(content(&doc.blocks, &settings), expected, "{policy:?}");
}
}
#[test]
fn raw_html_is_left_out_because_it_is_not_wml() {
assert_eq!(render("<div>x</div>\n"), "");
}
}