feat: serve rendered pages over http, spartan and nex
This commit is contained in:
parent
46f12b10da
commit
2f38b8b929
30 changed files with 2896 additions and 90 deletions
48
wap/src/escape.rs
Normal file
48
wap/src/escape.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//! XML escaping.
|
||||
|
||||
/// Escape text for an element's content.
|
||||
pub fn text(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Escape text for an attribute value, which is always double-quoted here.
|
||||
pub fn attr(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn escapes_markup_characters() {
|
||||
assert_eq!(text("a & b < c > d"), "a & b < c > d");
|
||||
// A quote is only significant inside an attribute, so content keeps it.
|
||||
assert_eq!(text("say \"hi\""), "say \"hi\"");
|
||||
assert_eq!(attr("say \"hi\" & bye"), "say "hi" & bye");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_ordinary_text_alone() {
|
||||
assert_eq!(text("plain text 123"), "plain text 123");
|
||||
}
|
||||
}
|
||||
176
wap/src/lib.rs
Normal file
176
wap/src/lib.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
//! 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.
|
||||
|
||||
mod escape;
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
170
wap/src/xhtmlmp.rs
Normal file
170
wap/src/xhtmlmp.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
//! 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),
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue