diff --git a/README.md b/README.md index de6d9a1..dece8f2 100644 --- a/README.md +++ b/README.md @@ -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. -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 diff --git a/bin/src/proto/gemini.rs b/bin/src/proto/gemini.rs index cb11680..0dcd1cb 100644 --- a/bin/src/proto/gemini.rs +++ b/bin/src/proto/gemini.rs @@ -68,6 +68,9 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> { std::io::copy(&mut file, &mut stream)?; 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::NotFound) => header(&mut stream, 51, "Not found"), Err(err) => { diff --git a/bin/src/proto/gopher.rs b/bin/src/proto/gopher.rs index 6c5b380..e727def 100644 --- a/bin/src/proto/gopher.rs +++ b/bin/src/proto/gopher.rs @@ -55,6 +55,10 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> { std::io::copy(&mut file, &mut stream)?; Ok(()) } + Ok(Resolution::Found(Resource::Embedded { bytes, .. })) => { + stream.write_all(bytes)?; + Ok(()) + } // resolve_flat follows redirects and sub-documents to real content. Ok(_) => text(&mut stream, b"Not found\n"), Err(err) => { diff --git a/bin/src/proto/http.rs b/bin/src/proto/http.rs index bc63d47..d75d150 100644 --- a/bin/src/proto/http.rs +++ b/bin/src/proto/http.rs @@ -175,6 +175,9 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> { } Ok(()) } + Ok(Resolution::Found(Resource::Embedded { bytes, media_type })) => { + respond(&mut stream, 200, "OK", media_type, bytes, &[], head_only) + } Ok(Resolution::Redirect(location)) => respond( &mut stream, 301, diff --git a/bin/src/proto/nex.rs b/bin/src/proto/nex.rs index 346cdb5..46cbe2d 100644 --- a/bin/src/proto/nex.rs +++ b/bin/src/proto/nex.rs @@ -45,6 +45,9 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> { let mut file = std::fs::File::open(&path)?; 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 // neither reaches here; they are matched for completeness. Ok(_) => stream.write_all(b"Not found\n")?, diff --git a/bin/src/proto/spartan.rs b/bin/src/proto/spartan.rs index 348c736..f1e5de8 100644 --- a/bin/src/proto/spartan.rs +++ b/bin/src/proto/spartan.rs @@ -73,6 +73,9 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> { std::io::copy(&mut file, &mut stream)?; 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::NotFound) => status(&mut stream, 4, "Not found", b""), Err(err) => { diff --git a/core/assets/mews-0.1.css b/core/assets/mews-0.1.css new file mode 100644 index 0000000..fc0d8ed --- /dev/null +++ b/core/assets/mews-0.1.css @@ -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; } \ No newline at end of file diff --git a/core/src/site.rs b/core/src/site.rs index 3eda4ab..0004ea1 100644 --- a/core/src/site.rs +++ b/core/src/site.rs @@ -23,6 +23,16 @@ use crate::render::{Page, Registry, title_from_stem}; /// always enough; the cap is there so a future rule cannot loop. const MAX_FLAT_HOPS: usize = 5; +/// Name a Mews page's stylesheet `` 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. #[derive(Debug)] pub enum Resolution { @@ -38,6 +48,9 @@ pub enum Resource { Document { url: String, page: Arc }, /// A file served byte for byte, streamed rather than buffered. 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. /// /// 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.md` | redirects to `/foo` | /// | `/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 { 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) }; // `/foo.md` always redirects to its extensionless form, so one @@ -473,6 +498,28 @@ mod tests { 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] fn a_bare_unresolvable_segment_resolves_to_nothing() { // The obvious implementation splits on the last `/`, where a bare