diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3468ebfc5..28fa3956b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -56,25 +56,73 @@ jobs: os: '["ubuntu-latest", "macos-latest", "windows-latest"]' release-build: true + # Everyday jobs on real data: the Census Bureau's tracts for Shelby County, Tennessee, checked + # against the areas it publishes (crates/engine/tests/workflows.rs). The files are public domain + # and pinned by checksum; a changed file fails the run, a download that fails skips the tests + # (`tested` tells the report, which then neither opens nor closes an issue). + workflows: + name: Workflows on real data + needs: changed + if: needs.changed.outputs.run == 'true' + runs-on: ubuntu-latest + outputs: + tested: ${{ steps.data.outputs.ok }} + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + save-if: false + - uses: actions/cache@v6 + with: + path: workflow-data + key: workflow-data-census-2024-1 + - id: data + name: Download the Census files + run: | + set -uo pipefail + mkdir -p workflow-data + cd workflow-data + ok=true + while read -r sum url; do + file="${url##*/}" + if ! echo "$sum $file" | sha256sum -c --status 2>/dev/null; then + curl -sSfL --retry 3 -o "$file" "$url" || { echo "::warning::could not download $url"; ok=false; continue; } + echo "$sum $file" | sha256sum -c || { echo "::error::$file has changed: update its checksum and check the test"; exit 1; } + fi + done <<'LIST' + e635ccb4f3035f8fa19b76352a63c89b893f57d0b96ecd5b713754141184bd3d https://www2.census.gov/geo/tiger/TIGER2024/TRACT/tl_2024_47_tract.zip + 38cdd818f50248f67fabf4baf4b82df4ed6110dcaafebcbe4d4f9a389bb47ab8 https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2024_Gazetteer/2024_gaz_tracts_47.txt + LIST + echo "ok=$ok" >> "$GITHUB_OUTPUT" + - name: Run the workflow tests on them + if: steps.data.outputs.ok == 'true' + env: + RUSTGIS_WORKFLOW_DATA: ${{ github.workspace }}/workflow-data + run: cargo test --locked -p rustgis-engine --test workflows -- --ignored + report: name: Report - needs: [changed, rust] + needs: [changed, rust, workflows] # A cancelled run is neither a failure nor a fix. - if: always() && github.event_name == 'schedule' && needs.changed.outputs.run == 'true' && needs.rust.result != 'cancelled' + if: always() && github.event_name == 'schedule' && needs.changed.outputs.run == 'true' && needs.rust.result != 'cancelled' && needs.workflows.result != 'cancelled' runs-on: ubuntu-latest permissions: issues: write steps: - env: GH_TOKEN: ${{ github.token }} - RESULT: ${{ needs.rust.result }} + # "incomplete": everything ran passed, but the real-data tests couldn't download their files. + RESULT: ${{ (needs.rust.result != 'success' || needs.workflows.result != 'success') && 'failure' || (needs.workflows.outputs.tested == 'true' && 'success' || 'incomplete') }} RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail title="Nightly build failed" open="$(gh issue list -R "$GITHUB_REPOSITORY" --state open --search "in:title \"$title\"" \ --json number,title --jq "[.[] | select(.title == \"$title\")][0].number // \"\"")" - if [ "$RESULT" = success ]; then + if [ "$RESULT" = incomplete ]; then + echo "The real-data tests didn't run (download failed): leaving the nightly issue as it is." + elif [ "$RESULT" = success ]; then if [ -n "$open" ]; then gh issue close "$open" -R "$GITHUB_REPOSITORY" --comment "Fixed: the nightly run on $GITHUB_SHA passed ($RUN)." fi diff --git a/crates/engine/tests/workflows.rs b/crates/engine/tests/workflows.rs new file mode 100644 index 000000000..22863b21b --- /dev/null +++ b/crates/engine/tests/workflows.rs @@ -0,0 +1,350 @@ +//! Everyday jobs run from start to finish the way a user runs them: by command id, as the ribbon, +//! the CLI, Python and the AI Assistant do. Each checks its result against an answer worked out +//! independently, then saves, reopens and hands the result on, checking nothing changed on the way. +//! +//! The data is made here, small enough that the whole file runs in well under a second. + +use rustgis_engine::data::{Dataset, Value as Field, io}; +use rustgis_engine::{Project, Session}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; + +/// The parcels' south-west corner, in Memphis, and their size in degrees: 4 columns by 3 rows. +const WEST: f64 = -90.05; +const SOUTH: f64 = 35.10; +const STEP: f64 = 0.005; +/// Zone R1 runs from the parcels' west edge to this longitude, through every row; C2 from here to +/// their east edge, through the two southern rows. Both split a column of parcels down the middle. +const SPLIT: f64 = WEST + 2.5 * STEP; +/// Square kilometres to acres (an acre is 4046.8564224 m²). +const ACRES_PER_KM2: f64 = 1e6 / 4_046.856_422_4; + +/// A scratch folder for one test, removed when it is dropped. +struct Scratch(PathBuf); + +impl Scratch { + /// A new, empty folder under the system's temporary folder, unique to this process and moment. + fn new(label: &str) -> Self { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + let dir = std::env::temp_dir().join(format!("rustgis-workflow-{label}-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + + /// A file in the folder. + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for Scratch { + /// Removes the folder and everything in it, ignoring a failure. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A lon/lat rectangle as a GeoJSON polygon. +fn rect(w: f64, s: f64, e: f64, n: f64) -> Value { + json!({"type": "Polygon", "coordinates": [[[w, s], [e, s], [e, n], [w, n], [w, s]]]}) +} + +/// The id of the parcel in column `col` and row `row`: text with leading zeros, "00101" to "00112". +fn parcel_id(col: usize, row: usize) -> String { + format!("{:05}", 101 + row * 4 + col) +} + +/// The parcels and zoning layers, and an assessment table to join to the parcels. +/// +/// The table keys parcels by the same text ids. It has no row for the last parcel, and has a row +/// for "101": the first parcel's id read as a number, which must not match it. +fn job_data() -> (String, String, String) { + let mut parcels = Vec::new(); + for row in 0..3 { + for col in 0..4 { + let (w, s) = (WEST + col as f64 * STEP, SOUTH + row as f64 * STEP); + parcels.push( + json!({"type": "Feature", "properties": {"parcel_id": parcel_id(col, row)}, "geometry": rect(w, s, w + STEP, s + STEP)}), + ); + } + } + let zoning = [ + json!({"type": "Feature", "properties": {"zone": "R1"}, "geometry": rect(WEST, SOUTH, SPLIT, SOUTH + 3.0 * STEP)}), + json!({"type": "Feature", "properties": {"zone": "C2"}, "geometry": rect(SPLIT, SOUTH, WEST + 4.0 * STEP, SOUTH + 2.0 * STEP)}), + ]; + let mut csv = String::from("parcel_id,value_usd\n"); + for k in 0..11 { + csv += &format!("{},{}\n", parcel_id(k % 4, k / 4), 100_000 + k * 1_000); + } + csv += "101,999999\n"; + let fc = |f: &[Value]| json!({"type": "FeatureCollection", "features": f}).to_string(); + (fc(&parcels), fc(&zoning), csv) +} + +/// A session with the job's layers and table loaded, named `parcels`, `zoning` and `assessments`. +fn loaded() -> Session { + let (parcels, zoning, csv) = job_data(); + let mut s = Session::new(Project::new("workflow")); + s.run("layer.addDataText", &json!({"fileName": "parcels.geojson", "text": parcels})).unwrap(); + s.run("layer.addDataText", &json!({"fileName": "zoning.geojson", "text": zoning})).unwrap(); + s.run("layer.addDataText", &json!({"fileName": "assessments.csv", "text": csv})).unwrap(); + s +} + +/// The job's steps: join the assessments to the parcels, split them by zone, measure the pieces, +/// and total their area by zone, into the table `zoned_Statistics`. +fn job_steps() -> Value { + json!([ + {"tool": "joinField", "params": {"target": "parcels", "targetField": "parcel_id", "joinTable": "assessments", "output": "assessed"}}, + {"tool": "intersect", "params": {"input": "%1", "overlay": "zoning", "output": "zoned"}}, + {"tool": "calculateGeometry", "params": {"input": "%2"}}, + {"tool": "summaryStatistics", "params": {"input": "%2", "case": "zone", "statistics": [{"field": "area_km2", "statistic": "SUM"}]}}, + ]) +} + +/// The area of a lon/lat rectangle on WGS 84, in km², from the closed form for the area between two +/// parallels (Snyder, Map Projections: A Working Manual, eq. 3-12 with 3-11). The tool's edges are +/// geodesics, which bow from the parallels by about 2 cm over these 1 km edges: under 1e-6 of the area. +fn quadrangle_km2(w: f64, s: f64, e: f64, n: f64) -> f64 { + let (a, f): (f64, f64) = (6_378_137.0, 1.0 / 298.257_223_563); + let e2 = f * (2.0 - f); + let ecc = e2.sqrt(); + let q = |lat: f64| { + let x = lat.to_radians().sin(); + (1.0 - e2) * (x / (1.0 - e2 * x * x) - (1.0 / (2.0 * ecc)) * ((1.0 - ecc * x) / (1.0 + ecc * x)).ln()) + }; + a * a / 2.0 * (e - w).to_radians() * (q(n) - q(s)) / 1e6 +} + +/// A dataset's rows as `field → value` maps, keyed by one field's text, for comparing two copies. +fn rows(ds: &Dataset, key: &str) -> std::collections::BTreeMap> { + let k = ds.field_index(key).unwrap_or_else(|| panic!("{}: no field '{key}'", ds.name)); + (0..ds.features.len()) + .map(|r| { + let fields = ds.fields.iter().enumerate().map(|(i, f)| (f.name.clone(), ds.value(r, i).clone())).collect(); + (ds.value(r, k).to_string(), fields) + }) + .collect() +} + +/// The project's table called `name`. +fn table<'a>(s: &'a Session, name: &str) -> &'a Dataset { + s.project.tables.iter().map(|t| &t.dataset).find(|d| d.name == name).unwrap_or_else(|| panic!("no table '{name}'")) +} + +/// The map's layer called `name`. +fn layer<'a>(s: &'a Session, name: &str) -> &'a Dataset { + &s.map().layer_by_name(name).unwrap_or_else(|| panic!("no layer '{name}'")).dataset +} + +/// Checks the zone totals against the closed-form areas: R1 is three rows of 2.5 parcels, C2 two +/// rows of 1.5 (the north-east parcel and the east half of its neighbour are in no zone). +fn check_totals(stats: &Dataset) { + let want = [ + ("R1", 9, quadrangle_km2(WEST, SOUTH, SPLIT, SOUTH + 3.0 * STEP)), + ("C2", 4, quadrangle_km2(SPLIT, SOUTH, WEST + 4.0 * STEP, SOUTH + 2.0 * STEP)), + ]; + let got = rows(stats, "zone"); + assert_eq!(stats.features.len(), 2, "one row per zone"); + assert_eq!(got.len(), 2, "one row per zone: {got:?}"); + for (zone, pieces, km2) in want { + let row: std::collections::HashMap<_, _> = got[zone].iter().cloned().collect(); + assert_eq!(row["FREQUENCY"], Field::Int(pieces), "{zone}: pieces"); + let sum = row["SUM_area_km2"].as_f64().unwrap(); + assert!((sum - km2).abs() < 1e-6 * km2, "{zone}: {sum} km², the closed form {km2} km²"); + // The figure an analyst reports, to the acre. + assert_eq!((sum * ACRES_PER_KM2).round(), (km2 * ACRES_PER_KM2).round(), "{zone}: acres"); + } +} + +/// The value the assessment table gives a parcel: none for the last, which has no row. +fn assessment(parcel: &str) -> Field { + let k = parcel.parse::().unwrap_or_else(|_| panic!("{parcel}")) - 101; + if k < 11 { Field::Int(100_000 + k * 1_000) } else { Field::Null } +} + +/// Checks that every feature carries its parcel's assessed value; the number checked. +fn check_values(ds: &Dataset) -> usize { + let (id, value) = (ds.field_index("parcel_id").unwrap(), ds.field_index("value_usd").unwrap()); + for r in 0..ds.features.len() { + let parcel = ds.value(r, id).to_string(); + assert_eq!(ds.value(r, value), &assessment(&parcel), "{}: {parcel}", ds.name); + } + ds.features.len() +} + +/// Join a spreadsheet to parcels, keeping leading zeros in their ids; overlay zoning; total the +/// area by zone; make a map. Save, reopen and hand the pieces and totals to a colleague as files, +/// with every count and total still matching. +#[test] +fn join_overlay_summarise_map_save_reopen_and_share() { + let dir = Scratch::new("w1"); + let mut s = loaded(); + // One tool at a time, as from the Tools pane: each step names the layer the one before made. + let steps = job_steps().to_string().replace("\"%1\"", "\"assessed\"").replace("\"%2\"", "\"zoned\""); + for step in serde_json::from_str::(&steps).unwrap().as_array().unwrap() { + s.run("gp.run", step).unwrap_or_else(|e| panic!("{}: {e}", step["tool"])); + } + + // The join matched every parcel by its text id; "101" matched none, and the parcel with no + // assessment kept its row with no value. + assert_eq!(check_values(layer(&s, "assessed")), 12); + check_totals(table(&s, "zoned_Statistics")); + let zoned = layer(&s, "zoned").clone(); + // Every piece keeps its parcel's value through the overlay. + assert_eq!(check_values(&zoned), 13); + + let png = dir.path("map.png"); + s.run("share.exportMap", &json!({"path": png, "width": 320, "height": 200})).unwrap(); + let bytes = std::fs::read(&png).unwrap(); + let map = rustgis_engine::render::decode_png(&bytes).expect("a PNG came out"); + let colours: std::collections::HashSet<_> = map.pixels().iter().map(|p| (p.red(), p.green(), p.blue())).collect(); + assert!(colours.len() > 2, "the map draws its layers, not only a background: {} colours", colours.len()); + + // Reopened, the project has the same layers, pieces and totals. + let project = dir.path("zoning.rgis"); + s.run("project.saveAs", &json!({"path": project})).unwrap(); + let mut reopened = Session::new(Project::default()); + reopened.run("project.open", &json!({"path": project})).unwrap(); + let names = |s: &Session| s.map().layers.iter().map(|l| l.dataset.name.clone()).collect::>(); + assert_eq!(names(&reopened), names(&s)); + assert_eq!(layer(&reopened, "zoned").features, zoned.features); + check_totals(table(&reopened, "zoned_Statistics")); + + // Handed on as a GeoPackage and a CSV, read back as a colleague's GIS would read them. + let gpkg = dir.path("zoned.gpkg"); + reopened.run("share.exportLayer", &json!({"layer": "zoned", "path": gpkg})).unwrap(); + let (back, _) = io::read_path(&gpkg).unwrap(); + assert_eq!(check_values(&back), 13, "values survive the GeoPackage"); + let ids = |ds: &Dataset| (0..ds.features.len()).map(|r| ds.value(r, ds.field_index("parcel_id").unwrap()).clone()).collect::>(); + assert_eq!(ids(&back), ids(&zoned), "parcel ids, leading zeros included"); + let area = + |ds: &Dataset| -> f64 { (0..ds.features.len()).filter_map(|r| ds.value(r, ds.field_index("area_km2").unwrap()).as_f64()).sum() }; + assert!((area(&back) - area(&zoned)).abs() < 1e-12, "areas survive the GeoPackage"); + let csv = dir.path("totals.csv"); + reopened.run("share.exportTable", &json!({"table": "zoned_Statistics", "path": csv})).unwrap(); + let (totals, _) = io::read_path(&csv).unwrap(); + check_totals(&totals); +} + +/// Exports a session's job results as files and returns their bytes: the pieces as GeoJSON and +/// the totals as CSV. +fn outputs(s: &mut Session, dir: &Path, tag: &str) -> (Vec, Vec) { + let (pieces, totals) = (dir.join(format!("{tag}.geojson")), dir.join(format!("{tag}.csv"))); + s.run("share.exportLayer", &json!({"layer": "zoned", "path": pieces})).unwrap(); + s.run("share.exportTable", &json!({"table": "zoned_Statistics", "path": totals})).unwrap(); + (std::fs::read(pieces).unwrap(), std::fs::read(totals).unwrap()) +} + +/// A saved job, run again on the same data, gives byte-identical files: in another session, and +/// after the project is saved and reopened. +#[test] +fn a_saved_job_gives_identical_output_every_time() { + let dir = Scratch::new("w3"); + let mut first = loaded(); + first.run("model.new", &json!({"name": "zoning job", "steps": job_steps()})).unwrap(); + first.run("model.run", &json!({"name": "zoning job"})).unwrap(); + let want = outputs(&mut first, &dir.0, "first"); + check_totals(table(&first, "zoned_Statistics")); + + let mut second = loaded(); + second.run("model.new", &json!({"name": "zoning job", "steps": job_steps()})).unwrap(); + second.run("model.run", &json!({"name": "zoning job"})).unwrap(); + assert!(outputs(&mut second, &dir.0, "second") == want, "a second session's run differs"); + + // The model travels with the project: reopened, the job runs on a fresh copy of the data. + let project = dir.path("job.rgis"); + let mut fresh = loaded(); + let saved = first.project.models.clone(); + fresh.project.models = saved; + fresh.run("project.saveAs", &json!({"path": project})).unwrap(); + let mut reopened = Session::new(Project::default()); + reopened.run("project.open", &json!({"path": project})).unwrap(); + reopened.run("model.run", &json!({"name": "zoning job"})).unwrap(); + assert!(outputs(&mut reopened, &dir.0, "reopened") == want, "the reopened project's run differs"); +} + +/// A job given bad input stops with a message that names the problem, and leaves the project as +/// it was: the data, the layers and the tables. +#[test] +fn a_bad_input_stops_the_job_and_leaves_the_data_untouched() { + let mut s = loaded(); + let before = serde_json::to_value(&s.project).unwrap(); + let mut steps = job_steps(); + steps[1]["params"]["overlay"] = json!("zonning"); + s.run("model.new", &json!({"name": "typo", "steps": steps})).unwrap(); + let with_model = serde_json::to_value(&s.project).unwrap(); + let err = s.run("model.run", &json!({"name": "typo"})).unwrap_err(); + assert!(err.contains("zonning"), "the message names the missing layer: {err}"); + let after = serde_json::to_value(&s.project).unwrap(); + // Everything but the run's history record is as it was. + let strip = |mut v: Value| { + v.as_object_mut().map(|o| o.remove("gp_history")); + v + }; + assert_eq!(strip(after), strip(with_model.clone()), "the failed run changed the project"); + assert_ne!(before, with_model, "the model was saved"); + + // A file that isn't text is refused, again without touching the project. + let err = s.run("layer.addDataBytes", &json!({"fileName": "broken.csv", "data": "//79/A=="})).unwrap_err(); + assert!(err.contains("broken.csv"), "the message names the file: {err}"); + assert_eq!(strip(serde_json::to_value(&s.project).unwrap()), strip(with_model)); +} + +/// The Census files the nightly build downloads (see `.github/workflows/nightly.yml`), in the folder +/// `RUSTGIS_WORKFLOW_DATA` names. +fn census(file: &str) -> PathBuf { + let dir = std::env::var_os("RUSTGIS_WORKFLOW_DATA").expect("RUSTGIS_WORKFLOW_DATA names the folder of Census files"); + Path::new(&dir).join(file) +} + +/// The Census Bureau's tracts for Tennessee (TIGER/Line), cut to Shelby County, joined to the +/// Gazetteer's published areas and measured: every tract's geodesic area matches its published +/// land and water area. Saved, reopened and exported, the 249 tracts and their areas stay the same. +/// +/// The published areas and the TIGER outlines agree to 4e-6 (worked out separately with +/// GeographicLib); the test allows 1e-5. +#[test] +#[ignore = "reads Census files the nightly build downloads (RUSTGIS_WORKFLOW_DATA)"] +fn shelby_county_tract_areas_match_the_census() { + let dir = Scratch::new("shelby"); + let mut s = Session::new(Project::new("shelby")); + s.run("layer.addData", &json!({"path": census("tl_2024_47_tract.zip")})).unwrap(); + s.run("layer.addData", &json!({"path": census("2024_gaz_tracts_47.txt")})).unwrap(); + let tracts = "tl_2024_47_tract"; + s.run("gp.run", &json!({"tool": "selectByAttributes", "params": {"input": tracts, "where": "COUNTYFP = '157'"}})).unwrap(); + // The Gazetteer's GEOID reads as a number and the tracts' as text, and Join Field matches only + // keys of one type, so the key is copied into a number field first, as a user would. + s.run("edit.addField", &json!({"layer": tracts, "name": "GEOID_N", "type": "long"})).unwrap(); + s.run("edit.calculateField", &json!({"layer": tracts, "field": "GEOID_N", "expression": "GEOID"})).unwrap(); + let join = json!({"target": tracts, "targetField": "GEOID_N", "joinTable": "2024_gaz_tracts_47", "joinField": "GEOID", + "fields": "ALAND,AWATER", "prefix": "gaz_", "output": "shelby"}); + s.run("gp.run", &json!({"tool": "joinField", "params": join})).unwrap(); + s.run("gp.run", &json!({"tool": "calculateGeometry", "params": {"input": "shelby"}})).unwrap(); + + let check = |ds: &Dataset| { + assert_eq!(ds.features.len(), 249, "Shelby County's tracts"); + let f = |name: &str| ds.field_index(name).unwrap_or_else(|| panic!("no field '{name}'")); + let (geoid, land, water, area) = (f("GEOID"), f("gaz_ALAND"), f("gaz_AWATER"), f("area_km2")); + for r in 0..ds.features.len() { + let id = ds.value(r, geoid).to_string(); + assert!(id.starts_with("47157") && id.len() == 11, "{id}"); + let published = + ds.value(r, land).as_f64().unwrap_or_else(|| panic!("{id}: not joined")) + ds.value(r, water).as_f64().unwrap_or(0.0); + let got = ds.value(r, area).as_f64().unwrap() * 1e6; + // The published areas are whole square metres: a metre either way is rounding. + assert!((got - published).abs() <= 1e-5 * published + 1.0, "{id}: {got:.0} m², the Census Bureau {published:.0} m²"); + } + }; + let shelby = layer(&s, "shelby").clone(); + check(&shelby); + + let project = dir.path("shelby.rgis"); + s.run("project.saveAs", &json!({"path": project})).unwrap(); + let mut reopened = Session::new(Project::default()); + reopened.run("project.open", &json!({"path": project})).unwrap(); + assert_eq!(layer(&reopened, "shelby").features, shelby.features); + let gpkg = dir.path("shelby.gpkg"); + reopened.run("share.exportLayer", &json!({"layer": "shelby", "path": gpkg})).unwrap(); + check(&io::read_path(&gpkg).unwrap().0); +} diff --git a/docs/contributing.md b/docs/contributing.md index 31b6d3606..b6f8ac608 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -154,6 +154,15 @@ test similarly generates its KML, KMZ, CSV and GeoJSON inputs. These run automat `cargo test --workspace --locked` and `cargo xtask ci`, without a separate setup step or a public service. Extend these generators when adding cases. +`crates/engine/tests/workflows.rs` runs everyday jobs from start to finish: join, overlay, +summarise, map, save, reopen and export, and the same saved job twice. Its real-data case, +Shelby County's census tracts checked against the areas the Census Bureau publishes, is +`#[ignore]`d: the nightly build downloads the public-domain files, pinned by checksum, and runs it +with `RUSTGIS_WORKFLOW_DATA` naming their folder (the list of files is in `nightly.yml`). To run +it locally, download the files listed there into one folder, check them with `sha256sum` against +the checksums beside them, and run +`RUSTGIS_WORKFLOW_DATA= cargo test -p rustgis-engine --test workflows -- --ignored`. + ## Documentation The site is built with [Zensical](https://zensical.org):