Serve the Mews Profile stylesheet from an embedded copy
mews_profile pins both the stylesheet's name and its content, so a site never actually chooses either; leaving the file itself to be placed in every content root by hand just gave operators a way to get it stale or missing. The byte-for-byte copy from mews.page/spec 5.1 now ships in the binary and resolves `mews-0.1.css` at any depth, matching how a page's relative link to it resolves, across every protocol that can hit it.
This commit is contained in:
parent
c9bde15f5c
commit
3e659df9dd
8 changed files with 170 additions and 1 deletions
|
|
@ -42,7 +42,7 @@ 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.
|
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.
|
||||||
|
|
||||||
### Formats and protocols
|
### Formats and protocols
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
std::io::copy(&mut file, &mut stream)?;
|
std::io::copy(&mut file, &mut stream)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Ok(Resolution::Found(Resource::Embedded { bytes, media_type })) => {
|
||||||
|
body_response(&mut stream, media_type, bytes)
|
||||||
|
}
|
||||||
Ok(Resolution::Redirect(location)) => header(&mut stream, 31, &location),
|
Ok(Resolution::Redirect(location)) => header(&mut stream, 31, &location),
|
||||||
Ok(Resolution::NotFound) => header(&mut stream, 51, "Not found"),
|
Ok(Resolution::NotFound) => header(&mut stream, 51, "Not found"),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
std::io::copy(&mut file, &mut stream)?;
|
std::io::copy(&mut file, &mut stream)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Ok(Resolution::Found(Resource::Embedded { bytes, .. })) => {
|
||||||
|
stream.write_all(bytes)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
// resolve_flat follows redirects and sub-documents to real content.
|
// resolve_flat follows redirects and sub-documents to real content.
|
||||||
Ok(_) => text(&mut stream, b"Not found\n"),
|
Ok(_) => text(&mut stream, b"Not found\n"),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,9 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Ok(Resolution::Found(Resource::Embedded { bytes, media_type })) => {
|
||||||
|
respond(&mut stream, 200, "OK", media_type, bytes, &[], head_only)
|
||||||
|
}
|
||||||
Ok(Resolution::Redirect(location)) => respond(
|
Ok(Resolution::Redirect(location)) => respond(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
301,
|
301,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
let mut file = std::fs::File::open(&path)?;
|
let mut file = std::fs::File::open(&path)?;
|
||||||
std::io::copy(&mut file, &mut stream)?;
|
std::io::copy(&mut file, &mut stream)?;
|
||||||
}
|
}
|
||||||
|
Ok(Resolution::Found(Resource::Embedded { bytes, .. })) => {
|
||||||
|
stream.write_all(bytes)?;
|
||||||
|
}
|
||||||
// resolve_flat follows redirects and sub-documents to real content, so
|
// resolve_flat follows redirects and sub-documents to real content, so
|
||||||
// neither reaches here; they are matched for completeness.
|
// neither reaches here; they are matched for completeness.
|
||||||
Ok(_) => stream.write_all(b"Not found\n")?,
|
Ok(_) => stream.write_all(b"Not found\n")?,
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
std::io::copy(&mut file, &mut stream)?;
|
std::io::copy(&mut file, &mut stream)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Ok(Resolution::Found(Resource::Embedded { bytes, media_type })) => {
|
||||||
|
status(&mut stream, 2, media_type, bytes)
|
||||||
|
}
|
||||||
Ok(Resolution::Redirect(location)) => status(&mut stream, 3, &location, b""),
|
Ok(Resolution::Redirect(location)) => status(&mut stream, 3, &location, b""),
|
||||||
Ok(Resolution::NotFound) => status(&mut stream, 4, "Not found", b""),
|
Ok(Resolution::NotFound) => status(&mut stream, 4, "Not found", b""),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
|
||||||
106
core/assets/mews-0.1.css
Normal file
106
core/assets/mews-0.1.css
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/* Mews Profile default stylesheet 0.1 */
|
||||||
|
|
||||||
|
/* Base: light theme */
|
||||||
|
body {
|
||||||
|
background-color: #faf8f3;
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-family: "Atkinson Hyperlegible", Verdana, Tahoma, system-ui, sans-serif;
|
||||||
|
font-size: 106%;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1em;
|
||||||
|
max-width: 40em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 1.6em 0 0.5em 0;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.6em; margin-top: 0.5em; }
|
||||||
|
h2 { font-size: 1.3em; }
|
||||||
|
h3 { font-size: 1.1em; }
|
||||||
|
h4, h5, h6 { font-size: 1em; }
|
||||||
|
|
||||||
|
p, ul, ol, dl, blockquote, pre, table, address {
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
}
|
||||||
|
ul, ol { padding-left: 1.5em; }
|
||||||
|
li { margin-bottom: 0.25em; }
|
||||||
|
dt { font-weight: bold; }
|
||||||
|
dd { margin: 0 0 0.5em 1.5em; }
|
||||||
|
|
||||||
|
a { color: #1a4f9c; text-decoration: underline; }
|
||||||
|
a:visited { color: #5b2a86; }
|
||||||
|
a:focus, a:active { outline: 2px solid #1a4f9c; }
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 3px solid #c9c4b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre, code, kbd, samp {
|
||||||
|
font-family: "Source Code Pro", Consolas, Menlo, "DejaVu Sans Mono", monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background-color: #efece4;
|
||||||
|
padding: 0.75em;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #c9c4b8;
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #c9c4b8;
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
caption { font-weight: bold; text-align: left; }
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea { font-size: 1em; font-family: inherit; }
|
||||||
|
|
||||||
|
/* Body variants: light */
|
||||||
|
body.mews-warm { background-color: #fbf4e8; color: #2a2420; }
|
||||||
|
body.mews-warm a { color: #8a3d14; }
|
||||||
|
body.mews-cool { background-color: #f3f6fa; color: #1c2430; }
|
||||||
|
body.mews-cool a { color: #1a4f9c; }
|
||||||
|
body.mews-green { background-color: #f2f6ef; color: #1f2a1f; }
|
||||||
|
body.mews-green a { color: #24502a; }
|
||||||
|
body.mews-mono { background-color: #e8ebe1; color: #1e2219; }
|
||||||
|
body.mews-mono a { color: #1e2219; }
|
||||||
|
|
||||||
|
/* Dark theme: follows the system setting */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background-color: #18191b; color: #e3e1dc; }
|
||||||
|
a { color: #8fb8f5; }
|
||||||
|
a:visited { color: #c9a3f2; }
|
||||||
|
a:focus, a:active { outline-color: #8fb8f5; }
|
||||||
|
blockquote, hr, th, td { border-color: #45464a; }
|
||||||
|
pre { background-color: #222326; }
|
||||||
|
|
||||||
|
body.mews-warm { background-color: #1d1916; color: #e8dfd3; }
|
||||||
|
body.mews-warm a { color: #f0b48a; }
|
||||||
|
body.mews-cool { background-color: #161a20; color: #dde4ee; }
|
||||||
|
body.mews-cool a { color: #8fb8f5; }
|
||||||
|
body.mews-green { background-color: #161b16; color: #dbe6d8; }
|
||||||
|
body.mews-green a { color: #9fd39a; }
|
||||||
|
body.mews-mono { background-color: #1b1d18; color: #c8d0b8; }
|
||||||
|
body.mews-mono a { color: #c8d0b8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
html { color-scheme: light dark; }
|
||||||
|
|
@ -23,6 +23,16 @@ use crate::render::{Page, Registry, title_from_stem};
|
||||||
/// always enough; the cap is there so a future rule cannot loop.
|
/// always enough; the cap is there so a future rule cannot loop.
|
||||||
const MAX_FLAT_HOPS: usize = 5;
|
const MAX_FLAT_HOPS: usize = 5;
|
||||||
|
|
||||||
|
/// Name a Mews page's stylesheet `<link>` carries (`wap::xhtmlmp::MEWS_HEAD_EXTRA`).
|
||||||
|
const MEWS_STYLESHEET_NAME: &str = "mews-0.1.css";
|
||||||
|
|
||||||
|
/// The stylesheet itself, byte for byte as published at mews.page/spec (5.1).
|
||||||
|
/// Served from here rather than from a file an operator must place in every
|
||||||
|
/// site's root: the link's name and content are pinned to the spec version a
|
||||||
|
/// site opts into, not something a site chooses, so there is nothing for a
|
||||||
|
/// copy on disk to add and every way for it to go stale or go missing.
|
||||||
|
const MEWS_STYLESHEET: &[u8] = include_bytes!("../assets/mews-0.1.css");
|
||||||
|
|
||||||
/// What a request path resolved to.
|
/// What a request path resolved to.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Resolution {
|
pub enum Resolution {
|
||||||
|
|
@ -38,6 +48,9 @@ pub enum Resource {
|
||||||
Document { url: String, page: Arc<Page> },
|
Document { url: String, page: Arc<Page> },
|
||||||
/// A file served byte for byte, streamed rather than buffered.
|
/// A file served byte for byte, streamed rather than buffered.
|
||||||
Raw { path: PathBuf, media_type: &'static str },
|
Raw { path: PathBuf, media_type: &'static str },
|
||||||
|
/// A byte slice built into the binary, served like `Raw` but with no file
|
||||||
|
/// to open: currently only the Mews Profile stylesheet.
|
||||||
|
Embedded { bytes: &'static [u8], media_type: &'static str },
|
||||||
/// A sub-document of a page, addressed under the page's own URL.
|
/// A sub-document of a page, addressed under the page's own URL.
|
||||||
///
|
///
|
||||||
/// Only a paginating format produces these, so whether this slug exists
|
/// Only a paginating format produces these, so whether this slug exists
|
||||||
|
|
@ -110,8 +123,20 @@ impl Site {
|
||||||
/// | `/foo` | `foo.md`, else `foo/index.md` |
|
/// | `/foo` | `foo.md`, else `foo/index.md` |
|
||||||
/// | `/foo.md` | redirects to `/foo` |
|
/// | `/foo.md` | redirects to `/foo` |
|
||||||
/// | `/img.png` | the file itself, by media type |
|
/// | `/img.png` | the file itself, by media type |
|
||||||
|
/// | `/mews-0.1.css` | the embedded stylesheet, when `mews_profile` is set |
|
||||||
pub fn resolve(&self, url_path: &str) -> Result<Resolution, Error> {
|
pub fn resolve(&self, url_path: &str) -> Result<Resolution, Error> {
|
||||||
let Some(clean) = clean_path(url_path) else { return Ok(Resolution::NotFound) };
|
let Some(clean) = clean_path(url_path) else { return Ok(Resolution::NotFound) };
|
||||||
|
|
||||||
|
// Checked by name only, so the link resolves the same at any depth: a
|
||||||
|
// page under `/foo/bar` carries the same relative `href` as one at the
|
||||||
|
// root, and both must land here rather than on a per-directory copy.
|
||||||
|
if self.mews_profile && clean.rsplit('/').next() == Some(MEWS_STYLESHEET_NAME) {
|
||||||
|
return Ok(Resolution::Found(Resource::Embedded {
|
||||||
|
bytes: MEWS_STYLESHEET,
|
||||||
|
media_type: mime::media_type(Path::new(MEWS_STYLESHEET_NAME)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let Some(source) = self.file_for(&clean) else { return self.part_for(&clean) };
|
let Some(source) = self.file_for(&clean) else { return self.part_for(&clean) };
|
||||||
|
|
||||||
// `/foo.md` always redirects to its extensionless form, so one
|
// `/foo.md` always redirects to its extensionless form, so one
|
||||||
|
|
@ -473,6 +498,28 @@ mod tests {
|
||||||
assert_not_found(&site, "/nope");
|
assert_not_found(&site, "/nope");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_mews_stylesheet_is_served_embedded_when_the_profile_is_on() {
|
||||||
|
let (dir, _site) = fixture();
|
||||||
|
let mews_site =
|
||||||
|
Site::new(dir.path(), registry(), vec!["stub".to_string()], true, Vec::new()).unwrap();
|
||||||
|
for path in ["/mews-0.1.css", "/dir/mews-0.1.css"] {
|
||||||
|
match mews_site.resolve(path).unwrap() {
|
||||||
|
Resolution::Found(Resource::Embedded { bytes, media_type }) => {
|
||||||
|
assert_eq!(media_type, "text/css; charset=utf-8");
|
||||||
|
assert_eq!(bytes, MEWS_STYLESHEET, "{path}");
|
||||||
|
}
|
||||||
|
other => panic!("expected the embedded stylesheet at {path}, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_mews_stylesheet_name_is_an_ordinary_missing_file_when_the_profile_is_off() {
|
||||||
|
let (_dir, site) = fixture();
|
||||||
|
assert_not_found(&site, "/mews-0.1.css");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bare_unresolvable_segment_resolves_to_nothing() {
|
fn a_bare_unresolvable_segment_resolves_to_nothing() {
|
||||||
// The obvious implementation splits on the last `/`, where a bare
|
// The obvious implementation splits on the last `/`, where a bare
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue