2026-10-04 19:55:21 +03:00
|
|
|
//! 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.
|
|
|
|
|
|
2026-10-04 21:04:49 +03:00
|
|
|
#[cfg(feature = "wml")]
|
|
|
|
|
mod deck;
|
2026-10-04 19:55:21 +03:00
|
|
|
mod escape;
|
2026-10-04 21:04:49 +03:00
|
|
|
#[cfg(feature = "wml")]
|
|
|
|
|
mod wml;
|
2026-10-04 19:55:21 +03:00
|
|
|
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()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 21:04:49 +03:00
|
|
|
/// 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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 19:55:21 +03:00
|
|
|
#[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);
|
|
|
|
|
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("\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 < b & c</p>\n");
|
|
|
|
|
assert_eq!(body("[x](/a?b=1&c=2)\n"), "<p><a href=\"/a?b=1&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}");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-04 21:04:49 +03:00
|
|
|
|
|
|
|
|
#[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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|