ontoref-outreach/site/justfile
Jesús Pérez fbb6f99f44 expedientes: the render becomes a projection; publish case 69/58
The howto called the CASE object the single source of truth. It was not. The rendered .md
was hand-written — 356 of its 500 lines were the shared glossary widget pasted in verbatim,
and it carried editorial prose (the balance, the closing) that the contract never modelled.
A generator run against the old contract would have DELETED that prose: the exact
work-in-the-generated-tree shape adr-070 names.

So the contract was extended first (balance / balance_outro / closing, additive — every
existing case validates unchanged), the prose was rescued into it, and only then was
gen-expediente.nu written. The glossary widget is now injected from its single source
instead of retyped. `just expedientes-check` asserts every .md body reproduces from its .ncl:
edit the source, never the render.

The generator dropped the hero and the "show full expediente" CTA on its first run — half an
hour after the ADR that forbids exactly that. It was caught only because the output was
diffed against the previous render instead of trusted because it printed a green tick. Both
are emitted from data now, and the check is why we know.

New case, clinical skin: 69/58 — "the mirror that reverted its own work". The tool reported
"index.json with 69 posts" while the disk held 58; eleven ADRs (059..069) were written and
never published; zero content reached production for four weeks; 214 lines of template work
sat one command from deletion with no witness anywhere. Every number in its ledger traces to
a log or an artifact, per the mode's own rule — none is recalled.

Its verdict is the one the series exists to ask and rarely gets: "with ontoref, would it not
have gone like this?" — and here the answer is NO. It happened INSIDE ontoref, with ADR-066
("the check decides, never the reporter") accepted a week earlier and proven by an eight-path
falsación run. The protocol did not fail. Difusión was the blind spot: the one surface the
protocol did not govern. Every mechanism that would have caught every one of these failures
already existed and was already accepted in this very repo. None of them was pointed here.

Also: adr-070's pages (the ADR projection now runs in the build chain).
2026-07-12 12:11:42 +01:00

386 lines
21 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.
rsync -a --delete \
--exclude about.json --exclude adr-map.json \
--exclude taglines.json --exclude content_graph.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 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"
# 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.
[no-cd]
expedientes-check:
nu "{{ project_root }}/scripts/build/gen-expediente.nu" --root "{{ project_root }}" --check
# 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 ───────────────────────────────────────────────────────────────
# Safe to re-run; overwrites htmx-templates/ from scratch — which is exactly why the
# site's own templates cannot live in the output. The assembler has two layers
# (framework defaults, then source-project templates) but the level chain has three:
# rustelo → website-htmx-rustelo → outreach/site. site/templates-overlay/ is the
# missing third slot: the only declared home for template work owned by THIS level.
# Anything edited straight into htmx-templates/ is destroyed on the next run — that is
# how the nav submenus were lost once already.
# Assemble htmx-templates: framework defaults → source-project templates → site overlay.
[no-cd]
templates:
nu "{{ source_project }}/scripts/build/assemble-htmx-templates.nu" \
--project-templates "{{ source_project }}/crates/pages_htmx/templates" \
--out "{{ project_root }}/htmx-templates"
cp -R "{{ project_root }}/site/templates-overlay/." "{{ project_root }}/htmx-templates/"
# Proves `just templates` is lossless: reassemble into a temp tree and diff it against
# the live one. Any difference means htmx-templates/ holds work that exists nowhere
# else and the next `just templates` (or `just sync`, which calls it) will delete it —
# the fix is to move that work into site/templates-overlay/, never to skip the run.
# Gate: assert htmx-templates/ is exactly reproducible from its declared sources.
[no-cd]
templates-check:
#!/usr/bin/env bash
set -e
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
nu "{{ source_project }}/scripts/build/assemble-htmx-templates.nu" \
--project-templates "{{ source_project }}/crates/pages_htmx/templates" \
--out "$tmp" >/dev/null
cp -R "{{ project_root }}/site/templates-overlay/." "$tmp/"
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 its sources:"
diff -rq "{{ project_root }}/htmx-templates" "$tmp" | sed "s|$tmp|<regenerated>|g"
exit 1
fi
# Safe to re-run; overwrites htmx-assets/ from scratch.
# Materialize the framework htmx runtime (htmx.min.js + ext/*) into a top-level
# htmx-assets/ tree (sibling of htmx-templates/). Shipped to the PV by content.nu
# (distro.ncl deliverable) and served at /assets/htmx/* via HTMX_ASSETS_PATH — the
# runtime lives OUTSIDE site/public (router mounts /assets/htmx as its own ServeDir,
# not from the public tree). Dev masks this via the local-install wrapper's
# HTMX_ASSETS_PATH; prod has no such mount. Source of truth: templates/shared/htmx.
[no-cd]
htmx-assets:
rm -rf "{{ project_root }}/htmx-assets"
mkdir -p "{{ project_root }}/htmx-assets/ext"
cp -f "{{ rustelo_root }}/templates/shared/htmx/htmx.min.js" "{{ project_root }}/htmx-assets/htmx.min.js"
cp -R "{{ rustelo_root }}/templates/shared/htmx/ext/." "{{ project_root }}/htmx-assets/ext/"
# ── 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 }}"
# ── 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.
# 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 content graph adr-map about-json sync-artifacts
# 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