2026-10-04 19:55:21 +03:00
|
|
|
//! XHTML Mobile Profile markup, and the `text/html` body derived from it.
|
|
|
|
|
//!
|
|
|
|
|
//! One document serves both: a WAP 2.0 client needs well-formed XML with the
|
|
|
|
|
//! prolog and the Mobile Profile doctype, while a browser parsing the same bytes
|
|
|
|
|
//! as `text/html` tolerates the leading `<?xml ...?>` as a bogus comment but
|
|
|
|
|
//! logs a warning for it. So the HTML form is the same markup with that one line
|
|
|
|
|
//! removed.
|
|
|
|
|
|
|
|
|
|
use itsybitsy_core::ir::{Block, Doc, Inline};
|
|
|
|
|
|
|
|
|
|
use crate::escape;
|
|
|
|
|
|
|
|
|
|
/// The XML declaration, which only the XHTML-MP form carries.
|
|
|
|
|
const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
|
|
|
|
|
|
|
|
|
|
const DOCTYPE: &str = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\" \
|
|
|
|
|
\"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\">\n";
|
|
|
|
|
|
|
|
|
|
/// Build the whole document. `prolog` distinguishes the two media types.
|
|
|
|
|
pub fn document(doc: &Doc, title: &str, prolog: bool) -> String {
|
|
|
|
|
let mut out = String::new();
|
|
|
|
|
if prolog {
|
|
|
|
|
out.push_str(PROLOG);
|
|
|
|
|
}
|
|
|
|
|
out.push_str(DOCTYPE);
|
|
|
|
|
out.push_str("<html xmlns=\"http://www.w3.org/1999/xhtml\">\n");
|
|
|
|
|
out.push_str(&format!("<head><title>{}</title></head>\n", escape::text(title)));
|
|
|
|
|
out.push_str("<body>\n");
|
|
|
|
|
blocks(&doc.blocks, &mut out);
|
|
|
|
|
out.push_str("</body>\n</html>\n");
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn blocks(blocks: &[Block], out: &mut String) {
|
|
|
|
|
for block in blocks {
|
|
|
|
|
self_block(block, out);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn self_block(block: &Block, out: &mut String) {
|
|
|
|
|
match block {
|
|
|
|
|
Block::Heading { level, inline } => {
|
|
|
|
|
let level = (*level).clamp(1, 6);
|
|
|
|
|
out.push_str(&format!("<h{level}>{}</h{level}>\n", inlines(inline)));
|
|
|
|
|
}
|
|
|
|
|
Block::Paragraph(inline) => out.push_str(&format!("<p>{}</p>\n", inlines(inline))),
|
|
|
|
|
Block::CodeBlock { lines, .. } => {
|
|
|
|
|
out.push_str("<pre>");
|
|
|
|
|
out.push_str(&escape::text(&lines.join("\n")));
|
|
|
|
|
out.push_str("</pre>\n");
|
|
|
|
|
}
|
|
|
|
|
Block::Art { alt, lines } => {
|
|
|
|
|
// The label is the only description a client that cannot show the
|
|
|
|
|
// art would have, so it is kept as the title attribute.
|
|
|
|
|
out.push_str(&format!("<pre title=\"{}\">", escape::attr(alt)));
|
|
|
|
|
out.push_str(&escape::text(&lines.join("\n")));
|
|
|
|
|
out.push_str("</pre>\n");
|
|
|
|
|
}
|
|
|
|
|
Block::BlockQuote(inner) => {
|
|
|
|
|
out.push_str("<blockquote>\n");
|
|
|
|
|
blocks(inner, out);
|
|
|
|
|
out.push_str("</blockquote>\n");
|
|
|
|
|
}
|
|
|
|
|
Block::List { ordered, start, items } => {
|
|
|
|
|
if *ordered {
|
|
|
|
|
// XHTML-MP has no `start` attribute, so a list beginning
|
|
|
|
|
// elsewhere than 1 cannot be expressed; the numbers restart.
|
|
|
|
|
out.push_str("<ol>\n");
|
|
|
|
|
} else {
|
|
|
|
|
out.push_str("<ul>\n");
|
|
|
|
|
}
|
|
|
|
|
let _ = start;
|
|
|
|
|
for item in items {
|
|
|
|
|
out.push_str("<li>");
|
|
|
|
|
// A single paragraph needs no block wrapper inside the item.
|
|
|
|
|
match item.as_slice() {
|
|
|
|
|
[Block::Paragraph(inline)] => out.push_str(&inlines(inline)),
|
|
|
|
|
blocks_in_item => {
|
|
|
|
|
out.push('\n');
|
|
|
|
|
blocks(blocks_in_item, out);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out.push_str("</li>\n");
|
|
|
|
|
}
|
|
|
|
|
out.push_str(if *ordered { "</ol>\n" } else { "</ul>\n" });
|
|
|
|
|
}
|
|
|
|
|
Block::Table { head, rows, .. } => {
|
|
|
|
|
out.push_str("<table>\n");
|
|
|
|
|
if !head.is_empty() {
|
|
|
|
|
out.push_str("<tr>");
|
|
|
|
|
for cell in head {
|
|
|
|
|
out.push_str(&format!("<th>{}</th>", inlines(cell)));
|
|
|
|
|
}
|
|
|
|
|
out.push_str("</tr>\n");
|
|
|
|
|
}
|
|
|
|
|
for row in rows {
|
|
|
|
|
out.push_str("<tr>");
|
|
|
|
|
for cell in row {
|
|
|
|
|
out.push_str(&format!("<td>{}</td>", inlines(cell)));
|
|
|
|
|
}
|
|
|
|
|
out.push_str("</tr>\n");
|
|
|
|
|
}
|
|
|
|
|
out.push_str("</table>\n");
|
|
|
|
|
}
|
|
|
|
|
Block::Rule => out.push_str("<hr/>\n"),
|
|
|
|
|
// Raw HTML is passed through: this is the one family of formats where it
|
|
|
|
|
// is already in the right language.
|
|
|
|
|
Block::Html(html) => {
|
|
|
|
|
out.push_str(html.trim_end());
|
|
|
|
|
out.push('\n');
|
|
|
|
|
}
|
|
|
|
|
// Alignment reaches only the fixed-width text formats. Pagination is a
|
|
|
|
|
// WML concern; a browser scrolls one document.
|
|
|
|
|
Block::Aligned { block, .. } => self_block(block, out),
|
2026-10-09 16:35:11 +03:00
|
|
|
// Gates are filtered out before rendering; keeping the content is the
|
|
|
|
|
// harmless reading if one ever arrives here.
|
|
|
|
|
Block::Gated { block, .. } => self_block(block, out),
|
2026-10-04 19:55:21 +03:00
|
|
|
Block::CardBreak { .. } => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn inlines(inline: &[Inline]) -> String {
|
|
|
|
|
let mut out = String::new();
|
|
|
|
|
for item in inline {
|
|
|
|
|
match item {
|
|
|
|
|
Inline::Text(text) => out.push_str(&escape::text(text)),
|
|
|
|
|
Inline::Code(code) => out.push_str(&format!("<code>{}</code>", escape::text(code))),
|
|
|
|
|
Inline::Emph(inner) => out.push_str(&format!("<em>{}</em>", inlines(inner))),
|
|
|
|
|
Inline::Strong(inner) => out.push_str(&format!("<strong>{}</strong>", inlines(inner))),
|
|
|
|
|
// XHTML-MP 1.0 has no <del>, and <strike> is not in the profile, so
|
|
|
|
|
// the text survives without its markup rather than being dropped.
|
|
|
|
|
Inline::Strike(inner) => out.push_str(&inlines(inner)),
|
|
|
|
|
Inline::Link { href, title, label } => {
|
|
|
|
|
let title = title
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(|t| format!(" title=\"{}\"", escape::attr(t)))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
out.push_str(&format!(
|
|
|
|
|
"<a href=\"{}\"{title}>{}</a>",
|
|
|
|
|
escape::attr(href),
|
|
|
|
|
inlines(label)
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Inline::Image { src, title, alt } => {
|
|
|
|
|
let title = title
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(|t| format!(" title=\"{}\"", escape::attr(t)))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
out.push_str(&format!(
|
|
|
|
|
"<img src=\"{}\" alt=\"{}\"{title}/>",
|
|
|
|
|
escape::attr(src),
|
|
|
|
|
escape::attr(&Doc::plain_text(alt))
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Inline::SoftBreak => out.push('\n'),
|
|
|
|
|
Inline::HardBreak => out.push_str("<br/>\n"),
|
|
|
|
|
Inline::Html(html) => out.push_str(html),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_prolog_is_present_only_when_asked_for() {
|
|
|
|
|
let doc = Doc { blocks: Vec::new(), first_h1: None };
|
|
|
|
|
assert!(document(&doc, "T", true).starts_with("<?xml"));
|
|
|
|
|
assert!(document(&doc, "T", false).starts_with("<!DOCTYPE html"));
|
|
|
|
|
}
|
|
|
|
|
}
|