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
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -332,6 +332,7 @@ name = "itsybitsy-wap"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itsybitsy-core",
|
"itsybitsy-core",
|
||||||
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -8,7 +8,7 @@ A Rust re-implementation of [smolweb](https://code.randogoth.com/randogoth/smolw
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
It serves. HTTP, Spartan and Nex are working, with gemtext, XHTML-MP and HTML output and virtual hosting across all three.
|
It serves. HTTP, Spartan and Nex are working across five output formats, with virtual hosting on all three. Gemini and Gopher are the remaining protocols.
|
||||||
|
|
||||||
| Area | State |
|
| Area | State |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|
@ -19,7 +19,7 @@ It serves. HTTP, Spartan and Nex are working, with gemtext, XHTML-MP and HTML ou
|
||||||
| HTTP, Spartan and Nex listeners, virtual hosting | implemented |
|
| HTTP, Spartan and Nex listeners, virtual hosting | implemented |
|
||||||
| Fixed-width text output (Nex, later Gopher) | implemented |
|
| Fixed-width text output (Nex, later Gopher) | implemented |
|
||||||
| FIGlet banners and hyphenation, behind features | implemented |
|
| FIGlet banners and hyphenation, behind features | implemented |
|
||||||
| WML and XHTML-MP decks | planned |
|
| WML decks and card sub-documents, behind a feature | implemented |
|
||||||
| Gemini and Gopher | planned |
|
| Gemini and Gopher | planned |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
@ -105,6 +105,8 @@ Include and art targets resolve relative to the file holding the reference and m
|
||||||
|
|
||||||
Configuration keys are not carried over verbatim either: md2txt accepts three spellings of `paragraph_spacing`, and its `cache_control` is named for the header it lands in rather than the value it holds, which here is `max_age`. Its `text` and `nex` renderers differ only in heading decoration and in whether links are inlined or numbered — and the numbered form never writes the reference list its numbers point at, so there is one text renderer here rather than two. Tables, which both of its text renderers drop entirely, are rendered as aligned columns.
|
Configuration keys are not carried over verbatim either: md2txt accepts three spellings of `paragraph_spacing`, and its `cache_control` is named for the header it lands in rather than the value it holds, which here is `max_age`. Its `text` and `nex` renderers differ only in heading decoration and in whether links are inlined or numbered — and the numbered form never writes the reference list its numbers point at, so there is one text renderer here rather than two. Tables, which both of its text renderers drop entirely, are rendered as aligned columns.
|
||||||
|
|
||||||
|
wapdown's own parser produces neither tables nor lists, so its WML decks render a bullet list as literal text and drop tables entirely; both come out as real markup here, lists as marked lines and tables as `<table>`, which WML has.
|
||||||
|
|
||||||
smolweb inherits five invented directives from its two renderer libraries: `{.card}`, `{.include}`, `![[ ]]`, `#[label](art.txt)` and MultiMarkdown attribute lists (`{: .center}`) — two incompatible brace grammars, with art alignment expressible two different ways in one line. None of it appeared in real content, so itsybitsy expresses the same capabilities with standard constructs instead. Parsing once rather than once per library also means the text formats gain setext headings and the WAP formats gain tables and ASCII art, none of which their original parser handled.
|
smolweb inherits five invented directives from its two renderer libraries: `{.card}`, `{.include}`, `![[ ]]`, `#[label](art.txt)` and MultiMarkdown attribute lists (`{: .center}`) — two incompatible brace grammars, with art alignment expressible two different ways in one line. None of it appeared in real content, so itsybitsy expresses the same capabilities with standard constructs instead. Parsing once rather than once per library also means the text formats gain setext headings and the WAP formats gain tables and ASCII art, none of which their original parser handled.
|
||||||
|
|
||||||
## Output formats
|
## Output formats
|
||||||
|
|
@ -117,6 +119,7 @@ Each format is a crate implementing one trait, compiled in behind a cargo featur
|
||||||
| `xhtmlmp` | `application/vnd.wap.xhtml+xml` | Well-formed XML with the Mobile Profile doctype |
|
| `xhtmlmp` | `application/vnd.wap.xhtml+xml` | Well-formed XML with the Mobile Profile doctype |
|
||||||
| `html` | `text/html` | The same markup without the XML declaration |
|
| `html` | `text/html` | The same markup without the XML declaration |
|
||||||
| `text` | `text/plain` | Wrapped to 80 columns, because Nex and Gopher clients do not wrap |
|
| `text` | `text/plain` | Wrapped to 80 columns, because Nex and Gopher clients do not wrap |
|
||||||
|
| `wml` | `text/vnd.wap.wml` | WAP 1.x decks, paginated to a per-card byte budget (feature `wml`) |
|
||||||
|
|
||||||
HTTP negotiates between them from `Accept`. Only a literal media type counts as a match, so a browser's `*/*` can never be read as willingness to receive a WAP format; `?format=<id>` overrides negotiation outright, and naming an id the listener does not serve is a bad request rather than a silent fallback.
|
HTTP negotiates between them from `Accept`. Only a literal media type counts as a match, so a browser's `*/*` can never be read as willingness to receive a WAP format; `?format=<id>` overrides negotiation outright, and naming an id the listener does not serve is a bad request rather than a silent fallback.
|
||||||
|
|
||||||
|
|
@ -128,6 +131,7 @@ Two decorative capabilities for the text format are off by default, because most
|
||||||
|
|
||||||
| Feature | Adds | Cost |
|
| Feature | Adds | Cost |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `wml` | WML 1.3 decks for WAP 1.x handsets | +37 KiB |
|
||||||
| `figlet` | `h1_style = "figlet"` banners, fonts `small` and `standard` | +60 KiB |
|
| `figlet` | `h1_style = "figlet"` banners, fonts `small` and `standard` | +60 KiB |
|
||||||
| `hyphenation` | `hyphenate = true`, English patterns | +127 KiB |
|
| `hyphenation` | `hyphenate = true`, English patterns | +127 KiB |
|
||||||
| `hyphenation-all` | every language the pattern crate carries | +3.0 MiB |
|
| `hyphenation-all` | every language the pattern crate carries | +3.0 MiB |
|
||||||
|
|
@ -138,6 +142,23 @@ cargo build --release --features "figlet hyphenation"
|
||||||
|
|
||||||
A banner that will not fit the line, names a font this build lacks, or is asked for by a build without `figlet` falls back to the level's underline — a heading that cannot be decorated should not be lost. An unknown font name is logged, since that is almost always a typo. Likewise `hyphenate` has no effect without the feature, and an unknown `hyphen_lang` leaves the text unhyphenated; a ragged right edge is the plain-text convention anyway.
|
A banner that will not fit the line, names a font this build lacks, or is asked for by a build without `figlet` falls back to the level's underline — a heading that cannot be decorated should not be lost. An unknown font name is logged, since that is almost always a typo. Likewise `hyphenate` has no effect without the feature, and an unknown `hyphen_lang` leaves the text unhyphenated; a ragged right edge is the plain-text convention anyway.
|
||||||
|
|
||||||
|
## Card sub-documents
|
||||||
|
|
||||||
|
WML is the one output that paginates: a WAP 1.x handset has a hard per-card byte budget and refuses a deck that exceeds it. So a long page becomes a chain of screens, and a page with dividers becomes a menu card linking to the rest.
|
||||||
|
|
||||||
|
With `deck_per_card`, each card also gets its own URL under the page's:
|
||||||
|
|
||||||
|
| Request | WML client | Any other format |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/trail` | the menu deck | the whole page |
|
||||||
|
| `/trail/weather` | that card's own deck | redirects to `/trail` |
|
||||||
|
| `/trail/nonexistent` | redirects to `/trail` | redirects to `/trail` |
|
||||||
|
| `/about/whatever` | not found | not found |
|
||||||
|
|
||||||
|
That URL space belongs to the format that claims it. A format which does not address a sub-document redirects to the parent rather than substituting something else, and a page with no dividers has no such URLs at all — `/about/whatever` is not a sub-document just because `/about` exists. Nex and Gopher have no redirect status, so they resolve to the parent's content directly instead of bouncing.
|
||||||
|
|
||||||
|
The deck keys are `split_level`, `split_on_rule`, `max_card_bytes`, `menu`, `menu_style`, `deck_per_card`, `template_nav`, `nav_next_label`, `nav_prev_label`, `nav_back_label`, `home_label` and `images`.
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
Links should be root-relative and extensionless (`[about](/about)`, not `about.md`), so the same link resolves identically from every protocol.
|
Links should be root-relative and extensionless (`[about](/about)`, not `about.md`), so the same link resolves identically from every protocol.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ spartan = []
|
||||||
nex = []
|
nex = []
|
||||||
gemtext = ["dep:itsybitsy-gemtext"]
|
gemtext = ["dep:itsybitsy-gemtext"]
|
||||||
wap = ["dep:itsybitsy-wap"]
|
wap = ["dep:itsybitsy-wap"]
|
||||||
|
# WML 1.3 decks for WAP 1.x handsets.
|
||||||
|
wml = ["wap", "itsybitsy-wap/wml"]
|
||||||
text = ["dep:itsybitsy-text"]
|
text = ["dep:itsybitsy-text"]
|
||||||
# FIGlet banners and hyphenation for the text format, both off by default.
|
# FIGlet banners and hyphenation for the text format, both off by default.
|
||||||
figlet = ["text", "itsybitsy-text/figlet"]
|
figlet = ["text", "itsybitsy-text/figlet"]
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,30 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
}
|
}
|
||||||
respond(&mut stream, 200, "OK", listener.media_type(&format), body, &headers, head_only)
|
respond(&mut stream, 200, "OK", listener.media_type(&format), body, &headers, head_only)
|
||||||
}
|
}
|
||||||
|
Ok(Resolution::Found(Resource::Part { parent_url, slug, page })) => {
|
||||||
|
match page.part(&format, &slug) {
|
||||||
|
Some(body) => respond(
|
||||||
|
&mut stream,
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
listener.media_type(&format),
|
||||||
|
body,
|
||||||
|
&[("Vary", "Accept")],
|
||||||
|
head_only,
|
||||||
|
),
|
||||||
|
// This format does not address that sub-document, so the parent
|
||||||
|
// is where the content actually is.
|
||||||
|
None => respond(
|
||||||
|
&mut stream,
|
||||||
|
301,
|
||||||
|
"Moved Permanently",
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
b"",
|
||||||
|
&[("Location", parent_url.as_str()), ("Vary", "Accept")],
|
||||||
|
head_only,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(Resolution::Found(Resource::Raw { path, media_type })) => {
|
Ok(Resolution::Found(Resource::Raw { path, media_type })) => {
|
||||||
let meta = std::fs::metadata(&path)?;
|
let meta = std::fs::metadata(&path)?;
|
||||||
write_head(&mut stream, 200, "OK", media_type, meta.len(), &[])?;
|
write_head(&mut stream, 200, "OK", media_type, meta.len(), &[])?;
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
let mut file = std::fs::File::open(&path)?;
|
let mut file = std::fs::File::open(&path)?;
|
||||||
std::io::copy(&mut file, &mut stream)?;
|
std::io::copy(&mut file, &mut stream)?;
|
||||||
}
|
}
|
||||||
// resolve_flat never yields a redirect; it is matched for completeness.
|
// resolve_flat follows redirects and sub-documents to real content, so
|
||||||
|
// neither reaches here; they are matched for completeness.
|
||||||
Ok(_) => stream.write_all(b"Not found\n")?,
|
Ok(_) => stream.write_all(b"Not found\n")?,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!("{} nex {}: {err}", listener.name, for_log(path));
|
log::warn!("{} nex {}: {err}", listener.name, for_log(path));
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,15 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
}
|
}
|
||||||
None => status(&mut stream, 5, "Not rendered", b""),
|
None => status(&mut stream, 5, "Not rendered", b""),
|
||||||
},
|
},
|
||||||
|
Ok(Resolution::Found(Resource::Part { parent_url, slug, page })) => {
|
||||||
|
match page.part(format, &slug) {
|
||||||
|
Some(body) => {
|
||||||
|
let media_type = listener.media_type(format);
|
||||||
|
status(&mut stream, 2, media_type, body)
|
||||||
|
}
|
||||||
|
None => status(&mut stream, 3, &parent_url, b""),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(Resolution::Found(Resource::Raw { path, media_type })) => {
|
Ok(Resolution::Found(Resource::Raw { path, media_type })) => {
|
||||||
write!(stream, "2 {media_type}\r\n")?;
|
write!(stream, "2 {media_type}\r\n")?;
|
||||||
let mut file = std::fs::File::open(&path)?;
|
let mut file = std::fs::File::open(&path)?;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ pub fn build() -> Registry {
|
||||||
{
|
{
|
||||||
registry.insert(Arc::new(itsybitsy_wap::XhtmlMp)).expect("duplicate renderer id");
|
registry.insert(Arc::new(itsybitsy_wap::XhtmlMp)).expect("duplicate renderer id");
|
||||||
registry.insert(Arc::new(itsybitsy_wap::Html)).expect("duplicate renderer id");
|
registry.insert(Arc::new(itsybitsy_wap::Html)).expect("duplicate renderer id");
|
||||||
|
#[cfg(feature = "wml")]
|
||||||
|
registry.insert(Arc::new(itsybitsy_wap::Wml)).expect("duplicate renderer id");
|
||||||
}
|
}
|
||||||
|
|
||||||
registry
|
registry
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ impl Server {
|
||||||
write(dir.path(), "one/img.png", "not really a png");
|
write(dir.path(), "one/img.png", "not really a png");
|
||||||
write(dir.path(), "one/.itsybitsy.toml", "[defaults]\nmax_age = 120\n");
|
write(dir.path(), "one/.itsybitsy.toml", "[defaults]\nmax_age = 120\n");
|
||||||
write(dir.path(), "two/index.md", "# Two\n\nSecond site.\n");
|
write(dir.path(), "two/index.md", "# Two\n\nSecond site.\n");
|
||||||
|
write(
|
||||||
|
dir.path(),
|
||||||
|
"one/trail.md",
|
||||||
|
"Intro.\n\n<!-- card Weather -->\n\nCold and clear. Permit fee: $5.\n\n\
|
||||||
|
<!-- card Status -->\n\n- North: open\n",
|
||||||
|
);
|
||||||
|
|
||||||
let text = format!(
|
let text = format!(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -401,3 +407,107 @@ fn nothing_outside_the_root_is_reachable_over_any_protocol() {
|
||||||
assert_eq!(server.send("spartan", b"one.test /.itsybitsy.toml 0\r\n"), "4 Not found\r\n");
|
assert_eq!(server.send("spartan", b"one.test /.itsybitsy.toml 0\r\n"), "4 Not found\r\n");
|
||||||
assert_eq!(server.send("nex", b"/.itsybitsy.toml\r\n"), "Not found\n");
|
assert_eq!(server.send("nex", b"/.itsybitsy.toml\r\n"), "Not found\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Sub-documents -------------------------------------------------------
|
||||||
|
|
||||||
|
/// A page that paginates addresses sub-documents under its own URL. Only a
|
||||||
|
/// format that paginates can serve them, so every other format redirects to the
|
||||||
|
/// parent rather than substituting something else.
|
||||||
|
#[cfg(feature = "wml")]
|
||||||
|
mod parts {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Like `Server::start`, but with a listener that can serve WML.
|
||||||
|
fn wml_server() -> Server {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("one");
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
fs::write(root.join("index.md"), "# One\n").unwrap();
|
||||||
|
fs::write(
|
||||||
|
root.join("trail.md"),
|
||||||
|
"Intro.\n\n<!-- card Weather -->\n\nCold and clear. Permit fee: $5.\n\n\
|
||||||
|
<!-- card Status -->\n\n- North: open\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(root.join("about.md"), "# About\n\nNo dividers here.\n").unwrap();
|
||||||
|
fs::write(root.join(".itsybitsy.toml"), "[defaults]\ndeck_per_card = true\n").unwrap();
|
||||||
|
|
||||||
|
let text = format!(
|
||||||
|
r#"
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[site.one]
|
||||||
|
root = "{root}"
|
||||||
|
hosts = ["one.test"]
|
||||||
|
|
||||||
|
[listener.web]
|
||||||
|
protocol = "http"
|
||||||
|
bind = "127.0.0.1:0"
|
||||||
|
formats = ["wml", "html"]
|
||||||
|
default_site = "one"
|
||||||
|
"#,
|
||||||
|
root = root.to_str().unwrap(),
|
||||||
|
);
|
||||||
|
let config: ServerConfig = toml::from_str(&text).unwrap();
|
||||||
|
let registry = Arc::new(itsybitsy::registry::build());
|
||||||
|
let formats: Vec<String> = ["wml", "html"].iter().map(|s| s.to_string()).collect();
|
||||||
|
let sites = Arc::new(SiteSet::build(&config, registry.clone(), formats).unwrap());
|
||||||
|
|
||||||
|
let bound = serve::bind(&config, registry, sites).unwrap();
|
||||||
|
let addrs = bound
|
||||||
|
.iter()
|
||||||
|
.map(|entry| (entry.listener.name.clone(), entry.local_addr().unwrap()))
|
||||||
|
.collect();
|
||||||
|
serve::spawn_all(bound);
|
||||||
|
Server { addrs, _dir: dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wml_client_gets_the_card_itself() {
|
||||||
|
let server = wml_server();
|
||||||
|
let response =
|
||||||
|
server.request("web", "one.test", "/trail/weather", "Accept: text/vnd.wap.wml\r\n");
|
||||||
|
let (status, body) = split(&response);
|
||||||
|
assert_eq!(status, "HTTP/1.1 200 OK");
|
||||||
|
assert!(response.contains("Content-Type: text/vnd.wap.wml"), "{response}");
|
||||||
|
assert!(body.contains("<card id=\"card1\" title=\"Weather\">"), "{body}");
|
||||||
|
// WML reads a single dollar as a variable reference.
|
||||||
|
assert!(body.contains("Permit fee: $$5."), "{body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_browser_is_redirected_to_the_parent_page() {
|
||||||
|
// That URL space belongs to WML; HTML does not address it.
|
||||||
|
let server = wml_server();
|
||||||
|
let response = server.get("one.test", "/trail/weather");
|
||||||
|
assert_eq!(split(&response).0, "HTTP/1.1 301 Moved Permanently");
|
||||||
|
assert!(response.contains("Location: /trail"), "{response}");
|
||||||
|
assert!(response.contains("Vary: Accept"), "the redirect depends on Accept");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_card_redirects_even_for_a_wml_client() {
|
||||||
|
let server = wml_server();
|
||||||
|
let response =
|
||||||
|
server.request("web", "one.test", "/trail/nonexistent", "Accept: text/vnd.wap.wml\r\n");
|
||||||
|
assert_eq!(split(&response).0, "HTTP/1.1 301 Moved Permanently");
|
||||||
|
assert!(response.contains("Location: /trail"), "{response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_page_that_does_not_paginate_has_no_such_urls() {
|
||||||
|
let server = wml_server();
|
||||||
|
let response =
|
||||||
|
server.request("web", "one.test", "/about/whatever", "Accept: text/vnd.wap.wml\r\n");
|
||||||
|
assert_eq!(split(&response).0, "HTTP/1.1 404 Not Found");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_index_deck_links_out_to_each_card() {
|
||||||
|
let server = wml_server();
|
||||||
|
let response = server.request("web", "one.test", "/trail", "Accept: text/vnd.wap.wml\r\n");
|
||||||
|
let (_, body) = split(&response);
|
||||||
|
assert!(body.contains("<a href=\"/trail/weather#card1\">Weather</a>"), "{body}");
|
||||||
|
assert!(body.contains("<a href=\"/trail/status#card2\">Status</a>"), "{body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@ max_age = 60
|
||||||
|
|
||||||
[page."about.md"]
|
[page."about.md"]
|
||||||
title = "About itsybitsy"
|
title = "About itsybitsy"
|
||||||
|
deck_per_card = true
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,63 @@ fn apply(keys: &PageKeys, settings: &mut PageSettings) -> Result<(), Error> {
|
||||||
if let Some(value) = keys.wrap_code_blocks {
|
if let Some(value) = keys.wrap_code_blocks {
|
||||||
settings.wrap_code_blocks = value;
|
settings.wrap_code_blocks = value;
|
||||||
}
|
}
|
||||||
|
if let Some(value) = keys.split_level {
|
||||||
|
if value > 6 {
|
||||||
|
return Err(Error::config(format!("split_level = {value} is above heading level 6")));
|
||||||
|
}
|
||||||
|
settings.split_level = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = keys.split_on_rule {
|
||||||
|
settings.split_on_rule = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = keys.max_card_bytes {
|
||||||
|
settings.max_card_bytes = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = keys.menu {
|
||||||
|
settings.menu = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = &keys.menu_style {
|
||||||
|
settings.menu_style = match value.as_str() {
|
||||||
|
"links" => MenuStyle::Links,
|
||||||
|
"select" => MenuStyle::Select,
|
||||||
|
other => {
|
||||||
|
return Err(Error::config(format!(
|
||||||
|
"menu_style = '{other}' is not one of links or select"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(value) = keys.deck_per_card {
|
||||||
|
settings.deck_per_card = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = keys.template_nav {
|
||||||
|
settings.template_nav = value;
|
||||||
|
}
|
||||||
|
for (configured, target) in [
|
||||||
|
(&keys.nav_next_label, &mut settings.nav_next_label),
|
||||||
|
(&keys.nav_prev_label, &mut settings.nav_prev_label),
|
||||||
|
(&keys.nav_back_label, &mut settings.nav_back_label),
|
||||||
|
] {
|
||||||
|
if let Some(value) = configured {
|
||||||
|
*target = value.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = &keys.home_label {
|
||||||
|
// An empty label means no softkey, rather than one with no text on it.
|
||||||
|
settings.home_label = (!value.is_empty()).then(|| value.clone());
|
||||||
|
}
|
||||||
|
if let Some(value) = &keys.images {
|
||||||
|
settings.images = match value.as_str() {
|
||||||
|
"keep" => Images::Keep,
|
||||||
|
"alt" => Images::Alt,
|
||||||
|
"drop" => Images::Drop,
|
||||||
|
other => {
|
||||||
|
return Err(Error::config(format!(
|
||||||
|
"images = '{other}' is not one of keep, alt or drop"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let levels = [
|
let levels = [
|
||||||
&keys.h1_style,
|
&keys.h1_style,
|
||||||
|
|
@ -480,6 +537,49 @@ pub struct PageKeys {
|
||||||
/// Wrap over-long code lines. Off by default, because wrapping code changes
|
/// Wrap over-long code lines. Off by default, because wrapping code changes
|
||||||
/// what it says; on, it is better than losing the end of the line.
|
/// what it says; on, it is better than losing the end of the line.
|
||||||
pub wrap_code_blocks: Option<bool>,
|
pub wrap_code_blocks: Option<bool>,
|
||||||
|
|
||||||
|
// WML decks. Only the WML format reads these; it is the one output that
|
||||||
|
// paginates, because a WAP 1.x handset has a per-card byte budget.
|
||||||
|
/// Heading level that starts a new card, or 0 to disable. Ignored when the
|
||||||
|
/// document carries explicit card dividers.
|
||||||
|
pub split_level: Option<u8>,
|
||||||
|
/// Treat a thematic break as a card divider.
|
||||||
|
pub split_on_rule: Option<bool>,
|
||||||
|
/// Bytes one card may occupy, navigation included, or 0 for no limit.
|
||||||
|
pub max_card_bytes: Option<u32>,
|
||||||
|
/// Hub-and-spoke with a menu card, rather than cards chained in sequence.
|
||||||
|
pub menu: Option<bool>,
|
||||||
|
/// `links` for one link per line, `select` for a keypad-pickable list.
|
||||||
|
pub menu_style: Option<String>,
|
||||||
|
/// Give each card its own deck, addressable under the page's own URL.
|
||||||
|
pub deck_per_card: Option<bool>,
|
||||||
|
/// Hoist navigation shared by every card into a `<template>`.
|
||||||
|
pub template_nav: Option<bool>,
|
||||||
|
pub nav_next_label: Option<String>,
|
||||||
|
pub nav_prev_label: Option<String>,
|
||||||
|
pub nav_back_label: Option<String>,
|
||||||
|
/// Adds an options softkey returning to the menu card.
|
||||||
|
pub home_label: Option<String>,
|
||||||
|
/// `keep`, `alt` or `drop`.
|
||||||
|
pub images: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a menu card lists the cards it links to.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MenuStyle {
|
||||||
|
/// One link per line.
|
||||||
|
Links,
|
||||||
|
/// A `<select>`, which a numeric keypad can pick from directly.
|
||||||
|
Select,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to do with an image in a format that may not be able to show one.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Images {
|
||||||
|
Keep,
|
||||||
|
/// Replace it with its alt text.
|
||||||
|
Alt,
|
||||||
|
Drop,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a heading is decorated in the fixed-width formats.
|
/// How a heading is decorated in the fixed-width formats.
|
||||||
|
|
@ -557,6 +657,18 @@ pub struct PageSettings {
|
||||||
pub hyphenate: bool,
|
pub hyphenate: bool,
|
||||||
pub hyphen_lang: String,
|
pub hyphen_lang: String,
|
||||||
pub wrap_code_blocks: bool,
|
pub wrap_code_blocks: bool,
|
||||||
|
pub split_level: u8,
|
||||||
|
pub split_on_rule: bool,
|
||||||
|
pub max_card_bytes: u32,
|
||||||
|
pub menu: bool,
|
||||||
|
pub menu_style: MenuStyle,
|
||||||
|
pub deck_per_card: bool,
|
||||||
|
pub template_nav: bool,
|
||||||
|
pub nav_next_label: String,
|
||||||
|
pub nav_prev_label: String,
|
||||||
|
pub nav_back_label: String,
|
||||||
|
pub home_label: Option<String>,
|
||||||
|
pub images: Images,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PageSettings {
|
impl Default for PageSettings {
|
||||||
|
|
@ -580,6 +692,21 @@ impl Default for PageSettings {
|
||||||
hyphenate: false,
|
hyphenate: false,
|
||||||
hyphen_lang: "en-us".to_string(),
|
hyphen_lang: "en-us".to_string(),
|
||||||
wrap_code_blocks: false,
|
wrap_code_blocks: false,
|
||||||
|
// Explicit dividers and thematic breaks split a deck; heading-based
|
||||||
|
// splitting is opt-in, since most pages are not written as screens.
|
||||||
|
split_level: 0,
|
||||||
|
split_on_rule: true,
|
||||||
|
// The classic WAP 1.x deck budget.
|
||||||
|
max_card_bytes: 1400,
|
||||||
|
menu: true,
|
||||||
|
menu_style: MenuStyle::Links,
|
||||||
|
deck_per_card: false,
|
||||||
|
template_nav: true,
|
||||||
|
nav_next_label: "More".to_string(),
|
||||||
|
nav_prev_label: "Prev".to_string(),
|
||||||
|
nav_back_label: "Back".to_string(),
|
||||||
|
home_label: None,
|
||||||
|
images: Images::Keep,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
192
core/src/site.rs
192
core/src/site.rs
|
|
@ -38,6 +38,14 @@ pub enum Resource {
|
||||||
Document { url: String, page: Arc<Page> },
|
Document { url: String, page: Arc<Page> },
|
||||||
/// A file served byte for byte, streamed rather than buffered.
|
/// A file served byte for byte, streamed rather than buffered.
|
||||||
Raw { path: PathBuf, media_type: &'static str },
|
Raw { path: PathBuf, media_type: &'static str },
|
||||||
|
/// A sub-document of a page, addressed under the page's own URL.
|
||||||
|
///
|
||||||
|
/// Only a paginating format produces these, so whether this slug exists
|
||||||
|
/// depends on which format the caller serves. A caller that cannot serve it
|
||||||
|
/// redirects to `parent_url` instead of substituting something else, which
|
||||||
|
/// keeps the rule format-agnostic: core does not know which formats
|
||||||
|
/// paginate.
|
||||||
|
Part { parent_url: String, slug: String, page: Arc<Page> },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Site {
|
pub struct Site {
|
||||||
|
|
@ -86,7 +94,7 @@ impl Site {
|
||||||
/// | `/img.png` | the file itself, by media type |
|
/// | `/img.png` | the file itself, by media type |
|
||||||
pub fn resolve(&self, url_path: &str) -> Result<Resolution, Error> {
|
pub fn resolve(&self, url_path: &str) -> Result<Resolution, Error> {
|
||||||
let Some(clean) = clean_path(url_path) else { return Ok(Resolution::NotFound) };
|
let Some(clean) = clean_path(url_path) else { return Ok(Resolution::NotFound) };
|
||||||
let Some(source) = self.file_for(&clean) else { return Ok(Resolution::NotFound) };
|
let Some(source) = self.file_for(&clean) else { return self.part_for(&clean) };
|
||||||
|
|
||||||
// `/foo.md` always redirects to its extensionless form, so one
|
// `/foo.md` always redirects to its extensionless form, so one
|
||||||
// root-relative link works identically from every protocol.
|
// root-relative link works identically from every protocol.
|
||||||
|
|
@ -107,6 +115,44 @@ impl Site {
|
||||||
Ok(Resolution::Found(Resource::Document { url, page }))
|
Ok(Resolution::Found(Resource::Document { url, page }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Interpret the last path segment as a sub-document of its parent page.
|
||||||
|
///
|
||||||
|
/// A real file or directory always wins over this reading, which is why it is
|
||||||
|
/// only tried once resolution has otherwise failed. Requiring an actual
|
||||||
|
/// separator matters: splitting a bare top-level segment would leave an empty
|
||||||
|
/// parent, and that would make every unresolvable top-level path look like a
|
||||||
|
/// sub-document of the root index.
|
||||||
|
fn part_for(&self, clean: &str) -> Result<Resolution, Error> {
|
||||||
|
let Some((parent, slug)) = clean.rsplit_once('/') else { return Ok(Resolution::NotFound) };
|
||||||
|
if parent.is_empty() || slug.is_empty() {
|
||||||
|
return Ok(Resolution::NotFound);
|
||||||
|
}
|
||||||
|
let Some(source) = self.file_for(parent) else { return Ok(Resolution::NotFound) };
|
||||||
|
if source.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
|
return Ok(Resolution::NotFound);
|
||||||
|
}
|
||||||
|
let Some(parent_url) = url_for(&self.root, &source) else {
|
||||||
|
return Ok(Resolution::NotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = self.page(&source, &parent_url)?;
|
||||||
|
// A page that addresses no sub-documents at all has no such URL space:
|
||||||
|
// `/about/whatever` is not a sub-document just because `/about` exists.
|
||||||
|
if page.parts.is_empty() {
|
||||||
|
return Ok(Resolution::NotFound);
|
||||||
|
}
|
||||||
|
if page.parts.keys().any(|(_, known)| known == slug) {
|
||||||
|
return Ok(Resolution::Found(Resource::Part {
|
||||||
|
parent_url,
|
||||||
|
slug: slug.to_string(),
|
||||||
|
page,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// The URL space exists here, just not under this name, so the parent is
|
||||||
|
// the right place to send the caller.
|
||||||
|
Ok(Resolution::Redirect(parent_url))
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the way [`Site::resolve`] does, but never hand back a redirect.
|
/// Resolve the way [`Site::resolve`] does, but never hand back a redirect.
|
||||||
///
|
///
|
||||||
/// Nex and Gopher have no redirect status, so there is nothing to bounce a
|
/// Nex and Gopher have no redirect status, so there is nothing to bounce a
|
||||||
|
|
@ -117,6 +163,9 @@ impl Site {
|
||||||
for _ in 0..MAX_FLAT_HOPS {
|
for _ in 0..MAX_FLAT_HOPS {
|
||||||
match self.resolve(&target)? {
|
match self.resolve(&target)? {
|
||||||
Resolution::Redirect(location) => target = location,
|
Resolution::Redirect(location) => target = location,
|
||||||
|
// A sub-document belongs to a format this caller does not serve,
|
||||||
|
// so its parent is what there is to send back.
|
||||||
|
Resolution::Found(Resource::Part { parent_url, .. }) => target = parent_url,
|
||||||
settled => return Ok(settled),
|
settled => return Ok(settled),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -516,3 +565,144 @@ mod tests {
|
||||||
assert!(matches!(Site::new(&file, registry(), vec![]), Err(Error::Config { .. })));
|
assert!(matches!(Site::new(&file, registry(), vec![]), Err(Error::Config { .. })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod part_tests {
|
||||||
|
//! Ported from smolweb's `TestWmlCardUrls`. A stub renderer stands in for a
|
||||||
|
//! paginating format, so these rules are tested without the WML crate: core
|
||||||
|
//! does not know which formats paginate, which is the point.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::ir::Doc;
|
||||||
|
use crate::render::{Part, RenderCtx, Rendered, Renderer};
|
||||||
|
|
||||||
|
/// Addresses one sub-document per `<!-- card -->` divider in the source.
|
||||||
|
struct Paginating;
|
||||||
|
|
||||||
|
impl Renderer for Paginating {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"paginating"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_type(&self) -> &'static str {
|
||||||
|
"text/plain; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, doc: &Doc, _ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||||
|
let parts = doc
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|block| match block {
|
||||||
|
crate::ir::Block::CardBreak { title } => title.as_deref(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.map(|title| Part {
|
||||||
|
slug: title.to_ascii_lowercase(),
|
||||||
|
body: format!("card {title}").into_bytes(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Rendered { body: b"whole".to_vec(), parts })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> (tempfile::TempDir, Site) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path();
|
||||||
|
fs::write(root.join("index.md"), "# Home\n").unwrap();
|
||||||
|
// Paginates: two dividers, so two sub-documents.
|
||||||
|
fs::write(
|
||||||
|
root.join("trail.md"),
|
||||||
|
"Intro.\n\n<!-- card Weather -->\n\nCold.\n\n<!-- card Status -->\n\nOpen.\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Does not paginate: no dividers, so no sub-document URL space at all.
|
||||||
|
fs::write(root.join("about.md"), "# About\n\nBody.\n").unwrap();
|
||||||
|
|
||||||
|
let mut registry = Registry::new();
|
||||||
|
registry.insert(Arc::new(Paginating)).unwrap();
|
||||||
|
let site = Site::new(root, Arc::new(registry), vec!["paginating".to_string()]).unwrap();
|
||||||
|
(dir, site)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn part(site: &Site, path: &str) -> (String, String, Arc<Page>) {
|
||||||
|
match site.resolve(path).unwrap() {
|
||||||
|
Resolution::Found(Resource::Part { parent_url, slug, page }) => {
|
||||||
|
(parent_url, slug, page)
|
||||||
|
}
|
||||||
|
other => panic!("expected a sub-document at {path}, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sub_path_resolves_to_the_pages_sub_document() {
|
||||||
|
let (_dir, site) = fixture();
|
||||||
|
let (parent_url, slug, page) = part(&site, "/trail/weather");
|
||||||
|
assert_eq!(parent_url, "/trail");
|
||||||
|
assert_eq!(slug, "weather");
|
||||||
|
assert_eq!(page.part("paginating", "weather"), Some(b"card Weather".as_slice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_sub_path_redirects_to_the_parent() {
|
||||||
|
// The URL space exists here, just not under that name.
|
||||||
|
let (_dir, site) = fixture();
|
||||||
|
match site.resolve("/trail/nonexistent").unwrap() {
|
||||||
|
Resolution::Redirect(location) => assert_eq!(location, "/trail"),
|
||||||
|
other => panic!("expected a redirect, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sub_path_of_a_page_that_does_not_paginate_is_not_found() {
|
||||||
|
// about.md has no dividers, so it addresses nothing: `/about/whatever` is
|
||||||
|
// not a sub-document just because `/about` exists.
|
||||||
|
let (_dir, site) = fixture();
|
||||||
|
assert!(matches!(site.resolve("/about/whatever").unwrap(), Resolution::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_unresolvable_segment_is_not_a_sub_document_of_the_root() {
|
||||||
|
// The regression this guards: splitting a bare top-level segment leaves an
|
||||||
|
// empty parent, which would resolve to the root index.
|
||||||
|
let (_dir, site) = fixture();
|
||||||
|
assert!(matches!(site.resolve("/totally-unresolvable").unwrap(), Resolution::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_real_file_wins_over_the_sub_document_reading() {
|
||||||
|
let (dir, site) = fixture();
|
||||||
|
fs::create_dir(dir.path().join("trail")).unwrap();
|
||||||
|
fs::write(dir.path().join("trail/weather.md"), "# Real file\n").unwrap();
|
||||||
|
|
||||||
|
match site.resolve("/trail/weather").unwrap() {
|
||||||
|
Resolution::Found(Resource::Document { url, .. }) => {
|
||||||
|
assert_eq!(url, "/trail/weather")
|
||||||
|
}
|
||||||
|
other => panic!("expected the real file, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_flat_lands_on_the_parent_rather_than_the_sub_document() {
|
||||||
|
// Nex and Gopher have no redirect status and serve one format, so a
|
||||||
|
// sub-document they cannot address has to resolve to real content.
|
||||||
|
let (_dir, site) = fixture();
|
||||||
|
for path in ["/trail/weather", "/trail/nonexistent"] {
|
||||||
|
match site.resolve_flat(path).unwrap() {
|
||||||
|
Resolution::Found(Resource::Document { url, .. }) => {
|
||||||
|
assert_eq!(url, "/trail", "for {path}");
|
||||||
|
}
|
||||||
|
other => panic!("expected the parent document for {path}, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_deeper_sub_path_is_not_found() {
|
||||||
|
let (_dir, site) = fixture();
|
||||||
|
assert!(matches!(site.resolve("/trail/weather/more").unwrap(), Resolution::NotFound));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ hosts = ["localhost", "127.0.0.1"]
|
||||||
[listener.web]
|
[listener.web]
|
||||||
protocol = "http"
|
protocol = "http"
|
||||||
bind = "127.0.0.1:8080"
|
bind = "127.0.0.1:8080"
|
||||||
formats = ["xhtmlmp", "html"]
|
formats = ["wml", "xhtmlmp", "html"]
|
||||||
default_site = "demo"
|
default_site = "demo"
|
||||||
|
|
||||||
[listener.spartan]
|
[listener.spartan]
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,15 @@ description = "XHTML-MP and HTML output for itsybitsy"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
publish = false
|
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]
|
[dependencies]
|
||||||
itsybitsy-core = { path = "../core" }
|
itsybitsy-core = { path = "../core" }
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
|
||||||
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
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -41,6 +56,17 @@ mod tests {
|
||||||
assert_eq!(attr("say \"hi\" & bye"), "say "hi" & bye");
|
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]
|
#[test]
|
||||||
fn leaves_ordinary_text_alone() {
|
fn leaves_ordinary_text_alone() {
|
||||||
assert_eq!(text("plain text 123"), "plain text 123");
|
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
|
//! is shared with nothing outside this family. Splitting them would duplicate the
|
||||||
//! escaper and the markup builder.
|
//! escaper and the markup builder.
|
||||||
|
|
||||||
|
#[cfg(feature = "wml")]
|
||||||
|
mod deck;
|
||||||
mod escape;
|
mod escape;
|
||||||
|
#[cfg(feature = "wml")]
|
||||||
|
mod wml;
|
||||||
mod xhtmlmp;
|
mod xhtmlmp;
|
||||||
|
|
||||||
use itsybitsy_core::Error;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use itsybitsy_core::config::PageSettings;
|
use itsybitsy_core::config::PageSettings;
|
||||||
|
|
@ -174,3 +200,283 @@ mod tests {
|
||||||
assert!(out.contains("<body>\n</body>"), "{out}");
|
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