671 lines
37 KiB
Makefile
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
|