ontoref-outreach/site/justfile
2026-07-17 06:52:19 +01:00

671 lines
37 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 *args:
nu "{{ project_root }}/../scripts/decks/gen-deck-pages.nu" {{ args }}
# Register a NEW deck: scaffold the content pages (frontmatter only — the body is the
# projection's) and print the decks.ncl entry to add. The manifest is not written by a script:
# which deck ships, under which slug, in which language, is a decision.
# monolingüe: just deck-new intro_slides.md intro --title-es "…" --slug-es "…"
# bilingüe (traduc): just deck-new ontology_slides.md ontology --title-es … --title-en … --slug-…
# bilingüe nativo: just deck-new rustikon_es.md rustikon --title-es … --slug-es … \
# --title-en … --slug-en … --source-en rustikon.md
[no-cd]
deck-new source id title_es="" slug_es="" title_en="" slug_en="" source_en="":
#!/usr/bin/env bash
set -e
# just binds positionals LITERALLY: `title_es="X"` arrives as the string `title_es=X`.
# Same `key=` strip the `content` recipe does, so both forms work. Omit a language's
# title/slug and that language gets NO page — a deck that exists only in Spanish must not
# have an English URL serving Spanish slides.
TE="{{ title_es }}"; TE="${TE#title_es=}"
SE="{{ slug_es }}"; SE="${SE#slug_es=}"
TN="{{ title_en }}"; TN="${TN#title_en=}"
SN="{{ slug_en }}"; SN="${SN#slug_en=}"
XN="{{ source_en }}"; XN="${XN#source_en=}"
nu "{{ project_root }}/../scripts/decks/new-deck.nu" "{{ source }}" "{{ id }}" \
--title-es "$TE" --slug-es "$SE" --title-en "$TN" --slug-en "$SN" --source-en "$XN"
# 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.
#
# The ids come from lib/variants.mjs, the one authority on the deck→artifact key. This recipe used
# to re-derive them with `jq -r '.decks[].id'`, from before a bilingual deck became one source (and
# one asset dir) PER LANGUAGE: it asked for `ai-infra`, which holds thumbnails and no bundle, while
# the pages load `ai-infra-es`. It crashed on the deck built after the split and passed on the one
# whose pre-split bundle still sat on disk — gating a file no page loads. Ask the authority.
[no-cd]
decks-check:
#!/usr/bin/env bash
set -e
cd "{{ project_root }}/../presentation"
for id in $(node ../scripts/decks/lib/variants.mjs --ids); 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 nu
# LAS URLs SE DERIVAN DEL MANIFIESTO. Hasta el 2026-07-17 esto llevaba dos URLs escritas a
# mano, y las dos eran del MISMO deck (ontoref-ontologia-reflexion). Había cuatro. La puerta
# salía verde —exit 0, todo PASS— sin haber cargado nunca ai-infra, rustikon ni el cuarto:
# gobernaba 1 de 4 y nada lo decía. Una puerta que no cubre lo que se añade después no es una
# puerta, es un cartel; y una lista a mano dentro de una receta es el patógeno que esta casa
# ya tiene con nombre (404-PAP, `sin match arms a mano`).
# Se descubrió publicando el cuarto deck: pasó `decks-served` sin ser mirado.
let base = ("{{ base_url }}" | split row "=" | last)
let m = (^nickel export "{{ project_root }}/../scripts/decks/decks.ncl" | from json)
cd "{{ project_root }}/../presentation"
let pages = ($m.decks | each {|d| $d.pages | each {|p| {id: $d.id, lang: $p.lang, slug: $p.slug} } } | flatten)
if ($pages | is-empty) {
# Cero páginas es un HALLAZGO sobre la medición, no un aprobado: así mueren las puertas.
error make { msg: "decks-served: el manifiesto no declara ni una página — no hay nada que verificar, y eso NO es un pase" }
}
print $"decks-served: ($pages | length) páginas derivadas del manifiesto"
for p in $pages {
let url = $"($base)/($m.kind | get $p.lang)/($m.category | get $p.lang)/($p.slug)"
print $"── ($p.id) [($p.lang)] → ($url)"
^node ../scripts/decks/gates/served.mjs $url
}
# 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
# Gate: the About's static hook reproduces from core.ncl — nobody draws the ontology by hand.
#
# `architecture-check` existed and these two did not, so `diagram` shipped THREE projections and
# only one was gated. The other two rode outside the chain: `diagram` was not in `sync-full`
# either, so the generator ran by hand, once, and nothing compared it to the ontology afterwards
# — `generator-outside-the-chain`, verbatim. Measured 2026-07-16: no drift, but core.ncl was
# NEWER than both outputs (12-jul 00:59 vs 11-jul 23:47). They agreed by luck, not by floor.
[no-cd]
diagram-check:
nu "{{ project_root }}/scripts/build/gen-diagram.nu" --root "{{ project_root }}/../.." --out "{{ project_root }}/site/public/images/ontoref-diagram.svg" --check
# Gate: the About's LIVE PRIZE reproduces from core.ncl (ADR-057 hook-static / prize-live).
# A prize that silently stops tracking the ontology is worse than no prize: the reveal still
# promises it, and the visitor who follows the route is the one who finds out.
[no-cd]
graph-page-check:
nu "{{ project_root }}/scripts/build/gen-graph-page.nu" --root "{{ project_root }}/../.." --out "{{ project_root }}/site/public/images/ontoref-graph.html" --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 }}"
nu "{{ project_root }}/scripts/build/gen-protocolo.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 }}/scripts/build/gen-protocolo.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
# The gates resolve some paths RELATIVE to the working directory (the nu generators default
# to --out-dir "site/public/r"). `check` is [no-cd], and a git hook runs from the REPO ROOT —
# where those paths point at nothing, and the gate reads "no file" as DRIFT. It reported
# `glossary-check: DRIFT` over a glossary that reproduces perfectly. There was no drift;
# there was a missing cd. Anchor it, so `check` means the same thing from anywhere.
cd "{{ project_root }}"
fail=()
gates=(adr-check architecture-check diagram-check graph-page-check glossary-check conceptos-check templates-check assets-check expedientes-check informe-check expedientes-parity posts-check decks-check)
for gate in "${gates[@]}"; do
# --justfile, explicitly. The outer call inherits nothing: `check` is [no-cd], and a git
# hook runs from the REPO ROOT, where there is no justfile — so a bare `just "$gate"`
# resolved against nothing and every gate came back "does not contain recipe", including
# the ones that had been green for months. The hook could never have passed; nobody had
# tried to commit a change under site/ since it was installed.
just --justfile "{{ justfile() }}" "$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.
# ¿El servidor que responde en ese puerto es ESTE sitio? Pide las rutas que site/config/routes/
# declara y exige el texto que su title_key promete. Un servidor arrancado sin el entorno de
# run.sh responde 200 a lo que la imagen compartida trae compilado y 404 a lo que aporta el sitio:
# medir contra él da diagnósticos falsos con toda la confianza del mundo. Corre sola, y de
# preflight dentro de expedientes-served.
[no-cd]
server-check base_url="http://localhost:3030":
#!/usr/bin/env bash
set -e
BASE="{{ base_url }}"; BASE="${BASE#*=}"
nu "{{ project_root }}/scripts/build/check-live-server.nu" --base-url "$BASE" --root "{{ project_root }}"
[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.
# `diagram` sits before `about-json` because the About graph block references its output — the
# recipe's own comment says so, and for that same reason it was left OUT of this chain entirely:
# it ran by hand. `graph` here is content_graph.json, a different artifact; the name collision is
# exactly what let the omission read as covered.
[no-cd]
sync-full: templates htmx-assets rotator-assets adr-pages expedientes informe catalog-content decks content glossary conceptos graph adr-map diagram 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