feat: declare a Mews page's feed, keep its prolog, and lift link-only list items
This commit is contained in:
parent
3e659df9dd
commit
c08b03db7b
9 changed files with 223 additions and 116 deletions
|
|
@ -42,7 +42,9 @@ 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. The stylesheet itself is served from an embedded copy, at whatever path the link's `href` resolves to, so there is nothing to place in the content root. Sites sharing one root must agree on this setting, since they share one render cache.
|
A site can set `mews_profile = true` to render its `xhtmlmp` and `html` output as [Mews Profile](https://mews.page/spec/) pages: the XML declaration, 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. The declaration stays in the `text/html` form too, unlike an ordinary page's, because section 3.1 requires it and a validator reads whichever bytes it was served; a browser logs a warning for it and renders the page the same. The stylesheet itself is served from an embedded copy, at whatever path the link's `href` resolves to, so there is nothing to place in the content root. Sites sharing one root must agree on this setting, since they share one render cache.
|
||||||
|
|
||||||
|
A site may also set `feed` to the URL of its Atom feed, which is then declared in the head of every `xhtmlmp` and `html` page so a client can subscribe without reading the body (Mews Profile 6.2). Give it absolute: a capsule rarely carries the feed its web site publishes, so a root-relative path would point at a document this server has no file for. Sites sharing one root must agree on it too.
|
||||||
|
|
||||||
### Formats and protocols
|
### Formats and protocols
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,13 @@ pub struct SiteSpec {
|
||||||
/// profile's permitted-element list cannot otherwise be guaranteed.
|
/// profile's permitted-element list cannot otherwise be guaranteed.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mews_profile: bool,
|
pub mews_profile: bool,
|
||||||
|
/// The site's Atom feed, declared in the head of its `xhtmlmp`/`html`
|
||||||
|
/// pages (mews.page/spec 6.2) so a client can subscribe without reading
|
||||||
|
/// the body. Absolute: a capsule rarely carries the feed its web site
|
||||||
|
/// publishes, so a root-relative path would point at a document this
|
||||||
|
/// server has no file for.
|
||||||
|
#[serde(default)]
|
||||||
|
pub feed: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -177,21 +184,32 @@ impl ServerConfig {
|
||||||
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);
|
let shared_roots = group_shared_roots(&roots);
|
||||||
self.validate_shared_mews_profile(&shared_roots)?;
|
self.validate_shared_markup(&shared_roots)?;
|
||||||
|
|
||||||
Ok(Checked { hosts, roots: roots.clone(), shared_roots, formats })
|
Ok(Checked { hosts, roots: roots.clone(), shared_roots, formats })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sites sharing a root share one [`crate::site::Site`] and so one render
|
/// Sites sharing a root share one [`crate::site::Site`] and so one render
|
||||||
/// cache; a page rendered once there cannot differ by which name served the
|
/// cache; a page rendered once there cannot differ by which name served the
|
||||||
/// request, so `mews_profile` must agree within a group.
|
/// request, so every setting that changes its markup must agree within a
|
||||||
fn validate_shared_mews_profile(&self, shared_roots: &[Vec<String>]) -> Result<(), Error> {
|
/// group.
|
||||||
|
fn validate_shared_markup(&self, shared_roots: &[Vec<String>]) -> Result<(), Error> {
|
||||||
|
self.validate_shared(shared_roots, "mews_profile", |spec| spec.mews_profile)?;
|
||||||
|
self.validate_shared(shared_roots, "feed", |spec| spec.feed.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_shared<T: PartialEq>(
|
||||||
|
&self,
|
||||||
|
shared_roots: &[Vec<String>],
|
||||||
|
key: &str,
|
||||||
|
of: impl Fn(&SiteSpec) -> T,
|
||||||
|
) -> Result<(), Error> {
|
||||||
for group in shared_roots {
|
for group in shared_roots {
|
||||||
let mut settings = group.iter().map(|name| self.site[name].mews_profile);
|
let mut settings = group.iter().map(|name| of(&self.site[name]));
|
||||||
let first = settings.next().unwrap_or(false);
|
let Some(first) = settings.next() else { continue };
|
||||||
if settings.any(|value| value != first) {
|
if settings.any(|value| value != first) {
|
||||||
return Err(Error::config(format!(
|
return Err(Error::config(format!(
|
||||||
"sites {} share one root, so mews_profile must agree between them",
|
"sites {} share one root, so {key} must agree between them",
|
||||||
group.join(", ")
|
group.join(", ")
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,18 @@ pub trait Renderer: Send + Sync {
|
||||||
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error>;
|
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a Mews Profile page needs beyond the document itself (mews.page/spec).
|
||||||
|
/// Present when the site has opted in, absent otherwise, so one `Option` says
|
||||||
|
/// both whether the profile applies and what it applies with.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Mews<'a> {
|
||||||
|
/// The site's own hostnames, for judging an image `src` as same-site (4.2).
|
||||||
|
pub hosts: &'a [String],
|
||||||
|
/// The site's Atom feed, declared in the head (6.2). Absolute, since a
|
||||||
|
/// capsule need not host the feed its web site publishes.
|
||||||
|
pub feed: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RenderCtx<'a> {
|
pub struct RenderCtx<'a> {
|
||||||
/// Canonical root-relative URL of this document: `/`, `/about`, `/dir/`. A
|
/// Canonical root-relative URL of this document: `/`, `/about`, `/dir/`. A
|
||||||
/// format that addresses sub-documents builds their URLs from this.
|
/// format that addresses sub-documents builds their URLs from this.
|
||||||
|
|
@ -48,11 +60,10 @@ 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
|
/// Mews Profile settings, present when the site has opted in. Threaded
|
||||||
/// compliance (mews.page/spec) and absent otherwise. Threaded through so a
|
/// through so a renderer that cares can switch its markup without reaching
|
||||||
/// renderer that cares can switch its markup and judge an image `src` as
|
/// into site configuration itself.
|
||||||
/// same-site, without reaching into site configuration itself.
|
pub mews: Option<Mews<'a>>,
|
||||||
pub mews_hosts: Option<&'a [String]>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Rendered {
|
pub struct Rendered {
|
||||||
|
|
@ -117,7 +128,7 @@ impl Registry {
|
||||||
url: &str,
|
url: &str,
|
||||||
settings: &PageSettings,
|
settings: &PageSettings,
|
||||||
fallback_title: &str,
|
fallback_title: &str,
|
||||||
mews_hosts: Option<&[String]>,
|
mews: Option<Mews<'_>>,
|
||||||
) -> Result<Page, Error> {
|
) -> Result<Page, Error> {
|
||||||
let title = settings
|
let title = settings
|
||||||
.title
|
.title
|
||||||
|
|
@ -142,7 +153,7 @@ impl Registry {
|
||||||
title: &page.title,
|
title: &page.title,
|
||||||
settings,
|
settings,
|
||||||
width: renderer.default_width(),
|
width: renderer.default_width(),
|
||||||
mews_hosts,
|
mews,
|
||||||
};
|
};
|
||||||
// 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
|
||||||
|
|
@ -222,7 +233,7 @@ mod tests {
|
||||||
// after gates — is visible in the body.
|
// after gates — is visible in the body.
|
||||||
format!(
|
format!(
|
||||||
"{} {} {:?} {} {:?} {:?}",
|
"{} {} {:?} {} {:?} {:?}",
|
||||||
self.id, ctx.url, ctx.width, ctx.title, doc.blocks, ctx.mews_hosts
|
self.id, ctx.url, ctx.width, ctx.title, doc.blocks, ctx.mews
|
||||||
)
|
)
|
||||||
.into_bytes(),
|
.into_bytes(),
|
||||||
))
|
))
|
||||||
|
|
@ -269,13 +280,16 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mews_hosts_reaches_the_renderer_when_the_site_opted_in() {
|
fn mews_settings_reach_the_renderer_when_the_site_opted_in() {
|
||||||
let formats = vec!["one".to_string()];
|
let formats = vec!["one".to_string()];
|
||||||
let hosts = vec!["example.test".to_string()];
|
let hosts = vec!["example.test".to_string()];
|
||||||
|
let mews = Mews { hosts: &hosts, feed: Some("https://example.test/feed.xml") };
|
||||||
let page = registry()
|
let page = registry()
|
||||||
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", Some(&hosts))
|
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", Some(mews))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("example.test"));
|
let body = String::from_utf8_lossy(page.body("one").unwrap()).to_string();
|
||||||
|
assert!(body.contains("example.test"), "{body}");
|
||||||
|
assert!(body.contains("feed.xml"), "{body}");
|
||||||
|
|
||||||
let unset = registry()
|
let unset = registry()
|
||||||
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None)
|
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ use crate::error::Error;
|
||||||
use crate::mime;
|
use crate::mime;
|
||||||
use crate::parse;
|
use crate::parse;
|
||||||
use crate::path::{clean_path, url_for};
|
use crate::path::{clean_path, url_for};
|
||||||
use crate::render::{Page, Registry, title_from_stem};
|
use crate::render::{Mews, Page, Registry, title_from_stem};
|
||||||
|
|
||||||
/// How deep a chain of server-side redirects `resolve_flat` will follow. Every
|
/// How deep a chain of server-side redirects `resolve_flat` will follow. Every
|
||||||
/// redirect currently points at a directly resolvable document, so one hop is
|
/// redirect currently points at a directly resolvable document, so one hop is
|
||||||
|
|
@ -82,6 +82,10 @@ pub struct Site {
|
||||||
/// differing host lists keeps only that one's, which is an accepted
|
/// differing host lists keeps only that one's, which is an accepted
|
||||||
/// simplification rather than a union of them all.
|
/// simplification rather than a union of them all.
|
||||||
hosts: Vec<String>,
|
hosts: Vec<String>,
|
||||||
|
/// This site's Atom feed (`SiteSpec::feed`), declared in the head of a
|
||||||
|
/// Mews page. Validation requires every site sharing this root to agree,
|
||||||
|
/// since the declaration is part of the cached markup.
|
||||||
|
feed: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Site {
|
impl Site {
|
||||||
|
|
@ -93,6 +97,7 @@ impl Site {
|
||||||
formats: Vec<String>,
|
formats: Vec<String>,
|
||||||
mews_profile: bool,
|
mews_profile: bool,
|
||||||
hosts: Vec<String>,
|
hosts: Vec<String>,
|
||||||
|
feed: Option<String>,
|
||||||
) -> Result<Self, Error> {
|
) -> 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 })?;
|
||||||
|
|
@ -108,6 +113,7 @@ impl Site {
|
||||||
formats,
|
formats,
|
||||||
mews_profile,
|
mews_profile,
|
||||||
hosts,
|
hosts,
|
||||||
|
feed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,15 +262,10 @@ 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();
|
||||||
let mews_hosts = self.mews_profile.then_some(self.hosts.as_slice());
|
let mews = self
|
||||||
self.registry.page(
|
.mews_profile
|
||||||
&self.formats,
|
.then_some(Mews { hosts: self.hosts.as_slice(), feed: self.feed.as_deref() });
|
||||||
&doc,
|
self.registry.page(&self.formats, &doc, url, &settings, &title_from_stem(stem), mews)
|
||||||
url,
|
|
||||||
&settings,
|
|
||||||
&title_from_stem(stem),
|
|
||||||
mews_hosts,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,7 +301,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={}\nmews_hosts={:?}\n", ctx.title, ctx.mews_hosts);
|
let mut body = format!("title={}\nmews={:?}\n", ctx.title, ctx.mews);
|
||||||
for block in &doc.blocks {
|
for block in &doc.blocks {
|
||||||
body.push_str(&format!("{block:?}\n"));
|
body.push_str(&format!("{block:?}\n"));
|
||||||
}
|
}
|
||||||
|
|
@ -315,7 +316,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open(root: &Path) -> Site {
|
fn open(root: &Path) -> Site {
|
||||||
Site::new(root, registry(), vec!["stub".to_string()], false, Vec::new()).unwrap()
|
Site::new(root, registry(), vec!["stub".to_string()], false, Vec::new(), None).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
|
||||||
|
|
@ -502,7 +503,8 @@ mod tests {
|
||||||
fn the_mews_stylesheet_is_served_embedded_when_the_profile_is_on() {
|
fn the_mews_stylesheet_is_served_embedded_when_the_profile_is_on() {
|
||||||
let (dir, _site) = fixture();
|
let (dir, _site) = fixture();
|
||||||
let mews_site =
|
let mews_site =
|
||||||
Site::new(dir.path(), registry(), vec!["stub".to_string()], true, Vec::new()).unwrap();
|
Site::new(dir.path(), registry(), vec!["stub".to_string()], true, Vec::new(), None)
|
||||||
|
.unwrap();
|
||||||
for path in ["/mews-0.1.css", "/dir/mews-0.1.css"] {
|
for path in ["/mews-0.1.css", "/dir/mews-0.1.css"] {
|
||||||
match mews_site.resolve(path).unwrap() {
|
match mews_site.resolve(path).unwrap() {
|
||||||
Resolution::Found(Resource::Embedded { bytes, media_type }) => {
|
Resolution::Found(Resource::Embedded { bytes, media_type }) => {
|
||||||
|
|
@ -628,10 +630,10 @@ mod tests {
|
||||||
let (dir, _site) = fixture();
|
let (dir, _site) = fixture();
|
||||||
let hosts = vec!["example.test".to_string()];
|
let hosts = vec!["example.test".to_string()];
|
||||||
let mews_site =
|
let mews_site =
|
||||||
Site::new(dir.path(), registry(), vec!["stub".to_string()], true, hosts).unwrap();
|
Site::new(dir.path(), registry(), vec!["stub".to_string()], true, hosts, None).unwrap();
|
||||||
let (_, page) = document(&mews_site, "/about");
|
let (_, page) = document(&mews_site, "/about");
|
||||||
let body = String::from_utf8(page.body("stub").unwrap().to_vec()).unwrap();
|
let body = String::from_utf8(page.body("stub").unwrap().to_vec()).unwrap();
|
||||||
assert!(body.contains("mews_hosts=Some([\"example.test\"])"), "{body}");
|
assert!(body.contains("hosts: [\"example.test\"]"), "{body}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -639,7 +641,7 @@ mod tests {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let absent = dir.path().join("absent");
|
let absent = dir.path().join("absent");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
Site::new(&absent, registry(), vec![], false, Vec::new()),
|
Site::new(&absent, registry(), vec![], false, Vec::new(), None),
|
||||||
Err(Error::Io { .. })
|
Err(Error::Io { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -650,7 +652,7 @@ mod tests {
|
||||||
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!(
|
assert!(matches!(
|
||||||
Site::new(&file, registry(), vec![], false, Vec::new()),
|
Site::new(&file, registry(), vec![], false, Vec::new(), None),
|
||||||
Err(Error::Config { .. })
|
Err(Error::Config { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -712,9 +714,15 @@ 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 =
|
let site = Site::new(
|
||||||
Site::new(root, Arc::new(registry), vec!["paginating".to_string()], false, Vec::new())
|
root,
|
||||||
.unwrap();
|
Arc::new(registry),
|
||||||
|
vec!["paginating".to_string()],
|
||||||
|
false,
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
(dir, site)
|
(dir, site)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,14 @@ impl SiteSet {
|
||||||
|
|
||||||
for (name, spec) in &config.site {
|
for (name, spec) in &config.site {
|
||||||
let hosts = spec.hosts.iter().filter_map(|host| normalize_host(host)).collect();
|
let hosts = spec.hosts.iter().filter_map(|host| normalize_host(host)).collect();
|
||||||
let site =
|
let site = Site::new(
|
||||||
Site::new(&spec.root, registry.clone(), formats.clone(), spec.mews_profile, hosts)?;
|
&spec.root,
|
||||||
|
registry.clone(),
|
||||||
|
formats.clone(),
|
||||||
|
spec.mews_profile,
|
||||||
|
hosts,
|
||||||
|
spec.feed.clone(),
|
||||||
|
)?;
|
||||||
// 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()) {
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ impl Writer {
|
||||||
// number in the text to keep the sequence readable.
|
// number in the text to keep the sequence readable.
|
||||||
if *ordered {
|
if *ordered {
|
||||||
self.line(&format!("* {}. {text}", start + index as u64));
|
self.line(&format!("* {}. {text}", start + index as u64));
|
||||||
} else {
|
} else if !is_only_links_item(item) {
|
||||||
self.line(&format!("* {text}"));
|
self.line(&format!("* {text}"));
|
||||||
}
|
}
|
||||||
for block in item {
|
for block in item {
|
||||||
|
|
@ -201,6 +201,16 @@ impl Writer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a list item holds links and nothing else worth printing, in which
|
||||||
|
/// case the `=>` lines are the whole item and a marker line above them would
|
||||||
|
/// list every entry twice -- the same reading [`is_only_links`] gives a
|
||||||
|
/// paragraph. Markup other than one paragraph keeps its marker, since the
|
||||||
|
/// shape is then doing something a link catalogue alone would not carry; so
|
||||||
|
/// does an ordered item, whose number would go with it.
|
||||||
|
fn is_only_links_item(item: &[Block]) -> bool {
|
||||||
|
matches!(item, [Block::Paragraph(inline)] if is_only_links(inline))
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a paragraph holds links and nothing else worth printing.
|
/// Whether a paragraph holds links and nothing else worth printing.
|
||||||
fn is_only_links(inline: &[Inline]) -> bool {
|
fn is_only_links(inline: &[Inline]) -> bool {
|
||||||
let mut saw_link = false;
|
let mut saw_link = false;
|
||||||
|
|
@ -295,8 +305,7 @@ 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 =
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None };
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
@ -371,6 +380,19 @@ mod tests {
|
||||||
assert_eq!(render("- see [x](/x)\n"), "* see x\n=> /x x\n");
|
assert_eq!(render("- see [x](/x)\n"), "* see x\n=> /x x\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_list_of_bare_links_is_a_run_of_link_lines() {
|
||||||
|
// The shape a generator wants for a dated index: a real list, so the
|
||||||
|
// markup formats can give it <ul><li>, without gemtext printing both
|
||||||
|
// the marker line and the link line for every entry.
|
||||||
|
assert_eq!(render("- [a](/a)\n- [b](/b)\n"), "=> /a a\n=> /b b\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ordered_list_of_bare_links_keeps_its_numbers() {
|
||||||
|
assert_eq!(render("1. [a](/a)\n"), "* 1. a\n=> /a a\n");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn code_blocks_become_fences_carrying_their_info_string() {
|
fn code_blocks_become_fences_carrying_their_info_string() {
|
||||||
assert_eq!(render("```rust\nlet x = 1;\n```\n"), "```rust\nlet x = 1;\n```\n");
|
assert_eq!(render("```rust\nlet x = 1;\n```\n"), "```rust\nlet x = 1;\n```\n");
|
||||||
|
|
@ -399,8 +421,7 @@ 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 =
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None };
|
||||||
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,8 +58,7 @@ 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 =
|
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews: None };
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,8 +213,7 @@ 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 =
|
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews: None };
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,13 +267,8 @@ 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 {
|
let ctx =
|
||||||
url: "/x",
|
RenderCtx { url: "/x", title: "T", settings: &settings, width: Some(40), mews: None };
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,8 +300,7 @@ 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 =
|
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews: None };
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//! The WAP family: XHTML Mobile Profile, and the `text/html` body derived from it.
|
//! 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
|
//! 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
|
//! bytes, less the XML declaration on a page that is not a Mews page, and because
|
||||||
//! is shared with nothing outside this family. Splitting them would duplicate the
|
//! WML's deck configuration is shared with nothing outside this family. Splitting
|
||||||
//! escaper and the markup builder.
|
//! them would duplicate the escaper and the markup builder.
|
||||||
|
|
||||||
#[cfg(feature = "wml")]
|
#[cfg(feature = "wml")]
|
||||||
mod deck;
|
mod deck;
|
||||||
|
|
@ -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, ctx.mews_hosts).into_bytes()))
|
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true, ctx.mews).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, ctx.mews_hosts).into_bytes()))
|
Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false, ctx.mews).into_bytes()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ mod tests {
|
||||||
title: "The Title",
|
title: "The Title",
|
||||||
settings: &settings,
|
settings: &settings,
|
||||||
width: None,
|
width: None,
|
||||||
mews_hosts: None,
|
mews: None,
|
||||||
};
|
};
|
||||||
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
|
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
}
|
}
|
||||||
|
|
@ -184,8 +184,7 @@ mod tests {
|
||||||
first_h1: None,
|
first_h1: None,
|
||||||
};
|
};
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx =
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None };
|
||||||
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}");
|
||||||
}
|
}
|
||||||
|
|
@ -202,8 +201,7 @@ mod tests {
|
||||||
first_h1: None,
|
first_h1: None,
|
||||||
};
|
};
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx =
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None };
|
||||||
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}");
|
||||||
}
|
}
|
||||||
|
|
@ -226,8 +224,7 @@ 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 =
|
let ctx = RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews: None };
|
||||||
RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews_hosts: None };
|
|
||||||
Wml.render(&doc, &ctx).unwrap()
|
Wml.render(&doc, &ctx).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -488,7 +485,7 @@ mod oracle_dump {
|
||||||
title: "Trail",
|
title: "Trail",
|
||||||
settings: &settings,
|
settings: &settings,
|
||||||
width: None,
|
width: None,
|
||||||
mews_hosts: None,
|
mews: 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();
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,18 @@
|
||||||
//! One document serves both: a WAP 2.0 client needs well-formed XML with the
|
//! 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
|
//! 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
|
//! 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
|
//! logs a warning for it. So an ordinary page's HTML form is the same markup
|
||||||
//! removed.
|
//! with that one line removed. A Mews page keeps it in both forms: see
|
||||||
|
//! [`document`].
|
||||||
|
|
||||||
use itsybitsy_core::config::normalize_host;
|
use itsybitsy_core::config::normalize_host;
|
||||||
use itsybitsy_core::ir::{Block, Doc, Inline};
|
use itsybitsy_core::ir::{Block, Doc, Inline};
|
||||||
|
use itsybitsy_core::render::Mews;
|
||||||
|
|
||||||
use crate::escape;
|
use crate::escape;
|
||||||
|
|
||||||
/// The XML declaration, which only the XHTML-MP form carries.
|
/// The XML declaration, which every form of a Mews page carries and only the
|
||||||
|
/// XHTML-MP form of an ordinary one does.
|
||||||
const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
|
const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
|
||||||
|
|
||||||
/// The ordinary doctype, used whenever a site has not opted into Mews Profile
|
/// The ordinary doctype, used whenever a site has not opted into Mews Profile
|
||||||
|
|
@ -32,44 +35,55 @@ const MEWS_HEAD_EXTRA: &str = "<meta name=\"mews-profile\" content=\"0.1\" />\n\
|
||||||
<link rel=\"stylesheet\" type=\"text/css\" href=\"mews-0.1.css\" />\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.
|
||||||
/// `mews_hosts` is `Some` with the site's own hostnames when it has opted into
|
/// `mews` is `Some` when the site has opted into Mews Profile compliance, and
|
||||||
/// Mews Profile compliance, and `None` otherwise.
|
/// `None` otherwise.
|
||||||
pub fn document(doc: &Doc, title: &str, prolog: bool, mews_hosts: Option<&[String]>) -> String {
|
pub fn document(doc: &Doc, title: &str, prolog: bool, mews: Option<Mews<'_>>) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
if prolog {
|
// A Mews page carries the declaration whatever its media type: 3.1 requires
|
||||||
|
// it, and a validator or directory checker judges the bytes it was handed,
|
||||||
|
// which for anything fetching the way a browser does is the `text/html`
|
||||||
|
// form. The cost is the console warning an HTML parser logs for a comment
|
||||||
|
// it did not expect; it reads the line as a comment before the doctype, so
|
||||||
|
// nothing about how the page renders changes.
|
||||||
|
if prolog || mews.is_some() {
|
||||||
out.push_str(PROLOG);
|
out.push_str(PROLOG);
|
||||||
}
|
}
|
||||||
out.push_str(if mews_hosts.is_some() { DOCTYPE_1_2 } else { DOCTYPE_1_0 });
|
out.push_str(if mews.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", escape::text(title)));
|
||||||
out.push_str(&format!(
|
if mews.is_some() {
|
||||||
"<head>\n<title>{}</title>\n{MEWS_HEAD_EXTRA}</head>\n",
|
out.push_str(MEWS_HEAD_EXTRA);
|
||||||
escape::text(title)
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
out.push_str(&format!("<head><title>{}</title></head>\n", escape::text(title)));
|
|
||||||
}
|
}
|
||||||
|
// The feed is declared here rather than linked from a page body, which is
|
||||||
|
// the only place a client reading the head can find it (6.2). The label is
|
||||||
|
// the spec example's, since the attribute names the feed rather than
|
||||||
|
// whichever page happens to carry the declaration.
|
||||||
|
if let Some(feed) = mews.and_then(|mews| mews.feed) {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<link rel=\"alternate\" type=\"application/atom+xml\" href=\"{}\" title=\"Posts\" />\n",
|
||||||
|
escape::attr(feed)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str("</head>\n");
|
||||||
out.push_str("<body>\n");
|
out.push_str("<body>\n");
|
||||||
blocks(&doc.blocks, mews_hosts, &mut out);
|
blocks(&doc.blocks, mews, &mut out);
|
||||||
out.push_str("</body>\n</html>\n");
|
out.push_str("</body>\n</html>\n");
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blocks(blocks: &[Block], mews_hosts: Option<&[String]>, out: &mut String) {
|
fn blocks(blocks: &[Block], mews: Option<Mews<'_>>, out: &mut String) {
|
||||||
for block in blocks {
|
for block in blocks {
|
||||||
self_block(block, mews_hosts, out);
|
self_block(block, mews, out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
|
fn self_block(block: &Block, mews: Option<Mews<'_>>, 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, mews_hosts)));
|
out.push_str(&format!("<h{level}>{}</h{level}>\n", inlines(inline, mews)));
|
||||||
}
|
|
||||||
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, mews))),
|
||||||
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")));
|
||||||
|
|
@ -84,7 +98,7 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
|
||||||
}
|
}
|
||||||
Block::BlockQuote(inner) => {
|
Block::BlockQuote(inner) => {
|
||||||
out.push_str("<blockquote>\n");
|
out.push_str("<blockquote>\n");
|
||||||
blocks(inner, mews_hosts, out);
|
blocks(inner, mews, out);
|
||||||
out.push_str("</blockquote>\n");
|
out.push_str("</blockquote>\n");
|
||||||
}
|
}
|
||||||
Block::List { ordered, start, items } => {
|
Block::List { ordered, start, items } => {
|
||||||
|
|
@ -100,10 +114,10 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, 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, mews_hosts)),
|
[Block::Paragraph(inline)] => out.push_str(&inlines(inline, mews)),
|
||||||
blocks_in_item => {
|
blocks_in_item => {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
blocks(blocks_in_item, mews_hosts, out);
|
blocks(blocks_in_item, mews, out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push_str("</li>\n");
|
out.push_str("</li>\n");
|
||||||
|
|
@ -115,14 +129,14 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, 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, mews_hosts)));
|
out.push_str(&format!("<th>{}</th>", inlines(cell, mews)));
|
||||||
}
|
}
|
||||||
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, mews_hosts)));
|
out.push_str(&format!("<td>{}</td>", inlines(cell, mews)));
|
||||||
}
|
}
|
||||||
out.push_str("</tr>\n");
|
out.push_str("</tr>\n");
|
||||||
}
|
}
|
||||||
|
|
@ -134,36 +148,34 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
|
||||||
// cannot make that guarantee — the markup might use an element or
|
// cannot make that guarantee — the markup might use an element or
|
||||||
// attribute the profile excludes — so it is dropped instead.
|
// attribute the profile excludes — so it is dropped instead.
|
||||||
Block::Html(html) => {
|
Block::Html(html) => {
|
||||||
if mews_hosts.is_none() {
|
if mews.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, mews_hosts, out),
|
Block::Aligned { block, .. } => self_block(block, mews, 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, mews_hosts, out),
|
Block::Gated { block, .. } => self_block(block, mews, out),
|
||||||
Block::CardBreak { .. } => {}
|
Block::CardBreak { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String {
|
fn inlines(inline: &[Inline], mews: Option<Mews<'_>>) -> 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) => {
|
Inline::Emph(inner) => out.push_str(&format!("<em>{}</em>", inlines(inner, mews))),
|
||||||
out.push_str(&format!("<em>{}</em>", inlines(inner, mews_hosts)))
|
|
||||||
}
|
|
||||||
Inline::Strong(inner) => {
|
Inline::Strong(inner) => {
|
||||||
out.push_str(&format!("<strong>{}</strong>", inlines(inner, mews_hosts)))
|
out.push_str(&format!("<strong>{}</strong>", inlines(inner, mews)))
|
||||||
}
|
}
|
||||||
// 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, mews_hosts)),
|
Inline::Strike(inner) => out.push_str(&inlines(inner, mews)),
|
||||||
Inline::Link { href, title, label } => {
|
Inline::Link { href, title, label } => {
|
||||||
let title = title
|
let title = title
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|
@ -172,14 +184,14 @@ fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> 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, mews_hosts)
|
inlines(label, mews)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
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 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
|
// a `data:` URI (SPEC.md 4.2); one that does not is dropped to
|
||||||
// its alt text rather than rendered non-conformant.
|
// its alt text rather than rendered non-conformant.
|
||||||
if mews_hosts.is_some_and(|hosts| !same_site_image(src, hosts)) {
|
if mews.is_some_and(|mews| !same_site_image(src, mews.hosts)) {
|
||||||
out.push_str(&escape::text(&Doc::plain_text(alt)));
|
out.push_str(&escape::text(&Doc::plain_text(alt)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +209,7 @@ fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String {
|
||||||
Inline::HardBreak => out.push_str("<br/>\n"),
|
Inline::HardBreak => out.push_str("<br/>\n"),
|
||||||
// See the Block::Html arm above: unverifiable in a Mews page.
|
// See the Block::Html arm above: unverifiable in a Mews page.
|
||||||
Inline::Html(html) => {
|
Inline::Html(html) => {
|
||||||
if mews_hosts.is_none() {
|
if mews.is_none() {
|
||||||
out.push_str(html);
|
out.push_str(html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -242,13 +254,50 @@ mod tests {
|
||||||
Doc { blocks: Vec::new(), first_h1: None }
|
Doc { blocks: Vec::new(), first_h1: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A site that has opted into the profile and declares no feed.
|
||||||
|
fn mews(hosts: &[String]) -> Mews<'_> {
|
||||||
|
Mews { hosts, feed: None }
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_prolog_is_present_only_when_asked_for() {
|
fn an_ordinary_page_carries_the_prolog_only_when_asked_for() {
|
||||||
let doc = empty();
|
let doc = empty();
|
||||||
assert!(document(&doc, "T", true, None).starts_with("<?xml"));
|
assert!(document(&doc, "T", true, None).starts_with("<?xml"));
|
||||||
assert!(document(&doc, "T", false, None).starts_with("<!DOCTYPE html"));
|
assert!(document(&doc, "T", false, None).starts_with("<!DOCTYPE html"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mews_page_carries_the_prolog_in_both_forms() {
|
||||||
|
// The text/html form is what a validator fetching like a browser is
|
||||||
|
// handed, and 3.1 is a MUST on the bytes it reads.
|
||||||
|
let hosts = vec!["example.test".to_string()];
|
||||||
|
for xml in [true, false] {
|
||||||
|
let out = document(&empty(), "T", xml, Some(mews(&hosts)));
|
||||||
|
assert!(out.starts_with("<?xml version=\"1.0\" encoding=\"utf-8\"?>"), "{out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_feed_is_declared_in_the_head_when_the_site_names_one() {
|
||||||
|
let hosts = vec!["example.test".to_string()];
|
||||||
|
let feed = Mews { hosts: &hosts, feed: Some("https://example.test/feed.xml") };
|
||||||
|
let out = document(&empty(), "T", false, Some(feed));
|
||||||
|
assert!(
|
||||||
|
out.contains(
|
||||||
|
"<link rel=\"alternate\" type=\"application/atom+xml\" \
|
||||||
|
href=\"https://example.test/feed.xml\" title=\"Posts\" />"
|
||||||
|
),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.split("</head>").next().is_some_and(|head| head.contains("alternate")),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let none = document(&empty(), "T", false, Some(mews(&hosts)));
|
||||||
|
assert!(!none.contains("alternate"), "{none}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_plain_page_keeps_the_1_0_doctype_and_no_mews_head() {
|
fn a_plain_page_keeps_the_1_0_doctype_and_no_mews_head() {
|
||||||
let out = document(&empty(), "T", false, None);
|
let out = document(&empty(), "T", false, None);
|
||||||
|
|
@ -259,7 +308,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn a_mews_page_carries_the_1_2_doctype_and_the_conformance_marker() {
|
fn a_mews_page_carries_the_1_2_doctype_and_the_conformance_marker() {
|
||||||
let hosts = vec!["example.test".to_string()];
|
let hosts = vec!["example.test".to_string()];
|
||||||
let out = document(&empty(), "T", false, Some(&hosts));
|
let out = document(&empty(), "T", false, Some(mews(&hosts)));
|
||||||
assert!(out.contains("XHTML Mobile 1.2"), "{out}");
|
assert!(out.contains("XHTML Mobile 1.2"), "{out}");
|
||||||
assert!(out.contains("<meta name=\"mews-profile\" content=\"0.1\" />"), "{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("<meta name=\"viewport\" content=\"width=device-width\" />"), "{out}");
|
||||||
|
|
@ -273,7 +322,7 @@ mod tests {
|
||||||
fn a_mews_page_drops_raw_html_it_cannot_vouch_for() {
|
fn a_mews_page_drops_raw_html_it_cannot_vouch_for() {
|
||||||
let hosts = vec!["example.test".to_string()];
|
let hosts = vec!["example.test".to_string()];
|
||||||
let doc = Doc { blocks: vec![Block::Html("<div>x</div>".into())], first_h1: None };
|
let doc = Doc { blocks: vec![Block::Html("<div>x</div>".into())], first_h1: None };
|
||||||
let out = document(&doc, "T", false, Some(&hosts));
|
let out = document(&doc, "T", false, Some(mews(&hosts)));
|
||||||
assert!(!out.contains("<div>"), "{out}");
|
assert!(!out.contains("<div>"), "{out}");
|
||||||
|
|
||||||
let plain = document(&doc, "T", false, None);
|
let plain = document(&doc, "T", false, None);
|
||||||
|
|
@ -295,7 +344,7 @@ mod tests {
|
||||||
fn a_mews_page_keeps_a_relative_or_same_site_image() {
|
fn a_mews_page_keeps_a_relative_or_same_site_image() {
|
||||||
let hosts = vec!["example.test".to_string()];
|
let hosts = vec!["example.test".to_string()];
|
||||||
for src in ["/i.png", "i.png", "https://example.test/i.png", "//img.example.test/i.png"] {
|
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));
|
let out = document(&image_doc(src), "T", false, Some(mews(&hosts)));
|
||||||
assert!(out.contains(&format!("src=\"{src}\"")), "{src}: {out}");
|
assert!(out.contains(&format!("src=\"{src}\"")), "{src}: {out}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -304,7 +353,7 @@ mod tests {
|
||||||
fn a_mews_page_drops_an_off_site_or_data_image_to_its_alt_text() {
|
fn a_mews_page_drops_an_off_site_or_data_image_to_its_alt_text() {
|
||||||
let hosts = vec!["example.test".to_string()];
|
let hosts = vec!["example.test".to_string()];
|
||||||
for src in ["https://elsewhere.test/i.png", "data:image/png;base64,AAAA"] {
|
for src in ["https://elsewhere.test/i.png", "data:image/png;base64,AAAA"] {
|
||||||
let out = document(&image_doc(src), "T", false, Some(&hosts));
|
let out = document(&image_doc(src), "T", false, Some(mews(&hosts)));
|
||||||
assert!(!out.contains("<img"), "{src}: {out}");
|
assert!(!out.contains("<img"), "{src}: {out}");
|
||||||
assert!(out.contains("a photo"), "{src}: {out}");
|
assert!(out.contains("a photo"), "{src}: {out}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue