feat: render wml decks with paginated card sub-documents
This commit is contained in:
parent
36b868e094
commit
1de4674b09
17 changed files with 1519 additions and 5 deletions
447
wap/src/deck.rs
Normal file
447
wap/src/deck.rs
Normal 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) = §ion.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}") }
|
||||
}
|
||||
|
|
@ -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 "hi" & 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 & $$b");
|
||||
assert_eq!(wml_attr("/x?a=1&b=$2"), "/x?a=1&b=$$2");
|
||||
// The doubling runs after the markup escape, so an escaped entity is not
|
||||
// itself rewritten.
|
||||
assert_eq!(wml("<$>"), "<$$>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_ordinary_text_alone() {
|
||||
assert_eq!(text("plain text 123"), "plain text 123");
|
||||
|
|
|
|||
306
wap/src/lib.rs
306
wap/src/lib.rs
|
|
@ -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
240
wap/src/wml.rs
Normal 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("ed.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&z=$$2\">a $$ b</a></p>\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_follow_the_configured_policy() {
|
||||
let doc = parse::markdown("\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"), "");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue