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
|
|
@ -18,6 +18,8 @@ spartan = []
|
|||
nex = []
|
||||
gemtext = ["dep:itsybitsy-gemtext"]
|
||||
wap = ["dep:itsybitsy-wap"]
|
||||
# WML 1.3 decks for WAP 1.x handsets.
|
||||
wml = ["wap", "itsybitsy-wap/wml"]
|
||||
text = ["dep:itsybitsy-text"]
|
||||
# FIGlet banners and hyphenation for the text format, both off by default.
|
||||
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)
|
||||
}
|
||||
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 })) => {
|
||||
let meta = std::fs::metadata(&path)?;
|
||||
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)?;
|
||||
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")?,
|
||||
Err(err) => {
|
||||
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""),
|
||||
},
|
||||
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 })) => {
|
||||
write!(stream, "2 {media_type}\r\n")?;
|
||||
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::Html)).expect("duplicate renderer id");
|
||||
#[cfg(feature = "wml")]
|
||||
registry.insert(Arc::new(itsybitsy_wap::Wml)).expect("duplicate renderer id");
|
||||
}
|
||||
|
||||
registry
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ impl Server {
|
|||
write(dir.path(), "one/img.png", "not really a png");
|
||||
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(),
|
||||
"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!(
|
||||
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("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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue