ontoref-outreach/site/justfile
Jesús Pérez da93544de3 decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.

The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.

Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.

Twelve traps, none of which raised an error:
  · The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
  · Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
    at 900px and swallowed 14 of 16 slides, silently.
  · UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
  · :not() inherits its argument's specificity — the boundary guard grew with every
    exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
  · scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
    box was 328px while the content stayed 980px and two thirds of every slide was cut.
  · The site's markdown renderer destroys an inline <script>: curly quotes, injected
    <p>, &amp;&amp;. CSS and JS go to external files — correctness, not optimisation.
  · The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
    because the slides were PNGs of themselves).
  · transform: translateY(0) — an IDENTITY transform — makes <main> the containing
    block for position:fixed, and the zoom modal landed at top = -221px.

Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.

Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00

579 lines
31 KiB
Makefile

set shell := ["bash", "-c"]
set dotenv-load := true
project_url := "https://ontoref.dev"
project_root := justfile_directory()
source_project := justfile_directory() + "/../../../website-htmx-rustelo/code"
rustelo_root := justfile_directory() + "/../../../rustelo/code"
# `authoring` (not `content`) to avoid colliding with the flat content recipe below.
# Content-authoring toolkit via rustelo-content CLI: images | sync | validate | new
mod authoring ".just/content.just"
default:
@just --list
@echo -e "\nproject_url = {{ project_url }}"
# ── Server ──────────────────────────────────────────────────────────────────
# Start the HTMX-SSR dev server (auto-detects site/ in PWD)
[no-cd]
dev:
lsof -ti:3030 | xargs kill -9 2>/dev/null || true
rustelo-htmx-server
# ── Content ─────────────────────────────────────────────────────────────────
# Positional filters: `just content projects` or `just content projects es`.
# A `type=`/`lang=` prefix is tolerated, so `just content type=projects lang=es` works too.
# The targeted (--content-type/--language) path is non-incremental — it always rewrites.
#
# The two trees own DIFFERENT files, so the mirror runs in both directions:
# site/r ← content_processor writes the content indexes here (canonical);
# the runtime server reads it (SITE_SERVER_ROOT_CONTENT=r).
# site/public/r ← the nu generators write about/adr-map/taglines/content_graph here,
# and this is the tree the deploy pack ships (site/public/**).
# So: content indexes go r → public/r (or nothing new ever reaches production), and
# the four nu artifacts come back public/r → r (or dev serves June's about/adr-map).
# A whole-tree rsync in ONE direction cannot be right for both, and the old one
# (public/r → r) clobbered each fresh index with the stale deploy-tree copy.
# Regenerate content indexes from markdown in site/content/, then reconcile both trees.
[no-cd]
content type="" lang="":
#!/usr/bin/env bash
set -e
# Brand-linkify blog "ontoref" mentions → /about (idempotent, source-level).
nu "{{ project_root }}/scripts/build/linkify-site.nu" "{{ project_root }}/site/content/blog/**/*.md"
# just args are POSITIONAL: `just content type=projects` binds type="type=projects".
# Strip an optional `key=` prefix so both positional and key=value forms work.
TYPE="{{ type }}"; TYPE="${TYPE#*=}"
LANG_ARG="{{ lang }}"; LANG_ARG="${LANG_ARG#*=}"
ARGS=()
[ -n "$TYPE" ] && ARGS+=(--content-type "$TYPE")
[ -n "$LANG_ARG" ] && ARGS+=(--language "$LANG_ARG")
content_processor "${ARGS[@]}"
# Content indexes → deploy tree. --delete is what keeps a renamed slug from living
# on as a live URL: content_processor prunes site/r, and without --delete the dead
# page would survive in public/r and ship to production. The four nu artifacts are
# excluded (site/r holds only stale copies that would overwrite the fresh originals
# gen-adr-map / gen-about-pages / gen-taglines just wrote) — and --exclude also
# protects them from --delete, since they legitimately exist only on this side.
# `glosario-*.json` joins the exclude list for the same reason the other four are on it: the
# nu generators own it in site/public/r, content_processor knows nothing about it, and an
# unexcluded --delete PRUNES IT. Measured, not feared: `just content` deleted it and the
# tooltip layer 404'd on its first publish. That is expediente 69/58 — the mirror that reverts
# its own work — on a path its cure never covered.
rsync -a --delete \
--exclude about.json --exclude adr-map.json \
--exclude taglines.json --exclude content_graph.json \
--exclude 'glosario-*.json' \
"{{ project_root }}/site/r/" "{{ project_root }}/site/public/r/"
# nu artifacts → serve tree, so `./run.sh` renders the current About/ADR map.
for f in about.json adr-map.json taglines.json content_graph.json; do
src="{{ project_root }}/site/public/r/$f"
if [ -f "$src" ]; then cp -f "$src" "{{ project_root }}/site/r/$f"; fi
done
# Watch mode: auto-regenerate content on file changes
[no-cd]
content-watch type="" lang="":
#!/usr/bin/env bash
set -e
TYPE="{{ type }}"; TYPE="${TYPE#*=}"
LANG_ARG="{{ lang }}"; LANG_ARG="${LANG_ARG#*=}"
ARGS=(--watch)
[ -n "$TYPE" ] && ARGS+=(--content-type "$TYPE")
[ -n "$LANG_ARG" ] && ARGS+=(--language "$LANG_ARG")
content_processor "${ARGS[@]}"
# Regenerate content_graph.json (mini-graphs + ONTOLOGY CONTEXT sidebar)
[no-cd]
graph daemon=project_url:
RUSTELO_ROOT="{{ rustelo_root }}" \
ONTOREF_NICKEL_IMPORT_PATH="{{ project_root }}/../../code/ontology" \
CONTENT_GRAPH_IMPL_ONTOLOGY="{{ project_root }}/../../.ontoref/ontology/core.ncl" \
CONTENT_GRAPH_IMPL_ADRS_GLOB="{{ project_root }}/../../.ontoref/adrs/adr-[0-9]*.ncl" \
CONTENT_GRAPH_FRAMEWORK_ONTOLOGY="{{ rustelo_root }}/../.ontoref/ontology/core.ncl" \
CONTENT_GRAPH_FRAMEWORK_ADRS_GLOB="{{ rustelo_root }}/../.ontoref/adrs/adr-[0-9]*.ncl" \
CONTENT_GRAPH_DAEMON_BASE="{{ daemon }}" \
gen-content-graph
# Regenerate ADR reference map from .ontoref/adrs → site/public/r/adr-map.json
[no-cd]
adr-map daemon=project_url:
nu "{{ project_root }}/scripts/build/gen-adr-map.nu" --root "{{ project_root }}/../.." --daemon "{{ daemon }}"
# The glosses were written in the registry "for a reader who does not know the term" and no
# surface ever showed them: /acerca-de said NCL and DAG to people who had met neither. This is the
# projection that ends that — and it is IN THE CHAIN, because a generator nobody calls is a
# generator that does not run (see adr-pages, eleven ADRs, four weeks).
# Project the term registry → site/public/r/glosario-<lang>.json (the tooltip's only source).
[no-cd]
glossary:
nu "{{ project_root }}/scripts/build/gen-glossary-json.nu" --root "{{ project_root }}/../.."
# Project the term registry → la página /conceptos (servida, no pintada con JS).
[no-cd]
conceptos:
nu "{{ project_root }}/scripts/build/gen-conceptos.nu" --root "{{ project_root }}/../.."
# Project the Slidev decks → real HTML on the deck pages. The words of a published slide used
# to be an IMAGE of words: nothing indexed them, no glossary tooltip could reach them, no screen
# reader read them, and the page drifted from the source the moment a slide changed. The deck is
# NOT re-parsed — Slidev renders it (its own /print route) and we capture what it produced.
# Lives in outreach/scripts/decks because it reads presentation/ and writes site/: it is the
# bridge, not the site's.
[no-cd]
decks:
nu "{{ project_root }}/../scripts/decks/gen-deck-pages.nu"
# Gate (on disk): the deck's CSS bundle cannot reach out of its wrapper, the site's CSS cannot
# reach in — except the glossary, which is entitled to and which is the whole point.
[no-cd]
decks-check:
#!/usr/bin/env bash
set -e
cd "{{ project_root }}/../presentation"
for id in $(nickel export --format json ../scripts/decks/decks.ncl | jq -r '.decks[].id'); do
node ../scripts/decks/gates/artifact.mjs "$id"
done
# Gate (served): the PAGE, not the file. 16/16 slides rendered, nothing clipped, the keyboard and
# the index drive it, the zoom fits, the copy buttons copy, no third-party origin added. Verifying
# the file would prove nothing — the site's content pipeline sits between generator and reader,
# and it is the pipeline that once ate an inline <script> whole.
[no-cd]
decks-served base_url="http://localhost:3030":
#!/usr/bin/env bash
set -e
BASE="{{ base_url }}"; BASE="${BASE#*=}"
cd "{{ project_root }}/../presentation"
node ../scripts/decks/gates/served.mjs "$BASE/recursos/decks/ontoref-ontologia-reflexion"
node ../scripts/decks/gates/served.mjs "$BASE/resources/decks/ontoref-ontology-and-reflection"
# Gate: la página de conceptos reproduce desde el registro — nadie edita un glosario a mano.
[no-cd]
conceptos-check:
nu "{{ project_root }}/scripts/build/gen-conceptos.nu" --root "{{ project_root }}/../.." --check
# Gate: the served glossary reproduces from the registry — nobody hand-edits a tooltip.
[no-cd]
glossary-check:
nu "{{ project_root }}/scripts/build/gen-glossary-json.nu" --root "{{ project_root }}/../.." --check
# The web-usable form of `onre diagram` (served at /images/, the static asset root).
# Referenced by the About graph block, so must run before about-json.
# Project the ontology (core.ncl) → site/public/images/ontoref-diagram.svg.
[no-cd]
diagram:
nu "{{ project_root }}/scripts/build/gen-diagram.nu" --root "{{ project_root }}/../.." --out "{{ project_root }}/site/public/images/ontoref-diagram.svg"
nu "{{ project_root }}/scripts/build/gen-graph-page.nu" --root "{{ project_root }}/../.." --out "{{ project_root }}/site/public/images/ontoref-graph.html"
nu "{{ project_root }}/../../scripts/gen-architecture.nu" --root "{{ project_root }}/../.." --only outreach/site
# The home architecture SVG was a HAND COPY of scripts/templates/architecture.svg.tmpl with every
# {{{{TOKEN}}}} frozen: it shipped v0.1.7 / 41 practices / 163 edges / 56 nodes against a spine at
# 45 / 171 / 60, under a footer claiming to be a live projection. The generator already existed —
# it simply did not know about this target, and nothing gated it. --only keeps the blast radius
# here: a stale brand asset in assets/ is not this site's gate to fail.
# Gate: assert the home architecture SVG matches the spine it is generated from.
[no-cd]
architecture-check:
nu "{{ project_root }}/../../scripts/gen-architecture.nu" --root "{{ project_root }}/../.." --only outreach/site --check
# The ADR set moves with the spine, not with this repo, so the projection has to be in
# the build chain or it silently stops tracking it: ADR-059..069 sat unpublished for
# months because this generator existed but no recipe called it. A generator outside the
# dependency chain is a generator that does not run. Regenerating is safe and idempotent —
# it rewrites the whole adr/ kind from .ontoref/adrs, md + .ncl sidecar per ADR.
# Project .ontoref/adrs → adr content-kind markdown (site/content/adr/en/{accepted,proposed}).
[no-cd]
adr-pages:
#!/usr/bin/env bash
set -e
# The status dirs are cleared first because status is part of the PATH
# (accepted/adr-070.md vs proposed/adr-070.md): accepting an ADR moves its page,
# and the generator only writes — it never removes the page at the old status. Without
# this, `ontoref adr accept` leaves the ADR published twice and adr-check fails on the
# count. The wipe is safe precisely because this projection is fully reproducible.
rm -f "{{ project_root }}"/site/content/adr/en/accepted/adr-[0-9]*.{md,ncl} \
"{{ project_root }}"/site/content/adr/en/proposed/adr-[0-9]*.{md,ncl}
ONTOREF_NICKEL_IMPORT_PATH="{{ project_root }}/../../code/ontology" \
nu "{{ project_root }}/scripts/build/gen-adr-pages.nu" \
--root "{{ project_root }}/../.." \
--mode content \
--content-out "{{ project_root }}/site/content/adr/en"
# Fails if the spine holds an ADR the site never published — the exact drift that hid
# eleven ADRs. Non-mutating: compares .ontoref/adrs against the adr content tree.
# Gate: assert every accepted/proposed ADR in the spine has a page.
[no-cd]
adr-check:
#!/usr/bin/env bash
set -e
spine=$(ls "{{ project_root }}/../../.ontoref/adrs"/adr-[0-9]*.ncl | wc -l | tr -d ' ')
pages=$(ls "{{ project_root }}"/site/content/adr/en/*/adr-[0-9]*.md | wc -l | tr -d ' ')
if [ "$spine" -eq "$pages" ]; then
echo "adr-check: $pages/$spine ADRs projected — spine and site agree"
else
echo "adr-check: DRIFT — spine has $spine ADRs, site publishes $pages. Run: just adr-pages"
exit 1
fi
# The howto calls the CASE object the single source of truth; until now it was not —
# the .md body was hand-rendered, and 356 of its 500 lines were the shared glossary
# widget pasted in verbatim. The body is now a projection of the .ncl; the frontmatter
# (title, excerpt, image, sort_order) stays authored. Edit the .ncl, never the body.
# Project the typed expediente CASEs (.ncl) → the body of their .md.
[no-cd]
expedientes:
nu "{{ project_root }}/scripts/build/gen-expediente.nu" --root "{{ project_root }}"
# Fails when a .md body differs from what its .ncl produces — i.e. when someone edited
# the render instead of the source, which is how the prose the contract did not model
# came to live only in the generated file.
# Gate: assert every expediente .md body is reproducible from its typed CASE.
#
# The gate runs FIRST, on the real tree, so a real defect is reported as itself. THEN its ORACLE
# runs — because a gate nobody has seen REFUSE is not a gate (adr-072,
# falsify-against-the-new-capability). The oracle carries one negative case per axis the gate
# covers: the extent grows, a language goes missing, the served tree falls behind, the extent
# becomes unreadable. Each was falsified by hand once and the falsification thrown away; this is
# the residue that refuses the same failure tomorrow.
[no-cd]
expedientes-check:
nu "{{ project_root }}/scripts/build/gen-expediente.nu" --root "{{ project_root }}" --check
nu "{{ project_root }}/../../.ontoref/reflection/tests/test_expediente_coverage.nu"
# The standalone informe (site/public/images/expedientes/<lang>/expedientes.html) was
# hand-kept HTML with its cases inlined as a JS array, while the ARTICLES were already
# projected from the same .ncl. Two hands on one dataset drift, and they did: deriva's
# ledger carried 6 rows in the article and 8 in the viewer, and `espejo` was never added
# at all — so its own article linked to an anchor that did not exist.
# Project the typed CASEs → the standalone informe, one per locale.
[no-cd]
informe:
nu "{{ project_root }}/scripts/build/gen-informe.nu" --root "{{ project_root }}"
# Gate: assert every informe is reproducible from its typed CASEs.
[no-cd]
informe-check:
nu "{{ project_root }}/scripts/build/gen-informe.nu" --root "{{ project_root }}" --check
# Gate: every case must exist in every locale. The English tree carried two cases by hand
# and never got the third — an absence nothing could report, because nothing compared the
# two trees. A locale that is deliberately behind should say so, not simply be missing.
[no-cd]
expedientes-parity:
#!/usr/bin/env nu
let base = "{{ project_root }}/site/content/expedientes"
let langs = (ls $base | where type == dir | get name | each {|d| $d | path basename } | where {|d| not ($d | str starts-with "_") })
let by_lang = ($langs | each {|l| { lang: $l, ids: (glob $"($base)/($l)/*/*.ncl" | each {|f| $f | path basename | str replace ".ncl" "" } | sort) } })
let all = ($by_lang | get ids | flatten | uniq | sort)
let gaps = ($by_lang | each {|e| { lang: $e.lang, missing: ($all | where {|id| $id not-in $e.ids }) } } | where {|e| ($e.missing | length) > 0 })
if ($gaps | is-empty) {
print $"expedientes-parity: ($all | length) case\(s\) present in all ($langs | length) locales"
} else {
for g in $gaps { print $"expedientes-parity: ($g.lang) is missing ($g.missing | str join ', ')" }
exit 1
}
# Makes /catalog (browse) + /catalog/{slug} serve as SSR content (runtime kind, no
# rebuild — registered in site/content/content-kinds.toml).
# Project .ontoref/catalog → catalog markdown, then build the content index.
[no-cd]
catalog-content:
nu "{{ project_root }}/scripts/build/gen-catalog-pages.nu" --root "{{ project_root }}/../.." --mode content --content-out "{{ project_root }}/site/content/catalog/en"
content_processor --content-type catalog
# Hot-reloaded by the SSR /about handler (via pages/about_project.j2 when kind=project).
# daemon="" → the "see it live" graph link is emitted host-relative (/graph?…),
# because on the SSR site /graph is served by this same origin. The static
# outreach/web pages pass an absolute --daemon since they may be served elsewhere.
# Emit the level-aware About view-model → site/public/r/about.json (needs adr-map fresh).
[no-cd]
about-json daemon="": diagram
nu "{{ project_root }}/scripts/build/gen-about-pages.nu" \
--root "{{ project_root }}/../.." \
--emit-json "{{ project_root }}/site/public/r/about.json" \
--daemon "{{ daemon }}"
# ── Spine (bl-035) ────────────────────────────────────────────────────────────
# Generate spine landing pages from .ontoref/positioning/spine.ncl
[no-cd]
spine daemon=project_url:
nu "{{ project_root }}/scripts/build/gen-spine-pages.nu" \
--root "{{ project_root }}/../.." \
--content "{{ project_root }}/site/content" \
--daemon "{{ daemon }}"
# ── Taglines (rotating hero phrases) ──────────────────────────────────────────
# Drift-checked at export (taglines-schema.ncl); the hero rotator fetches /r/taglines.json.
# Project .ontoref/positioning/taglines.ncl → site/public/r/taglines.json.
[no-cd]
taglines:
nu "{{ project_root }}/scripts/build/gen-taglines.nu" \
--root "{{ project_root }}/../.." \
--out "{{ project_root }}/site/public/r/taglines.json"
# Projects code/Cargo.toml [workspace.package].version into the home hero badge — the
# one hand-written version literal on the site. Run after a version bump so the badge
# never drifts. Scoped to the home-hero-badge line, so other version strings are untouched.
# Stamp the canonical workspace version into the home hero badge (en + es).
[no-cd]
stamp-version:
#!/usr/bin/env nu
let ver = (open "{{ project_root }}/../../code/Cargo.toml" | get workspace.package.version)
let ftls = [
"{{ project_root }}/site/i18n/locales/en/pages/home.ftl"
"{{ project_root }}/site/i18n/locales/es/pages/home.ftl"
]
for f in $ftls {
let out = (open --raw $f | lines | each {|l|
if ($l | str starts-with "home-hero-badge") {
$l | str replace --all --regex 'v\d+\.\d+\.\d+' $"v($ver)"
} else { $l }
} | str join "\n")
$"($out)\n" | save --force $f
}
print $"stamped v($ver) into home-hero-badge \(en + es\)"
# From the constellation canonical source (assets/vendor) into the site asset trees
# (_public source + public served mirror).
# Materialize the shared hero rotator + typed.js vendor into the site asset trees.
[no-cd]
rotator-assets:
cp -f "{{ project_root }}/../../assets/vendor/tagline-rotator.js" "{{ project_root }}/site/_public/js/tagline-rotator.js"
cp -f "{{ project_root }}/../../assets/vendor/typed.umd.js" "{{ project_root }}/site/_public/js/typed.umd.js"
cp -f "{{ project_root }}/../../assets/vendor/tagline-rotator.js" "{{ project_root }}/site/public/js/tagline-rotator.js"
cp -f "{{ project_root }}/../../assets/vendor/typed.umd.js" "{{ project_root }}/site/public/js/typed.umd.js"
# assets/site/ is the source of truth; only the web formats are mirrored — PNG/JPG
# originals stay out of the deploy. Safe to re-run.
# Mirror web-optimised images (AVIF + WebP) from assets/site/ → site/public/images/.
[no-cd]
sync-site-images:
#!/usr/bin/env nu
let src = "{{ project_root }}/../../assets/site"
let dest = "{{ project_root }}/site/public/images"
mkdir $dest
let files = (glob $"($src)/*.{avif,webp}")
if ($files | is-empty) {
error make { msg: $"no AVIF/WebP found in ($src)" }
}
$files | each {|f| cp $f $dest }
print $"synced ($files | length) image\(s\) from ($src) → ($dest)"
# ── Templates & htmx runtime (the override chain) ───────────────────────────
#
# ONE rule governs both trees below, and it is the rule the implementation already
# applies to the framework: a file present in a higher layer WINS, a file absent from
# it is INHERITED from the layer below. The chain has three levels, not two:
#
# rustelo (framework) → website-htmx-rustelo (implementation) → outreach/site (this)
#
# The consumer's layer is site/_htmx/ — `_` prefixed like site/_public, and for the same
# reason: underscore is the tree you EDIT, its unprefixed twin is the tree that gets
# GENERATED. htmx-templates/ and htmx-assets/ are outputs. They are wiped and rebuilt on
# every run, so a file that lives only there lives nowhere — that is how the nav submenus
# were lost once already. Both targets keep their current paths (HTMX_TEMPLATE_PATH /
# HTMX_ASSETS_PATH, the PV deliverable); only the sources moved.
#
# site/_htmx/templates/** → htmx-templates/**
# site/_htmx/assets/** → htmx-assets/**
framework_templates := rustelo_root + "/crates/foundation/crates/rustelo_pages_htmx/templates"
impl_templates := source_project + "/crates/pages_htmx/templates"
site_templates := project_root + "/site/_htmx/templates"
framework_htmx := rustelo_root + "/templates/shared/htmx"
impl_htmx := source_project + "/templates/shared/htmx"
site_htmx := project_root + "/site/_htmx/assets"
# htmx.lock.toml is the framework's provenance record for the vendored runtime, not part
# of it; .gitkeep only exists to declare an empty consumer layer. Neither ships.
htmx_assets_exclude := "htmx.lock.toml,.gitkeep"
# Assemble htmx-templates: framework → implementation → site/_htmx/templates.
[no-cd]
templates:
nu "{{ project_root }}/scripts/build/assemble-layers.nu" \
"{{ framework_templates }}" "{{ impl_templates }}" "{{ site_templates }}" \
--out "{{ project_root }}/htmx-templates" --label "htmx templates"
# Materialize the htmx runtime (htmx.min.js + ext/*) into htmx-assets/, sibling of
# htmx-templates/. Shipped to the PV by content.nu (distro.ncl deliverable) and served at
# /assets/htmx/* via HTMX_ASSETS_PATH — it lives OUTSIDE site/public (the router mounts
# /assets/htmx as its own ServeDir). Dev masks this via the local-install wrapper's
# HTMX_ASSETS_PATH; prod has no such mount.
#
# It gets the SAME three layers as the templates: until now it had one (the framework),
# so an ext/ override shipped by the implementation could never reach the site, and the
# consumer had nowhere to put one at all.
# Assemble htmx-assets: framework → implementation → site/_htmx/assets.
[no-cd]
htmx-assets:
nu "{{ project_root }}/scripts/build/assemble-layers.nu" \
"{{ framework_htmx }}" "{{ impl_htmx }}" "{{ site_htmx }}" \
--out "{{ project_root }}/htmx-assets" --exclude "{{ htmx_assets_exclude }}" \
--label "htmx runtime"
# Proves the assembly is lossless: reassemble into a temp tree and diff it against the
# live one. Any difference means the target holds work that exists in no layer, and the
# next run (or `just sync`, which calls both) will delete it — the fix is to move that
# work into site/_htmx/, never to skip the run.
# Gate: assert htmx-templates/ is exactly reproducible from its declared layers.
[no-cd]
templates-check:
#!/usr/bin/env bash
set -e
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
nu "{{ project_root }}/scripts/build/assemble-layers.nu" \
"{{ framework_templates }}" "{{ impl_templates }}" "{{ site_templates }}" \
--out "$tmp" >/dev/null
if diff -r "{{ project_root }}/htmx-templates" "$tmp" >/dev/null 2>&1; then
echo "templates-check: htmx-templates/ is reproducible — no unwitnessed work"
else
echo "templates-check: DRIFT — htmx-templates/ holds work absent from site/_htmx/templates/:"
diff -rq "{{ project_root }}/htmx-templates" "$tmp" | sed "s|$tmp|<regenerated>|g"
exit 1
fi
# Gate: assert htmx-assets/ is exactly reproducible from its declared layers.
[no-cd]
assets-check:
#!/usr/bin/env bash
set -e
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
nu "{{ project_root }}/scripts/build/assemble-layers.nu" \
"{{ framework_htmx }}" "{{ impl_htmx }}" "{{ site_htmx }}" \
--out "$tmp" --exclude "{{ htmx_assets_exclude }}" >/dev/null
if diff -r "{{ project_root }}/htmx-assets" "$tmp" >/dev/null 2>&1; then
echo "assets-check: htmx-assets/ is reproducible — no unwitnessed work"
else
echo "assets-check: DRIFT — htmx-assets/ holds work absent from site/_htmx/assets/:"
diff -rq "{{ project_root }}/htmx-assets" "$tmp" | sed "s|$tmp|<regenerated>|g"
exit 1
fi
# ── Posts ─────────────────────────────────────────────────────────────────────
# Brand-linkify mentions → /about, regenerate the content indexes, and rebuild the
# content graph (mini-graph + sidecar relations). Run after editing a post + .ncl sidecar.
# Build a post for publication: linkify + content indexes + content graph.
[no-cd]
post: content graph
# 1. every brand mention is already linkified (no `just content` pending)
# 2. every post graph sidecar resolves into content_graph.json (no stale graph)
# Verify ALL posts are publication-ready without mutating anything (CI gate).
[no-cd]
posts-check:
#!/usr/bin/env bash
set -e
nu "{{ project_root }}/scripts/build/linkify-site.nu" --check "{{ project_root }}/site/content/blog/**/*.md"
nu "{{ project_root }}/scripts/build/check-graph-coverage.nu" "{{ project_root }}"
# Every gate this repo owns, in one run. It does NOT stop at the first failure: a build
# that publishes nothing tells you everything that is wrong, not the first thing.
#
# Until now each of these was an orphan recipe — written by the expediente that motivated
# it and then invoked by nobody. A gate outside the chain is a private ritual, not a gate;
# it is the exact shape of the bug the 69/58 case is about (a generator existed, no recipe
# called it, eleven ADRs went unpublished for four weeks). So: `sync-full` ends here, and
# `just install-hooks` puts it in front of every commit that touches site/.
[no-cd]
check:
#!/usr/bin/env bash
fail=()
gates=(adr-check architecture-check glossary-check conceptos-check templates-check assets-check expedientes-check informe-check expedientes-parity posts-check decks-check)
for gate in "${gates[@]}"; do
just "$gate" > /tmp/gate-$$.log 2>&1 || fail+=("$gate")
sed 's/^/ /' /tmp/gate-$$.log | grep -v '^\s*nu \|^\s*#!' || true
rm -f /tmp/gate-$$.log
done
if [ ${#fail[@]} -eq 0 ]; then
echo "check: ${#gates[@]}/${#gates[@]} gates green — ON DISK. This is NOT proof of publication:"
echo " the server reads its content index INTO MEMORY at startup, so a tree it has"
echo " never re-read is a tree that, to every reader, does not exist."
echo " The page is the publication: just check-live <base-url>"
else
echo "check: FAILED — ${fail[*]}"
exit 1
fi
# THE OTHER HALF OF `check`, and the half that a reader would recognise.
#
# `check` asks whether the DISK is right. It cannot ask whether the SITE is right, and the
# difference is not academic: both trees said five cases, the grid rendered four, and only a
# restart fixed it — because the server had cached its index at boot. A gate that stops at the
# filesystem certifies a publication that nobody can see.
#
# So this is the witness at the end of the chain, and it asks the only question a reader can:
# IS THE CASE ON THE PAGE? It needs a running site, and if it cannot reach one it FAILS — it does
# not skip. "I could not look" is a finding, never a pass (adr-072).
[no-cd]
check-live base_url="http://localhost:3030":
#!/usr/bin/env bash
set -e
BASE="{{ base_url }}"; BASE="${BASE#*=}"
just expedientes-served "$BASE"
just decks-served "$BASE"
echo "check-live: the served surface renders what the tree says is published"
# Gate: every published case is RENDERED AS A CARD on the live grid — counting the card, never
# the string. The ids also live in the nav, the meta tags and two JSON blobs, so a page-wide grep
# reports a case as present while its card is missing. That grep is the check that once reported
# "5 cards" over a grid that was rendering four.
[no-cd]
expedientes-served base_url="http://localhost:3030":
#!/usr/bin/env bash
set -e
BASE="{{ base_url }}"; BASE="${BASE#*=}"
nu "{{ project_root }}/scripts/build/check-served-live.nu" --base-url "$BASE" --root "{{ project_root }}"
# Install the pre-commit gate into this repo (sets core.hooksPath). Undo with:
# git config --unset core.hooksPath
[no-cd]
install-hooks:
#!/usr/bin/env bash
set -e
# Absolute: the repo root is outreach/, the justfile lives in outreach/site/, and a
# relative hooksPath would resolve against the wrong one of the two.
git -C "{{ project_root }}" config core.hooksPath "{{ project_root }}/scripts/hooks"
chmod +x "{{ project_root }}/scripts/hooks/pre-commit"
echo "install-hooks: pre-commit → just check (only when the commit touches site/)"
# ── Sync ────────────────────────────────────────────────────────────────────
# Full resync: templates + htmx runtime + vendor assets + content indexes
# (no graph — that needs ONTOREF env)
[no-cd]
sync: templates htmx-assets rotator-assets content
# Order is load-bearing, not cosmetic:
# adr-pages / catalog-content BEFORE content — they emit the markdown content indexes.
# graph / adr-map / about-json AFTER content — they read the freshly built indexes.
# sync-artifacts LAST — the nu generators write to site/public/r, and only `content`
# copies them back to the serve tree; without this final leg a sync-full leaves
# site/r rendering the previous run's About and ADR map.
# check LAST — a build that cannot assert what it just produced is a reporter, and in
# this repo the check decides, never the reporter (ADR-066).
# Requires ONTOREF_NICKEL_IMPORT_PATH in .env.
# Full resync: projections → content indexes → derived artifacts → serve tree.
[no-cd]
sync-full: templates htmx-assets rotator-assets adr-pages expedientes informe catalog-content decks content glossary conceptos graph adr-map about-json sync-artifacts check
# The nu generators own these four files in site/public/r (the deploy tree); the serve
# tree needs the same copies. `content` does this too, so a standalone `just content`
# stays correct — this recipe exists for the chains that regenerate them AFTER content.
# Copy the four nu artifacts public/r → site/r so the dev server serves what was just built.
[no-cd]
sync-artifacts:
#!/usr/bin/env bash
set -e
for f in about.json adr-map.json taglines.json content_graph.json; do
src="{{ project_root }}/site/public/r/$f"
if [ -f "$src" ]; then cp -f "$src" "{{ project_root }}/site/r/$f"; fi
done