diff --git a/apps/vectorcraft-web/Trunk.toml b/apps/vectorcraft-web/Trunk.toml index f468dc13..aad9a163 100644 --- a/apps/vectorcraft-web/Trunk.toml +++ b/apps/vectorcraft-web/Trunk.toml @@ -10,8 +10,9 @@ addresses = ["127.0.0.1"] port = 8766 # Copy the craft-fonts faces web builds fetch (crates/text/web-fonts.txt) into the site's fonts/ -# folder; a no-op without CRAFT_FONTS_DIR. Runs for `trunk serve` and `trunk build`. +# folder; a no-op without CRAFT_FONTS_DIR. Runs for `trunk serve` and `trunk build`, through cargo so +# it needs no shell (xtask/src/web_fonts.rs). [[hooks]] stage = "post_build" -command = "sh" -command_arguments = ["../../packaging/web/copy-fonts.sh"] +command = "cargo" +command_arguments = ["xtask", "web-fonts"] diff --git a/apps/vectorcraft-web/src/fonts.rs b/apps/vectorcraft-web/src/fonts.rs index 36c7a380..2677c11e 100644 --- a/apps/vectorcraft-web/src/fonts.rs +++ b/apps/vectorcraft-web/src/fonts.rs @@ -1,5 +1,5 @@ //! Craft-fonts faces served beside the wasm (`fonts//…`, copied by -//! `packaging/web/copy-fonts.sh`) instead of embedded: [`load_startup_fonts`] fetches the `startup` +//! `cargo xtask web-fonts`) instead of embedded: [`load_startup_fonts`] fetches the `startup` //! ones before the app starts, [`spawn_background_fonts`] the rest once it runs. The browser checks //! each file against its SHA-256 (Subresource Integrity). A face that fails is logged and skipped: //! the app runs without it. With `WEB_FONTS` empty (built without craft-fonts) both do nothing. diff --git a/crates/text/build.rs b/crates/text/build.rs index aed93aa3..73c952fa 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -9,9 +9,9 @@ //! limits (Cloudflare Pages: 25 MiB). //! //! It also writes `WEB_FONTS`: the faces listed in `web-fonts.txt`, which web builds fetch from -//! `fonts//` beside the wasm instead of embedding (`packaging/web/copy-fonts.sh` copies them -//! there; `apps/vectorcraft-web/src/fonts.rs` fetches them). A list line the manifest lacks is a -//! warning, or an error with `CRAFT_FONTS_REQUIRED=1`, and never costs the desktop its fonts. +//! `fonts//` beside the wasm instead of embedding (`cargo xtask web-fonts` copies them +//! there; `apps/vectorcraft-web/src/fonts.rs` fetches them). A list line the manifest lacks is left +//! out with a warning, never an error, and never costs the desktop its fonts. use std::fmt::Write as _; use std::path::{Path, PathBuf}; diff --git a/crates/text/build/web_fonts.rs b/crates/text/build/web_fonts.rs index 94dcddb5..a2e2a805 100644 --- a/crates/text/build/web_fonts.rs +++ b/crates/text/build/web_fonts.rs @@ -1,6 +1,6 @@ //! `web-fonts.txt` (the craft-fonts faces web builds fetch instead of embedding) and the //! `WEB_FONTS` fields built from it. Shared by `build.rs` and the crate's tests (`#[path]`-included -//! by both, so it has no dependencies). `packaging/web/copy-fonts.sh` must agree with [`url`]. +//! by both, so it has no dependencies), and by `xtask/src/web_fonts.rs`, which copies the files to [`url`]. /// One line of `web-fonts.txt`: `when | family | style`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/text/src/craft_fonts.rs b/crates/text/src/craft_fonts.rs index 715a73ed..66887b91 100644 --- a/crates/text/src/craft_fonts.rs +++ b/crates/text/src/craft_fonts.rs @@ -16,7 +16,7 @@ pub struct CraftFont { /// A craft-fonts face the web (wasm32) build fetches from beside the wasm instead of embedding, /// listed in `crates/text/web-fonts.txt` (empty unless built with `CRAFT_FONTS_DIR`). Fetched by -/// `apps/vectorcraft-web/src/fonts.rs`; copied into the site by `packaging/web/copy-fonts.sh`. +/// `apps/vectorcraft-web/src/fonts.rs`; copied into the site by `cargo xtask web-fonts`. pub struct WebFont { pub family: &'static str, pub style: &'static str, diff --git a/crates/text/web-fonts.txt b/crates/text/web-fonts.txt index 5cd21e94..1c8c4401 100644 --- a/crates/text/web-fonts.txt +++ b/crates/text/web-fonts.txt @@ -2,7 +2,7 @@ # One face per line: when | family | style (family and style as in craft-fonts' fonts/manifest.txt). # startup = fetched before the app starts (fallbacks the first frame needs) # background = fetched after the app starts (fonts users pick by name) -# Read by build.rs (the WEB_FONTS table) and packaging/web/copy-fonts.sh (copies the files). +# Read by build.rs (the WEB_FONTS table) and `cargo xtask web-fonts` (copies the files, a Trunk hook). startup | Noto Sans Arabic | Regular background | Cairo | Regular background | Tajawal | ExtraLight diff --git a/docs/development.md b/docs/development.md index 4a30c705..1f28f924 100644 --- a/docs/development.md +++ b/docs/development.md @@ -12,6 +12,13 @@ trunk build --release # writes ../../dist/web (index.html, .js glue trunk serve --release # dev server on http://127.0.0.1:8766 ``` +What the web build needs: `trunk`, the `wasm32-unknown-unknown` target and `cargo`, nothing else (no +POSIX shell, so it works the same on Windows). Optionally, `CRAFT_FONTS_DIR` set to an absolute +craft-fonts checkout adds the fonts the site serves beside the wasm: Trunk's `post_build` hook runs +`cargo xtask web-fonts`, which copies them into `dist/web/fonts/` (it does nothing without +`CRAFT_FONTS_DIR`, and skips with a warning any listed face an older checkout lacks). See +[Arabic fonts and the web build](#arabic-fonts-and-the-web-build). + Any static file server works for `dist/web`, for example `python3 -m http.server 8766` inside that directory. The release `.wasm` is about 17.5 MB, or 7.1 MB gzipped, so serve it with compression. URL flag: `?webgl` forces the WebGL2 backend. @@ -134,7 +141,9 @@ it fetches from beside the wasm, `startup` (before the app starts) or `backgroun - `crates/text/build.rs` turns the list into `vectorcraft_text::WEB_FONTS` (URL `fonts//` and the SRI hash, from the craft-fonts manifest). -- `packaging/web/copy-fonts.sh`, a Trunk `post_build` hook, copies the files there. +- `cargo xtask web-fonts` (`xtask/src/web_fonts.rs`), the Trunk `post_build` hook, copies the files + there, checking each one's SHA-256. A listed face the checkout lacks is skipped with a warning, as + in `build.rs`, so an older craft-fonts just means fewer fonts. - `apps/vectorcraft-web/src/fonts.rs` fetches and registers them, logging `web font …` for any that fail. diff --git a/packaging/web/copy-fonts.sh b/packaging/web/copy-fonts.sh deleted file mode 100755 index f62804ba..00000000 --- a/packaging/web/copy-fonts.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# Trunk post_build hook (apps/vectorcraft-web/Trunk.toml): copy the craft-fonts faces listed in -# crates/text/web-fonts.txt from $CRAFT_FONTS_DIR into the site, each at -# fonts// with its licence beside it: the URLs -# crates/text/build.rs puts in WEB_FONTS. A no-op without CRAFT_FONTS_DIR. -set -eu -[ -n "${CRAFT_FONTS_DIR:-}" ] || exit 0 -: "${TRUNK_STAGING_DIR:?run me as a Trunk post_build hook (TRUNK_STAGING_DIR unset)}" -ROOT=$(cd "$(dirname "$0")/../.." && pwd) -LIST="$ROOT/crates/text/web-fonts.txt" -MANIFEST="$CRAFT_FONTS_DIR/fonts/manifest.txt" -[ -f "$MANIFEST" ] || { echo "copy-fonts: $MANIFEST not found" >&2; exit 1; } - -sha256() { - if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 - else shasum -a 256 "$1" | cut -d' ' -f1; fi -} - -grep -v '^[[:space:]]*#' "$LIST" | grep -v '^[[:space:]]*$' | while IFS= read -r line; do - family=$(printf '%s\n' "$line" | awk -F' [|] ' '{print $2}') - style=$(printf '%s\n' "$line" | awk -F' [|] ' '{print $3}') - row=$(awk -F' [|] ' -v f="$family" -v s="$style" '$1 == f && $2 == s { print $3 "|" $6 "|" $7; exit }' "$MANIFEST") - [ -n "$row" ] || { echo "copy-fonts: $family $style is not in $MANIFEST" >&2; exit 1; } - file=${row%%|*}; rest=${row#*|}; licence=${rest%%|*}; sha=${rest#*|} - got=$(sha256 "$CRAFT_FONTS_DIR/$file") - [ "$got" = "$sha" ] || { echo "copy-fonts: $file: sha256 $got, manifest says $sha" >&2; exit 1; } - dest="$TRUNK_STAGING_DIR/fonts/$(printf '%s' "$sha" | cut -c1-16)" - mkdir -p "$dest" - cp "$CRAFT_FONTS_DIR/$file" "$dest/" - cp "$CRAFT_FONTS_DIR/$licence" "$dest/" -done diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cc568adf..16631b06 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -12,6 +12,7 @@ mod layers; mod sha256; mod stats; mod version; +mod web_fonts; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -32,6 +33,8 @@ commands: ico ... pack PNGs into a Windows .ico (used by packaging/icons.sh) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk + post_build hook; a no-op without CRAFT_FONTS_DIR) "; fn main() -> ExitCode { @@ -49,6 +52,7 @@ fn main() -> ExitCode { Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("web-fonts") => web_fonts::run(&root()), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) diff --git a/xtask/src/web_fonts.rs b/xtask/src/web_fonts.rs new file mode 100644 index 00000000..42943108 --- /dev/null +++ b/xtask/src/web_fonts.rs @@ -0,0 +1,119 @@ +//! `cargo xtask web-fonts`: the Trunk `post_build` hook (`apps/*-web/Trunk.toml`). Copies the +//! craft-fonts faces listed in `crates/text/web-fonts.txt` from `$CRAFT_FONTS_DIR` into the site, +//! each at `fonts//` with its licence beside it: the URLs +//! `crates/text/build.rs` puts in `WEB_FONTS` (both use `crates/text/build/web_fonts.rs`). +//! +//! A no-op without `CRAFT_FONTS_DIR`. A listed face the checkout lacks (an older craft-fonts) is +//! skipped with a warning, as `build.rs` does; a file whose SHA-256 doesn't match the manifest is an +//! error (a corrupt checkout). Rust rather than a shell script, so the hook runs wherever cargo does. + +use std::path::{Path, PathBuf}; + +use crate::sha256; + +#[allow(dead_code)] // build.rs uses the rest (sri, base64) +#[path = "../../crates/text/build/web_fonts.rs"] +mod list; + +/// The hook: reads `CRAFT_FONTS_DIR` and Trunk's `TRUNK_STAGING_DIR`. +pub fn run(root: &Path) -> Result<(), String> { + let var = |name| std::env::var_os(name).filter(|v| !v.is_empty()).map(PathBuf::from); + run_with(root, var("CRAFT_FONTS_DIR"), var("TRUNK_STAGING_DIR")) +} + +fn run_with(root: &Path, craft_fonts: Option, staging: Option) -> Result<(), String> { + let Some(dir) = craft_fonts else { return Ok(()) }; + // Relative like build.rs: from the workspace root, not the directory Trunk runs hooks in. + let dir = if dir.is_absolute() { dir } else { root.join(dir) }; + let staging = staging.ok_or("TRUNK_STAGING_DIR is unset: run this as the Trunk post_build hook")?; + let (copied, skipped) = copy(&root.join("crates/text/web-fonts.txt"), &dir, &staging)?; + if !skipped.is_empty() { + eprintln!("warning: web build won't fetch {} (not in CRAFT_FONTS_DIR={}; an older craft-fonts?)", skipped.join(", "), dir.display()); + } + println!("web-fonts: copied {copied} font(s) into {}", staging.join("fonts").display()); + Ok(()) +} + +/// Copy the faces `list_path` names from the craft-fonts checkout `dir` into `site` (at their +/// `fonts/…` URLs). Returns how many were copied and the listed faces the checkout lacks. +fn copy(list_path: &Path, dir: &Path, site: &Path) -> Result<(usize, Vec), String> { + let read = |p: &Path| std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display())); + let list = read(list_path)?; + let manifest = read(&dir.join("fonts/manifest.txt"))?; + let rows: Vec> = + manifest.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| l.split(" | ").map(str::trim).collect()).collect(); + let (mut copied, mut skipped) = (0, Vec::new()); + for e in list::parse_list(&list)? { + let Some(row) = rows.iter().find(|r| r.first() == Some(&e.family) && r.get(1) == Some(&e.style)) else { + skipped.push(format!("{} {}", e.family, e.style)); + continue; + }; + let (Some(file), Some(licence), Some(sha)) = (row.get(2), row.get(5), row.get(6)) else { + return Err(format!("malformed manifest row for {} {}", e.family, e.style)); + }; + let bytes = std::fs::read(dir.join(file)).map_err(|err| format!("{file}: {err}"))?; + let got = sha256::hex(&bytes); + if got != *sha { + return Err(format!("{file}: sha256 {got}, manifest says {sha}")); + } + let dest = site.join(list::url(sha, file)); + let folder = dest.parent().ok_or_else(|| format!("{}: no parent folder", dest.display()))?; + std::fs::create_dir_all(folder).map_err(|err| format!("{}: {err}", folder.display()))?; + std::fs::write(&dest, &bytes).map_err(|err| format!("{}: {err}", dest.display()))?; + let licence_name = Path::new(licence).file_name().ok_or_else(|| format!("{licence}: not a file"))?; + std::fs::copy(dir.join(licence), folder.join(licence_name)).map_err(|err| format!("{licence}: {err}"))?; + copied += 1; + } + Ok((copied, skipped)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fake craft-fonts checkout with one face, and a list naming it plus one it lacks. + fn fixture(name: &str, manifest_sha: Option<&str>) -> (PathBuf, PathBuf, PathBuf, String) { + let base = std::env::temp_dir().join(format!("xtask-web-fonts-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("craft-fonts"); + std::fs::create_dir_all(dir.join("fonts/a")).unwrap(); + std::fs::write(dir.join("fonts/a/A-Regular.ttf"), b"font bytes").unwrap(); + std::fs::write(dir.join("fonts/a/OFL.txt"), b"licence").unwrap(); + let sha = sha256::hex(b"font bytes"); + let row_sha = manifest_sha.map_or(sha.clone(), str::to_string); + std::fs::write( + dir.join("fonts/manifest.txt"), + format!("# family | style | file | scripts | licence | licence file | sha256 | source\nA Sans | Regular | fonts/a/A-Regular.ttf | Arab | OFL-1.1 | fonts/a/OFL.txt | {row_sha} | https://example.invalid\n"), + ) + .unwrap(); + let list = base.join("web-fonts.txt"); + std::fs::write(&list, "startup | A Sans | Regular\nbackground | Missing Sans | Bold\n").unwrap(); + (list, dir, base.join("site"), sha) + } + + #[test] + fn copies_listed_faces_with_their_licence_to_their_urls() { + let (list, dir, site, sha) = fixture("copy", None); + let (copied, skipped) = copy(&list, &dir, &site).unwrap(); + assert_eq!(copied, 1); + let at = site.join("fonts").join(&sha[..16]); + assert_eq!(std::fs::read(at.join("A-Regular.ttf")).unwrap(), b"font bytes"); + assert_eq!(std::fs::read(at.join("OFL.txt")).unwrap(), b"licence"); + assert_eq!(format!("fonts/{}/A-Regular.ttf", &sha[..16]), list::url(&sha, "fonts/a/A-Regular.ttf")); + assert_eq!(skipped, ["Missing Sans Bold"], "a face the checkout lacks is skipped, not an error"); + } + + #[test] + fn a_file_that_does_not_match_its_manifest_hash_is_an_error() { + let (list, dir, site, _) = fixture("mismatch", Some(&"0".repeat(64))); + let e = copy(&list, &dir, &site).unwrap_err(); + assert!(e.contains("A-Regular.ttf") && e.contains("sha256"), "{e}"); + } + + #[test] + fn without_craft_fonts_the_hook_does_nothing() { + let (_, _, site, _) = fixture("noop", None); + run_with(Path::new("/nonexistent"), None, None).unwrap(); + assert!(!site.exists()); + } +}