feat: let a site render its xhtmlmp/html output as a Mews Profile page
A new mews_profile site setting switches the XHTML-MP 1.2 doctype, the conformance marker, the viewport tag and the default stylesheet link, and drops raw HTML passthrough and off-site or data: images that the profile's permitted-element list cannot vouch for.
This commit is contained in:
parent
1b47145522
commit
c9bde15f5c
9 changed files with 357 additions and 70 deletions
|
|
@ -42,6 +42,8 @@ site = "smol"
|
||||||
|
|
||||||
HTTP and Spartan route by the hostname the request carries, falling back to `default_site`; Nex and Gopher carry none, so their listener names one site outright. Every format a listener serves must be compiled in, which `--check` reports. A `.itsybitsy.toml` in a content directory sets rendering options for that directory — `[defaults]` for all its Markdown files, `[page."name.md"]` for one — with no inheritance from parent directories. Editing it re-renders on the next request.
|
HTTP and Spartan route by the hostname the request carries, falling back to `default_site`; Nex and Gopher carry none, so their listener names one site outright. Every format a listener serves must be compiled in, which `--check` reports. A `.itsybitsy.toml` in a content directory sets rendering options for that directory — `[defaults]` for all its Markdown files, `[page."name.md"]` for one — with no inheritance from parent directories. Editing it re-renders on the next request.
|
||||||
|
|
||||||
|
A site can set `mews_profile = true` to render its `xhtmlmp` and `html` output as [Mews Profile](https://mews.page/spec/) pages: the XHTML-MP 1.2 doctype, the conformance marker, the viewport tag and the default stylesheet link, with raw HTML and off-site or `data:` images dropped rather than passed through. Sites sharing one root must agree on this setting, since they share one render cache.
|
||||||
|
|
||||||
### Formats and protocols
|
### Formats and protocols
|
||||||
|
|
||||||
| Format | Served as | Behind feature |
|
| Format | Served as | Behind feature |
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,13 @@ pub struct SiteSpec {
|
||||||
/// `default_site`.
|
/// `default_site`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hosts: Vec<String>,
|
pub hosts: Vec<String>,
|
||||||
|
/// Render this site's `xhtmlmp`/`html` output as a Mews Profile page
|
||||||
|
/// (mews.page/spec): the XHTML-MP 1.2 doctype, the conformance marker, the
|
||||||
|
/// viewport tag and the default stylesheet link, with raw HTML and
|
||||||
|
/// off-site or `data:` images dropped rather than passed through, since the
|
||||||
|
/// profile's permitted-element list cannot otherwise be guaranteed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mews_profile: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -169,13 +176,27 @@ impl ServerConfig {
|
||||||
self.reject_config_inside_root(config_path, &roots)?;
|
self.reject_config_inside_root(config_path, &roots)?;
|
||||||
let hosts = self.build_host_map()?;
|
let hosts = self.build_host_map()?;
|
||||||
let formats = self.validate_listeners(available_formats)?;
|
let formats = self.validate_listeners(available_formats)?;
|
||||||
|
let shared_roots = group_shared_roots(&roots);
|
||||||
|
self.validate_shared_mews_profile(&shared_roots)?;
|
||||||
|
|
||||||
Ok(Checked {
|
Ok(Checked { hosts, roots: roots.clone(), shared_roots, formats })
|
||||||
hosts,
|
}
|
||||||
roots: roots.clone(),
|
|
||||||
shared_roots: group_shared_roots(&roots),
|
/// Sites sharing a root share one [`crate::site::Site`] and so one render
|
||||||
formats,
|
/// cache; a page rendered once there cannot differ by which name served the
|
||||||
})
|
/// request, so `mews_profile` must agree within a group.
|
||||||
|
fn validate_shared_mews_profile(&self, shared_roots: &[Vec<String>]) -> Result<(), Error> {
|
||||||
|
for group in shared_roots {
|
||||||
|
let mut settings = group.iter().map(|name| self.site[name].mews_profile);
|
||||||
|
let first = settings.next().unwrap_or(false);
|
||||||
|
if settings.any(|value| value != first) {
|
||||||
|
return Err(Error::config(format!(
|
||||||
|
"sites {} share one root, so mews_profile must agree between them",
|
||||||
|
group.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonicalise every content root, which also proves it exists and is a
|
/// Canonicalise every content root, which also proves it exists and is a
|
||||||
|
|
@ -915,6 +936,19 @@ mod tests {
|
||||||
assert_eq!(checked.shared_roots, vec![vec!["one".to_string(), "two".to_string()]]);
|
assert_eq!(checked.shared_roots, vec![vec!["one".to_string(), "two".to_string()]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_sites_sharing_one_root_with_differing_mews_profile() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("content");
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
let two = format!(
|
||||||
|
"{HTTP}\n[site.two]\nroot = {:?}\nhosts = [\"two.test\"]\nmews_profile = true\n",
|
||||||
|
root.to_str().unwrap()
|
||||||
|
);
|
||||||
|
let err = check(dir.path(), &two).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("mews_profile must agree"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_a_config_file_inside_a_content_root() {
|
fn rejects_a_config_file_inside_a_content_root() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ pub struct RenderCtx<'a> {
|
||||||
pub title: &'a str,
|
pub title: &'a str,
|
||||||
pub settings: &'a PageSettings,
|
pub settings: &'a PageSettings,
|
||||||
pub width: Option<u16>,
|
pub width: Option<u16>,
|
||||||
|
/// The site's own hostnames, present when it has opted into Mews Profile
|
||||||
|
/// compliance (mews.page/spec) and absent otherwise. Threaded through so a
|
||||||
|
/// renderer that cares can switch its markup and judge an image `src` as
|
||||||
|
/// same-site, without reaching into site configuration itself.
|
||||||
|
pub mews_hosts: Option<&'a [String]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Rendered {
|
pub struct Rendered {
|
||||||
|
|
@ -112,6 +117,7 @@ impl Registry {
|
||||||
url: &str,
|
url: &str,
|
||||||
settings: &PageSettings,
|
settings: &PageSettings,
|
||||||
fallback_title: &str,
|
fallback_title: &str,
|
||||||
|
mews_hosts: Option<&[String]>,
|
||||||
) -> Result<Page, Error> {
|
) -> Result<Page, Error> {
|
||||||
let title = settings
|
let title = settings
|
||||||
.title
|
.title
|
||||||
|
|
@ -131,8 +137,13 @@ impl Registry {
|
||||||
// caller passing an unknown id is a bug, not bad input.
|
// caller passing an unknown id is a bug, not bad input.
|
||||||
Error::config(format!("no renderer provides the format '{id}'"))
|
Error::config(format!("no renderer provides the format '{id}'"))
|
||||||
})?;
|
})?;
|
||||||
let ctx =
|
let ctx = RenderCtx {
|
||||||
RenderCtx { url, title: &page.title, settings, width: renderer.default_width() };
|
url,
|
||||||
|
title: &page.title,
|
||||||
|
settings,
|
||||||
|
width: renderer.default_width(),
|
||||||
|
mews_hosts,
|
||||||
|
};
|
||||||
// Format gates are resolved here, so no renderer meets one and a
|
// Format gates are resolved here, so no renderer meets one and a
|
||||||
// gated run costs nothing extra in the cache: bodies are already
|
// gated run costs nothing extra in the cache: bodies are already
|
||||||
// kept per format.
|
// kept per format.
|
||||||
|
|
@ -209,7 +220,10 @@ mod tests {
|
||||||
Ok(Rendered::body(
|
Ok(Rendered::body(
|
||||||
// The blocks are printed too, so what a format was handed —
|
// The blocks are printed too, so what a format was handed —
|
||||||
// after gates — is visible in the body.
|
// after gates — is visible in the body.
|
||||||
format!("{} {} {:?} {} {:?}", self.id, ctx.url, ctx.width, ctx.title, doc.blocks)
|
format!(
|
||||||
|
"{} {} {:?} {} {:?} {:?}",
|
||||||
|
self.id, ctx.url, ctx.width, ctx.title, doc.blocks, ctx.mews_hosts
|
||||||
|
)
|
||||||
.into_bytes(),
|
.into_bytes(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
@ -238,7 +252,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_only_the_formats_asked_for() {
|
fn renders_only_the_formats_asked_for() {
|
||||||
let page = registry()
|
let page = registry()
|
||||||
.page(&["one".to_string()], &doc(None), "/x", &PageSettings::default(), "x")
|
.page(&["one".to_string()], &doc(None), "/x", &PageSettings::default(), "x", None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(page.body("one").is_some());
|
assert!(page.body("one").is_some());
|
||||||
assert!(page.body("two").is_none(), "a format no listener serves is not rendered");
|
assert!(page.body("two").is_none(), "a format no listener serves is not rendered");
|
||||||
|
|
@ -247,26 +261,44 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn each_renderer_gets_its_own_declared_width() {
|
fn each_renderer_gets_its_own_declared_width() {
|
||||||
let formats = vec!["one".to_string(), "two".to_string()];
|
let formats = vec!["one".to_string(), "two".to_string()];
|
||||||
let page =
|
let page = registry()
|
||||||
registry().page(&formats, &doc(None), "/x", &PageSettings::default(), "x").unwrap();
|
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None)
|
||||||
|
.unwrap();
|
||||||
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("None"));
|
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("None"));
|
||||||
assert!(String::from_utf8_lossy(page.body("two").unwrap()).contains("Some(80)"));
|
assert!(String::from_utf8_lossy(page.body("two").unwrap()).contains("Some(80)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mews_hosts_reaches_the_renderer_when_the_site_opted_in() {
|
||||||
|
let formats = vec!["one".to_string()];
|
||||||
|
let hosts = vec!["example.test".to_string()];
|
||||||
|
let page = registry()
|
||||||
|
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", Some(&hosts))
|
||||||
|
.unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("example.test"));
|
||||||
|
|
||||||
|
let unset = registry()
|
||||||
|
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None)
|
||||||
|
.unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(unset.body("one").unwrap()).contains("None"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_title_falls_back_from_config_to_heading_to_file_name() {
|
fn the_title_falls_back_from_config_to_heading_to_file_name() {
|
||||||
let configured = PageSettings { title: Some("Configured".into()), ..Default::default() };
|
let configured = PageSettings { title: Some("Configured".into()), ..Default::default() };
|
||||||
let formats = vec!["one".to_string()];
|
let formats = vec!["one".to_string()];
|
||||||
let reg = registry();
|
let reg = registry();
|
||||||
|
|
||||||
let from_config = reg.page(&formats, &doc(Some("Heading")), "/x", &configured, "stem");
|
let from_config =
|
||||||
|
reg.page(&formats, &doc(Some("Heading")), "/x", &configured, "stem", None);
|
||||||
assert_eq!(from_config.unwrap().title, "Configured");
|
assert_eq!(from_config.unwrap().title, "Configured");
|
||||||
|
|
||||||
let from_heading =
|
let from_heading =
|
||||||
reg.page(&formats, &doc(Some("Heading")), "/x", &PageSettings::default(), "stem");
|
reg.page(&formats, &doc(Some("Heading")), "/x", &PageSettings::default(), "stem", None);
|
||||||
assert_eq!(from_heading.unwrap().title, "Heading");
|
assert_eq!(from_heading.unwrap().title, "Heading");
|
||||||
|
|
||||||
let from_stem = reg.page(&formats, &doc(None), "/x", &PageSettings::default(), "stem");
|
let from_stem =
|
||||||
|
reg.page(&formats, &doc(None), "/x", &PageSettings::default(), "stem", None);
|
||||||
assert_eq!(from_stem.unwrap().title, "stem");
|
assert_eq!(from_stem.unwrap().title, "stem");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,7 +313,8 @@ mod tests {
|
||||||
first_h1: None,
|
first_h1: None,
|
||||||
};
|
};
|
||||||
let formats = vec!["one".to_string(), "two".to_string()];
|
let formats = vec!["one".to_string(), "two".to_string()];
|
||||||
let page = registry().page(&formats, &gated, "/x", &PageSettings::default(), "x").unwrap();
|
let page =
|
||||||
|
registry().page(&formats, &gated, "/x", &PageSettings::default(), "x", None).unwrap();
|
||||||
// The stub prints the blocks it was handed, so presence is visible.
|
// The stub prints the blocks it was handed, so presence is visible.
|
||||||
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("secret"));
|
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("secret"));
|
||||||
assert!(!String::from_utf8_lossy(page.body("two").unwrap()).contains("secret"));
|
assert!(!String::from_utf8_lossy(page.body("two").unwrap()).contains("secret"));
|
||||||
|
|
@ -290,7 +323,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_format_is_a_bug_not_bad_input() {
|
fn an_unknown_format_is_a_bug_not_bad_input() {
|
||||||
let err = registry()
|
let err = registry()
|
||||||
.page(&["absent".to_string()], &doc(None), "/x", &PageSettings::default(), "x")
|
.page(&["absent".to_string()], &doc(None), "/x", &PageSettings::default(), "x", None)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("no renderer provides"), "{err}");
|
assert!(err.to_string().contains("no renderer provides"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,28 @@ pub struct Site {
|
||||||
/// Formats every page here is rendered into: the union of what the enabled
|
/// Formats every page here is rendered into: the union of what the enabled
|
||||||
/// listeners can serve, so a site with no HTTP listener never renders HTML.
|
/// listeners can serve, so a site with no HTTP listener never renders HTML.
|
||||||
formats: Vec<String>,
|
formats: Vec<String>,
|
||||||
|
/// Whether this site's `xhtmlmp`/`html` output is rendered as Mews Profile
|
||||||
|
/// pages (`SiteSpec::mews_profile`). Validation requires every site sharing
|
||||||
|
/// this root to agree, so one value is correct for all of them.
|
||||||
|
mews_profile: bool,
|
||||||
|
/// This site's own hostnames, used to judge an image `src` as same-site
|
||||||
|
/// when `mews_profile` is set. Taken from whichever configured site name
|
||||||
|
/// opened this root first; a root reached under several names with
|
||||||
|
/// differing host lists keeps only that one's, which is an accepted
|
||||||
|
/// simplification rather than a union of them all.
|
||||||
|
hosts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Site {
|
impl Site {
|
||||||
/// Open a content root, canonicalising it so containment checks have a
|
/// Open a content root, canonicalising it so containment checks have a
|
||||||
/// stable base and a missing root fails now rather than per request.
|
/// stable base and a missing root fails now rather than per request.
|
||||||
pub fn new(root: &Path, registry: Arc<Registry>, formats: Vec<String>) -> Result<Self, Error> {
|
pub fn new(
|
||||||
|
root: &Path,
|
||||||
|
registry: Arc<Registry>,
|
||||||
|
formats: Vec<String>,
|
||||||
|
mews_profile: bool,
|
||||||
|
hosts: Vec<String>,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
let root =
|
let root =
|
||||||
root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?;
|
root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?;
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
|
|
@ -77,6 +93,8 @@ impl Site {
|
||||||
built_in: Arc::new(DirConfig::default()),
|
built_in: Arc::new(DirConfig::default()),
|
||||||
registry,
|
registry,
|
||||||
formats,
|
formats,
|
||||||
|
mews_profile,
|
||||||
|
hosts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,7 +231,15 @@ impl Site {
|
||||||
let settings = self.dir_config(dir)?.settings_for(name)?;
|
let settings = self.dir_config(dir)?.settings_for(name)?;
|
||||||
let doc = parse::document(source, &self.root)?;
|
let doc = parse::document(source, &self.root)?;
|
||||||
let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
|
let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
|
||||||
self.registry.page(&self.formats, &doc, url, &settings, &title_from_stem(stem))
|
let mews_hosts = self.mews_profile.then_some(self.hosts.as_slice());
|
||||||
|
self.registry.page(
|
||||||
|
&self.formats,
|
||||||
|
&doc,
|
||||||
|
url,
|
||||||
|
&settings,
|
||||||
|
&title_from_stem(stem),
|
||||||
|
mews_hosts,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,7 +275,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||||
let mut body = format!("title={}\n", ctx.title);
|
let mut body = format!("title={}\nmews_hosts={:?}\n", ctx.title, ctx.mews_hosts);
|
||||||
for block in &doc.blocks {
|
for block in &doc.blocks {
|
||||||
body.push_str(&format!("{block:?}\n"));
|
body.push_str(&format!("{block:?}\n"));
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +290,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open(root: &Path) -> Site {
|
fn open(root: &Path) -> Site {
|
||||||
Site::new(root, registry(), vec!["stub".to_string()]).unwrap()
|
Site::new(root, registry(), vec!["stub".to_string()], false, Vec::new()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One of each kind of thing a request can land on, shared by the resolution
|
/// One of each kind of thing a request can land on, shared by the resolution
|
||||||
|
|
@ -550,11 +576,25 @@ mod tests {
|
||||||
assert!(matches!(site.resolve("/about"), Err(Error::Toml { .. })));
|
assert!(matches!(site.resolve("/about"), Err(Error::Toml { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mews_profile_and_hosts_reach_the_render_context() {
|
||||||
|
let (dir, _site) = fixture();
|
||||||
|
let hosts = vec!["example.test".to_string()];
|
||||||
|
let mews_site =
|
||||||
|
Site::new(dir.path(), registry(), vec!["stub".to_string()], true, hosts).unwrap();
|
||||||
|
let (_, page) = document(&mews_site, "/about");
|
||||||
|
let body = String::from_utf8(page.body("stub").unwrap().to_vec()).unwrap();
|
||||||
|
assert!(body.contains("mews_hosts=Some([\"example.test\"])"), "{body}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_root_is_rejected_at_construction() {
|
fn a_missing_root_is_rejected_at_construction() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let absent = dir.path().join("absent");
|
let absent = dir.path().join("absent");
|
||||||
assert!(matches!(Site::new(&absent, registry(), vec![]), Err(Error::Io { .. })));
|
assert!(matches!(
|
||||||
|
Site::new(&absent, registry(), vec![], false, Vec::new()),
|
||||||
|
Err(Error::Io { .. })
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -562,7 +602,10 @@ mod tests {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let file = dir.path().join("not-a-dir");
|
let file = dir.path().join("not-a-dir");
|
||||||
fs::write(&file, "x").unwrap();
|
fs::write(&file, "x").unwrap();
|
||||||
assert!(matches!(Site::new(&file, registry(), vec![]), Err(Error::Config { .. })));
|
assert!(matches!(
|
||||||
|
Site::new(&file, registry(), vec![], false, Vec::new()),
|
||||||
|
Err(Error::Config { .. })
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -622,7 +665,9 @@ mod part_tests {
|
||||||
|
|
||||||
let mut registry = Registry::new();
|
let mut registry = Registry::new();
|
||||||
registry.insert(Arc::new(Paginating)).unwrap();
|
registry.insert(Arc::new(Paginating)).unwrap();
|
||||||
let site = Site::new(root, Arc::new(registry), vec!["paginating".to_string()]).unwrap();
|
let site =
|
||||||
|
Site::new(root, Arc::new(registry), vec!["paginating".to_string()], false, Vec::new())
|
||||||
|
.unwrap();
|
||||||
(dir, site)
|
(dir, site)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ impl SiteSet {
|
||||||
SiteSet { sites: Vec::new(), by_host: HashMap::new(), by_name: HashMap::new() };
|
SiteSet { sites: Vec::new(), by_host: HashMap::new(), by_name: HashMap::new() };
|
||||||
|
|
||||||
for (name, spec) in &config.site {
|
for (name, spec) in &config.site {
|
||||||
let site = Site::new(&spec.root, registry.clone(), formats.clone())?;
|
let hosts = spec.hosts.iter().filter_map(|host| normalize_host(host)).collect();
|
||||||
|
let site =
|
||||||
|
Site::new(&spec.root, registry.clone(), formats.clone(), spec.mews_profile, hosts)?;
|
||||||
// One `Site` per distinct root, so sites sharing a folder share its
|
// One `Site` per distinct root, so sites sharing a folder share its
|
||||||
// caches rather than each building their own.
|
// caches rather than each building their own.
|
||||||
let index = match set.sites.iter().position(|open| open.root() == site.root()) {
|
let index = match set.sites.iter().position(|open| open.root() == site.root()) {
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,8 @@ mod tests_support {
|
||||||
pub fn render(markdown: &str) -> String {
|
pub fn render(markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown).unwrap();
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
let ctx =
|
||||||
|
RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None };
|
||||||
let out = Gemtext.render(&doc, &ctx).unwrap();
|
let out = Gemtext.render(&doc, &ctx).unwrap();
|
||||||
String::from_utf8(out.body).unwrap()
|
String::from_utf8(out.body).unwrap()
|
||||||
}
|
}
|
||||||
|
|
@ -398,7 +399,8 @@ mod tests {
|
||||||
// Pagination is a WML concern; a Gemini client scrolls one document.
|
// Pagination is a WML concern; a Gemini client scrolls one document.
|
||||||
let doc = parse::markdown("a\n\nb\n").unwrap();
|
let doc = parse::markdown("a\n\nb\n").unwrap();
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
let ctx =
|
||||||
|
RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None };
|
||||||
let plain = String::from_utf8(Gemtext.render(&doc, &ctx).unwrap().body).unwrap();
|
let plain = String::from_utf8(Gemtext.render(&doc, &ctx).unwrap().body).unwrap();
|
||||||
assert_eq!(plain, "a\n\nb\n");
|
assert_eq!(plain, "a\n\nb\n");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@ mod tests {
|
||||||
/// assertions read as the content itself.
|
/// assertions read as the content itself.
|
||||||
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
|
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown).unwrap();
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
|
let ctx =
|
||||||
|
RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None };
|
||||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,7 +214,8 @@ mod tests {
|
||||||
/// `parse::document`, which needs a file, so the unit tests build them.
|
/// `parse::document`, which needs a file, so the unit tests build them.
|
||||||
fn render_blocks(settings: &PageSettings, width: u16, blocks: Vec<Block>) -> String {
|
fn render_blocks(settings: &PageSettings, width: u16, blocks: Vec<Block>) -> String {
|
||||||
let doc = Doc { blocks, first_h1: None };
|
let doc = Doc { blocks, first_h1: None };
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
|
let ctx =
|
||||||
|
RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None };
|
||||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,7 +269,13 @@ mod separation_tests {
|
||||||
fn render(markdown: &str) -> String {
|
fn render(markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown).unwrap();
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
let settings = PageSettings { margin_left: 0, margin_right: 0, ..Default::default() };
|
let settings = PageSettings { margin_left: 0, margin_right: 0, ..Default::default() };
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: Some(40) };
|
let ctx = RenderCtx {
|
||||||
|
url: "/x",
|
||||||
|
title: "T",
|
||||||
|
settings: &settings,
|
||||||
|
width: Some(40),
|
||||||
|
mews_hosts: None,
|
||||||
|
};
|
||||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,7 +307,8 @@ mod optional_feature_tests {
|
||||||
|
|
||||||
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
|
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown).unwrap();
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
|
let ctx =
|
||||||
|
RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None };
|
||||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ impl Renderer for XhtmlMp {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||||
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true).into_bytes()))
|
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true, ctx.mews_hosts).into_bytes()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +46,7 @@ impl Renderer for Html {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||||
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false).into_bytes()))
|
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false, ctx.mews_hosts).into_bytes()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +82,13 @@ mod tests {
|
||||||
fn render(renderer: &dyn Renderer, markdown: &str) -> String {
|
fn render(renderer: &dyn Renderer, markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown).unwrap();
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx = RenderCtx { url: "/x", title: "The Title", settings: &settings, width: None };
|
let ctx = RenderCtx {
|
||||||
|
url: "/x",
|
||||||
|
title: "The Title",
|
||||||
|
settings: &settings,
|
||||||
|
width: None,
|
||||||
|
mews_hosts: None,
|
||||||
|
};
|
||||||
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
|
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,7 +184,8 @@ mod tests {
|
||||||
first_h1: None,
|
first_h1: None,
|
||||||
};
|
};
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
let ctx =
|
||||||
|
RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None };
|
||||||
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
|
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
|
||||||
assert!(out.contains("<pre title=\"Dragon\">/\\</pre>"), "{out}");
|
assert!(out.contains("<pre title=\"Dragon\">/\\</pre>"), "{out}");
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +202,8 @@ mod tests {
|
||||||
first_h1: None,
|
first_h1: None,
|
||||||
};
|
};
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
let ctx =
|
||||||
|
RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None };
|
||||||
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
|
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
|
||||||
assert!(out.contains("<body>\n</body>"), "{out}");
|
assert!(out.contains("<body>\n</body>"), "{out}");
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +226,8 @@ mod deck_tests {
|
||||||
// Card dividers are comment directives, so the IR is built by hand here
|
// Card dividers are comment directives, so the IR is built by hand here
|
||||||
// the way `parse::document` would produce it.
|
// the way `parse::document` would produce it.
|
||||||
let doc = with_card_breaks(markdown);
|
let doc = with_card_breaks(markdown);
|
||||||
let ctx = RenderCtx { url: "/trail", title: "Trail", settings, width: None };
|
let ctx =
|
||||||
|
RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews_hosts: None };
|
||||||
Wml.render(&doc, &ctx).unwrap()
|
Wml.render(&doc, &ctx).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,7 +483,13 @@ mod oracle_dump {
|
||||||
];
|
];
|
||||||
for (name, settings) in cases {
|
for (name, settings) in cases {
|
||||||
let doc = with_card_breaks(TRAIL);
|
let doc = with_card_breaks(TRAIL);
|
||||||
let ctx = RenderCtx { url: "/trail", title: "Trail", settings: &settings, width: None };
|
let ctx = RenderCtx {
|
||||||
|
url: "/trail",
|
||||||
|
title: "Trail",
|
||||||
|
settings: &settings,
|
||||||
|
width: None,
|
||||||
|
mews_hosts: None,
|
||||||
|
};
|
||||||
let out = Wml.render(&doc, &ctx).unwrap();
|
let out = Wml.render(&doc, &ctx).unwrap();
|
||||||
std::fs::write(format!("/tmp/mine-{name}.wml"), out.body).unwrap();
|
std::fs::write(format!("/tmp/mine-{name}.wml"), out.body).unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
//! logs a warning for it. So the HTML form is the same markup with that one line
|
//! logs a warning for it. So the HTML form is the same markup with that one line
|
||||||
//! removed.
|
//! removed.
|
||||||
|
|
||||||
|
use itsybitsy_core::config::normalize_host;
|
||||||
use itsybitsy_core::ir::{Block, Doc, Inline};
|
use itsybitsy_core::ir::{Block, Doc, Inline};
|
||||||
|
|
||||||
use crate::escape;
|
use crate::escape;
|
||||||
|
|
@ -13,37 +14,62 @@ use crate::escape;
|
||||||
/// The XML declaration, which only the XHTML-MP form carries.
|
/// The XML declaration, which only the XHTML-MP form carries.
|
||||||
const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
|
const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
|
||||||
|
|
||||||
const DOCTYPE: &str = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\" \
|
/// The ordinary doctype, used whenever a site has not opted into Mews Profile
|
||||||
|
/// compliance.
|
||||||
|
const DOCTYPE_1_0: &str = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\" \
|
||||||
\"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\">\n";
|
\"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\">\n";
|
||||||
|
|
||||||
|
/// Mews Profile pages carry the XHTML-MP 1.2 doctype instead (mews.page/spec
|
||||||
|
/// 3.1), which is what a Mews DTD or validator checks a page against.
|
||||||
|
const DOCTYPE_1_2: &str = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.2//EN\" \
|
||||||
|
\"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd\">\n";
|
||||||
|
|
||||||
|
/// The conformance marker (3.2), viewport tag (3.3) and default stylesheet
|
||||||
|
/// link (5.2), pinned to spec version 0.1: a site opts into the profile as a
|
||||||
|
/// whole, not into picking its own version or stylesheet path.
|
||||||
|
const MEWS_HEAD_EXTRA: &str = "<meta name=\"mews-profile\" content=\"0.1\" />\n\
|
||||||
|
<meta name=\"viewport\" content=\"width=device-width\" />\n\
|
||||||
|
<link rel=\"stylesheet\" type=\"text/css\" href=\"mews-0.1.css\" />\n";
|
||||||
|
|
||||||
/// Build the whole document. `prolog` distinguishes the two media types.
|
/// Build the whole document. `prolog` distinguishes the two media types.
|
||||||
pub fn document(doc: &Doc, title: &str, prolog: bool) -> String {
|
/// `mews_hosts` is `Some` with the site's own hostnames when it has opted into
|
||||||
|
/// Mews Profile compliance, and `None` otherwise.
|
||||||
|
pub fn document(doc: &Doc, title: &str, prolog: bool, mews_hosts: Option<&[String]>) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
if prolog {
|
if prolog {
|
||||||
out.push_str(PROLOG);
|
out.push_str(PROLOG);
|
||||||
}
|
}
|
||||||
out.push_str(DOCTYPE);
|
out.push_str(if mews_hosts.is_some() { DOCTYPE_1_2 } else { DOCTYPE_1_0 });
|
||||||
out.push_str("<html xmlns=\"http://www.w3.org/1999/xhtml\">\n");
|
out.push_str("<html xmlns=\"http://www.w3.org/1999/xhtml\">\n");
|
||||||
|
if mews_hosts.is_some() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<head>\n<title>{}</title>\n{MEWS_HEAD_EXTRA}</head>\n",
|
||||||
|
escape::text(title)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
out.push_str(&format!("<head><title>{}</title></head>\n", escape::text(title)));
|
out.push_str(&format!("<head><title>{}</title></head>\n", escape::text(title)));
|
||||||
|
}
|
||||||
out.push_str("<body>\n");
|
out.push_str("<body>\n");
|
||||||
blocks(&doc.blocks, &mut out);
|
blocks(&doc.blocks, mews_hosts, &mut out);
|
||||||
out.push_str("</body>\n</html>\n");
|
out.push_str("</body>\n</html>\n");
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blocks(blocks: &[Block], out: &mut String) {
|
fn blocks(blocks: &[Block], mews_hosts: Option<&[String]>, out: &mut String) {
|
||||||
for block in blocks {
|
for block in blocks {
|
||||||
self_block(block, out);
|
self_block(block, mews_hosts, out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn self_block(block: &Block, out: &mut String) {
|
fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
|
||||||
match block {
|
match block {
|
||||||
Block::Heading { level, inline } => {
|
Block::Heading { level, inline } => {
|
||||||
let level = (*level).clamp(1, 6);
|
let level = (*level).clamp(1, 6);
|
||||||
out.push_str(&format!("<h{level}>{}</h{level}>\n", inlines(inline)));
|
out.push_str(&format!("<h{level}>{}</h{level}>\n", inlines(inline, mews_hosts)));
|
||||||
|
}
|
||||||
|
Block::Paragraph(inline) => {
|
||||||
|
out.push_str(&format!("<p>{}</p>\n", inlines(inline, mews_hosts)))
|
||||||
}
|
}
|
||||||
Block::Paragraph(inline) => out.push_str(&format!("<p>{}</p>\n", inlines(inline))),
|
|
||||||
Block::CodeBlock { lines, .. } => {
|
Block::CodeBlock { lines, .. } => {
|
||||||
out.push_str("<pre>");
|
out.push_str("<pre>");
|
||||||
out.push_str(&escape::text(&lines.join("\n")));
|
out.push_str(&escape::text(&lines.join("\n")));
|
||||||
|
|
@ -58,7 +84,7 @@ fn self_block(block: &Block, out: &mut String) {
|
||||||
}
|
}
|
||||||
Block::BlockQuote(inner) => {
|
Block::BlockQuote(inner) => {
|
||||||
out.push_str("<blockquote>\n");
|
out.push_str("<blockquote>\n");
|
||||||
blocks(inner, out);
|
blocks(inner, mews_hosts, out);
|
||||||
out.push_str("</blockquote>\n");
|
out.push_str("</blockquote>\n");
|
||||||
}
|
}
|
||||||
Block::List { ordered, start, items } => {
|
Block::List { ordered, start, items } => {
|
||||||
|
|
@ -74,10 +100,10 @@ fn self_block(block: &Block, out: &mut String) {
|
||||||
out.push_str("<li>");
|
out.push_str("<li>");
|
||||||
// A single paragraph needs no block wrapper inside the item.
|
// A single paragraph needs no block wrapper inside the item.
|
||||||
match item.as_slice() {
|
match item.as_slice() {
|
||||||
[Block::Paragraph(inline)] => out.push_str(&inlines(inline)),
|
[Block::Paragraph(inline)] => out.push_str(&inlines(inline, mews_hosts)),
|
||||||
blocks_in_item => {
|
blocks_in_item => {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
blocks(blocks_in_item, out);
|
blocks(blocks_in_item, mews_hosts, out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push_str("</li>\n");
|
out.push_str("</li>\n");
|
||||||
|
|
@ -89,47 +115,55 @@ fn self_block(block: &Block, out: &mut String) {
|
||||||
if !head.is_empty() {
|
if !head.is_empty() {
|
||||||
out.push_str("<tr>");
|
out.push_str("<tr>");
|
||||||
for cell in head {
|
for cell in head {
|
||||||
out.push_str(&format!("<th>{}</th>", inlines(cell)));
|
out.push_str(&format!("<th>{}</th>", inlines(cell, mews_hosts)));
|
||||||
}
|
}
|
||||||
out.push_str("</tr>\n");
|
out.push_str("</tr>\n");
|
||||||
}
|
}
|
||||||
for row in rows {
|
for row in rows {
|
||||||
out.push_str("<tr>");
|
out.push_str("<tr>");
|
||||||
for cell in row {
|
for cell in row {
|
||||||
out.push_str(&format!("<td>{}</td>", inlines(cell)));
|
out.push_str(&format!("<td>{}</td>", inlines(cell, mews_hosts)));
|
||||||
}
|
}
|
||||||
out.push_str("</tr>\n");
|
out.push_str("</tr>\n");
|
||||||
}
|
}
|
||||||
out.push_str("</table>\n");
|
out.push_str("</table>\n");
|
||||||
}
|
}
|
||||||
Block::Rule => out.push_str("<hr/>\n"),
|
Block::Rule => out.push_str("<hr/>\n"),
|
||||||
// Raw HTML is passed through: this is the one family of formats where it
|
// Raw HTML is ordinarily passed through, since this is the one family
|
||||||
// is already in the right language.
|
// of formats where it is already in the right language. A Mews page
|
||||||
|
// cannot make that guarantee — the markup might use an element or
|
||||||
|
// attribute the profile excludes — so it is dropped instead.
|
||||||
Block::Html(html) => {
|
Block::Html(html) => {
|
||||||
|
if mews_hosts.is_none() {
|
||||||
out.push_str(html.trim_end());
|
out.push_str(html.trim_end());
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Alignment reaches only the fixed-width text formats. Pagination is a
|
// Alignment reaches only the fixed-width text formats. Pagination is a
|
||||||
// WML concern; a browser scrolls one document.
|
// WML concern; a browser scrolls one document.
|
||||||
Block::Aligned { block, .. } => self_block(block, out),
|
Block::Aligned { block, .. } => self_block(block, mews_hosts, out),
|
||||||
// Gates are filtered out before rendering; keeping the content is the
|
// Gates are filtered out before rendering; keeping the content is the
|
||||||
// harmless reading if one ever arrives here.
|
// harmless reading if one ever arrives here.
|
||||||
Block::Gated { block, .. } => self_block(block, out),
|
Block::Gated { block, .. } => self_block(block, mews_hosts, out),
|
||||||
Block::CardBreak { .. } => {}
|
Block::CardBreak { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inlines(inline: &[Inline]) -> String {
|
fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for item in inline {
|
for item in inline {
|
||||||
match item {
|
match item {
|
||||||
Inline::Text(text) => out.push_str(&escape::text(text)),
|
Inline::Text(text) => out.push_str(&escape::text(text)),
|
||||||
Inline::Code(code) => out.push_str(&format!("<code>{}</code>", escape::text(code))),
|
Inline::Code(code) => out.push_str(&format!("<code>{}</code>", escape::text(code))),
|
||||||
Inline::Emph(inner) => out.push_str(&format!("<em>{}</em>", inlines(inner))),
|
Inline::Emph(inner) => {
|
||||||
Inline::Strong(inner) => out.push_str(&format!("<strong>{}</strong>", inlines(inner))),
|
out.push_str(&format!("<em>{}</em>", inlines(inner, mews_hosts)))
|
||||||
|
}
|
||||||
|
Inline::Strong(inner) => {
|
||||||
|
out.push_str(&format!("<strong>{}</strong>", inlines(inner, mews_hosts)))
|
||||||
|
}
|
||||||
// XHTML-MP 1.0 has no <del>, and <strike> is not in the profile, so
|
// 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.
|
// the text survives without its markup rather than being dropped.
|
||||||
Inline::Strike(inner) => out.push_str(&inlines(inner)),
|
Inline::Strike(inner) => out.push_str(&inlines(inner, mews_hosts)),
|
||||||
Inline::Link { href, title, label } => {
|
Inline::Link { href, title, label } => {
|
||||||
let title = title
|
let title = title
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|
@ -138,10 +172,17 @@ fn inlines(inline: &[Inline]) -> String {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"<a href=\"{}\"{title}>{}</a>",
|
"<a href=\"{}\"{title}>{}</a>",
|
||||||
escape::attr(href),
|
escape::attr(href),
|
||||||
inlines(label)
|
inlines(label, mews_hosts)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Inline::Image { src, title, alt } => {
|
Inline::Image { src, title, alt } => {
|
||||||
|
// A Mews page's image must point at the same site and never be
|
||||||
|
// a `data:` URI (SPEC.md 4.2); one that does not is dropped to
|
||||||
|
// its alt text rather than rendered non-conformant.
|
||||||
|
if mews_hosts.is_some_and(|hosts| !same_site_image(src, hosts)) {
|
||||||
|
out.push_str(&escape::text(&Doc::plain_text(alt)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let title = title
|
let title = title
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|t| format!(" title=\"{}\"", escape::attr(t)))
|
.map(|t| format!(" title=\"{}\"", escape::attr(t)))
|
||||||
|
|
@ -154,20 +195,124 @@ fn inlines(inline: &[Inline]) -> String {
|
||||||
}
|
}
|
||||||
Inline::SoftBreak => out.push('\n'),
|
Inline::SoftBreak => out.push('\n'),
|
||||||
Inline::HardBreak => out.push_str("<br/>\n"),
|
Inline::HardBreak => out.push_str("<br/>\n"),
|
||||||
Inline::Html(html) => out.push_str(html),
|
// See the Block::Html arm above: unverifiable in a Mews page.
|
||||||
|
Inline::Html(html) => {
|
||||||
|
if mews_hosts.is_none() {
|
||||||
|
out.push_str(html);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an image `src` satisfies the Mews same-site rule (SPEC.md 4.2). A
|
||||||
|
/// `data:` URI never qualifies. A relative or root-relative URL always does,
|
||||||
|
/// since it resolves against the page's own origin by definition. An absolute
|
||||||
|
/// or protocol-relative URL qualifies when its host equals one of the site's
|
||||||
|
/// own hostnames or is a subdomain of one — a looser stand-in for "the same
|
||||||
|
/// registered domain" that needs no public-suffix list, adequate for hosts
|
||||||
|
/// the site itself configured.
|
||||||
|
fn same_site_image(src: &str, hosts: &[String]) -> bool {
|
||||||
|
if src.len() >= 5 && src.as_bytes()[..5].eq_ignore_ascii_case(b"data:") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(raw_host) = url_host(src) else { return true };
|
||||||
|
let Some(host) = normalize_host(raw_host) else { return false };
|
||||||
|
hosts.iter().any(|allowed| host == *allowed || host.ends_with(&format!(".{allowed}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host component of an absolute (`scheme://host/...`) or
|
||||||
|
/// protocol-relative (`//host/...`) URL, or `None` for anything else: a bare
|
||||||
|
/// or root-relative path has no host of its own, so it always resolves
|
||||||
|
/// against the page's own site.
|
||||||
|
fn url_host(src: &str) -> Option<&str> {
|
||||||
|
let rest = match src.split_once("://") {
|
||||||
|
Some((_scheme, rest)) => rest,
|
||||||
|
None => src.strip_prefix("//")?,
|
||||||
|
};
|
||||||
|
Some(rest.split(['/', '?', '#']).next().unwrap_or(rest))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn empty() -> Doc {
|
||||||
|
Doc { blocks: Vec::new(), first_h1: None }
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_prolog_is_present_only_when_asked_for() {
|
fn the_prolog_is_present_only_when_asked_for() {
|
||||||
let doc = Doc { blocks: Vec::new(), first_h1: None };
|
let doc = empty();
|
||||||
assert!(document(&doc, "T", true).starts_with("<?xml"));
|
assert!(document(&doc, "T", true, None).starts_with("<?xml"));
|
||||||
assert!(document(&doc, "T", false).starts_with("<!DOCTYPE html"));
|
assert!(document(&doc, "T", false, None).starts_with("<!DOCTYPE html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_page_keeps_the_1_0_doctype_and_no_mews_head() {
|
||||||
|
let out = document(&empty(), "T", false, None);
|
||||||
|
assert!(out.contains("XHTML Mobile 1.0"), "{out}");
|
||||||
|
assert!(!out.contains("mews-profile"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mews_page_carries_the_1_2_doctype_and_the_conformance_marker() {
|
||||||
|
let hosts = vec!["example.test".to_string()];
|
||||||
|
let out = document(&empty(), "T", false, Some(&hosts));
|
||||||
|
assert!(out.contains("XHTML Mobile 1.2"), "{out}");
|
||||||
|
assert!(out.contains("<meta name=\"mews-profile\" content=\"0.1\" />"), "{out}");
|
||||||
|
assert!(out.contains("<meta name=\"viewport\" content=\"width=device-width\" />"), "{out}");
|
||||||
|
assert!(
|
||||||
|
out.contains("<link rel=\"stylesheet\" type=\"text/css\" href=\"mews-0.1.css\" />"),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mews_page_drops_raw_html_it_cannot_vouch_for() {
|
||||||
|
let hosts = vec!["example.test".to_string()];
|
||||||
|
let doc = Doc { blocks: vec![Block::Html("<div>x</div>".into())], first_h1: None };
|
||||||
|
let out = document(&doc, "T", false, Some(&hosts));
|
||||||
|
assert!(!out.contains("<div>"), "{out}");
|
||||||
|
|
||||||
|
let plain = document(&doc, "T", false, None);
|
||||||
|
assert!(plain.contains("<div>x</div>"), "{plain}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_doc(src: &str) -> Doc {
|
||||||
|
Doc {
|
||||||
|
blocks: vec![Block::Paragraph(vec![Inline::Image {
|
||||||
|
src: src.into(),
|
||||||
|
title: None,
|
||||||
|
alt: vec![Inline::Text("a photo".into())],
|
||||||
|
}])],
|
||||||
|
first_h1: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mews_page_keeps_a_relative_or_same_site_image() {
|
||||||
|
let hosts = vec!["example.test".to_string()];
|
||||||
|
for src in ["/i.png", "i.png", "https://example.test/i.png", "//img.example.test/i.png"] {
|
||||||
|
let out = document(&image_doc(src), "T", false, Some(&hosts));
|
||||||
|
assert!(out.contains(&format!("src=\"{src}\"")), "{src}: {out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mews_page_drops_an_off_site_or_data_image_to_its_alt_text() {
|
||||||
|
let hosts = vec!["example.test".to_string()];
|
||||||
|
for src in ["https://elsewhere.test/i.png", "data:image/png;base64,AAAA"] {
|
||||||
|
let out = document(&image_doc(src), "T", false, Some(&hosts));
|
||||||
|
assert!(!out.contains("<img"), "{src}: {out}");
|
||||||
|
assert!(out.contains("a photo"), "{src}: {out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_page_keeps_every_image_regardless_of_its_host() {
|
||||||
|
let out = document(&image_doc("https://elsewhere.test/i.png"), "T", false, None);
|
||||||
|
assert!(out.contains("<img src=\"https://elsewhere.test/i.png\""), "{out}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue