ontoref-outreach/site/htmx-assets/ext/head-support.js
Jesús Pérez 72a389f056 site: materialize the outreach repo's site projection, with witnesses
First real commit of site/. Until now outreach/.git tracked exactly one file
(README.md) and every content pack stamped git_sha=5619a40 regardless of what it
shipped — a signature with no provenance behind it. The site had no witness, which
is why a deploy could erase two sessions of work with nothing to diff against and
nothing to restore from.

Tracked here (authored): site/content, site/config, site/i18n, site/public/images,
justfile, scripts/, .just/, run*.sh, rustelo.manifest.toml.

Ignored (reproducible, or secret):
  .k, .env                    real keys — outreach mirrors to a PUBLIC remote
  site/r, site/public/r       content indexes, rebuilt by `just content`
  provisioning/.content-packs 228 MB of deploy tarballs
  cache, data, logs_*.out     runtime droppings

Three trees are deliberately NOT ignored despite looking generated, because their
declared source cannot currently reproduce them — ignoring them would delete the
only copy:
  site/public/images          196 of 209 images exist nowhere else (assets/site is empty)
  site/content/{adr,catalog}  projections whose generators are unwired (gen-adr-pages)
  site/content/domains        or failing (`just graph`)
They become ignorable when an audit proves regeneration is a no-op, not before.

Fixes carried in this import
---------------------------
content: the mirror ran one way and reverted its own work. content_processor writes
site/r, but the recipe ended with `rsync -a site/public/r/ -> site/r/`, so each fresh
index was clobbered by the stale deploy-tree copy: the tool reported "index.json with
69 posts" while the disk kept 58. Meanwhile public/r is the tree the deploy pack ships,
so no new content could ever reach production. The two trees own different files, so
the mirror now runs both ways: content indexes r -> public/r (with --delete, or a
renamed slug survives as a live URL), and the four nu artifacts (about, adr-map,
taglines, content_graph) back public/r -> r, excluded from the outbound leg so stale
copies cannot overwrite the fresh originals.

templates: the assembler has two layers (framework defaults, source-project templates)
but the level chain has three (rustelo -> website-htmx-rustelo -> outreach/site). The
site level had no overlay slot, so its template work had nowhere legitimate to live and
ended up in htmx-templates/ — the one tree `just templates` rm -rf's. That is how the
nav submenus were lost. site/templates-overlay/ is the missing third slot; 214 lines
(nav submenus, subscribe CTA, post engagement block) now live in a declared source and
survive regeneration byte-identical.

adr: projected ADR-059..069 into the content tree. They were written months ago and
never reached the site because gen-adr-pages.nu is wired into no recipe — a generator
outside the dependency chain is a generator that does not run.

Gates added
-----------
templates-check  reassembles into a temp tree and asserts an empty diff against the
                 live one: any drift means htmx-templates/ holds work that exists
                 live one: any drift means htmx-templates/ holds work that exists
                 nowhere else and the next run deletes it.

Both gates run before the destructive operation, not after. A check you can only run
afterwards is not a gate, it is an autopsy.

Refs: ADR-062 (projection-not-own-repo: site is a Projection of outreach, not its own
repo — different deploy target is not a different visibility boundary), ADR-048
(materialization), ADR-066 (the check decides, never the reporter — which the content
pipeline, the surface that publishes ontoref, was the last place not to honour).
2026-07-11 23:44:00 +01:00

147 lines
No EOL
6.5 KiB
JavaScript

//==========================================================
// head-support.js
//
// An extension to htmx 1.0 to add head tag merging.
//==========================================================
(function(){
var api = null;
function log() {
//console.log(arguments);
}
function mergeHead(newContent, defaultMergeStrategy) {
if (newContent && newContent.indexOf('<head') > -1) {
const htmlDoc = document.createElement("html");
// remove svgs to avoid conflicts
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
// extract head tag
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
// if the head tag exists...
if (headTag) {
var added = []
var removed = []
var preserved = []
var nodesToAppend = []
htmlDoc.innerHTML = headTag;
var newHeadTag = htmlDoc.querySelector("head");
var currentHead = document.head;
if (newHeadTag == null) {
return;
} else {
// put all new head elements into a Map, by their outerHTML
var srcToNewHeadNodes = new Map();
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
}
// determine merge strategy
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
// get the current head
for (const currentHeadElt of currentHead.children) {
// If the current head element is in the map
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (mergeStrategy === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the tremaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
log("to append: ", nodesToAppend);
for (const newNode of nodesToAppend) {
log("adding: ", newNode);
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
log(newElt);
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
currentHead.appendChild(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
currentHead.removeChild(removedElement);
}
}
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
}
}
}
htmx.defineExtension("head-support", {
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef;
htmx.on('htmx:afterSwap', function(evt){
// PATCH: htmx:afterSwap also fires on history restore from
// popstate, where evt.detail.xhr is undefined. The original
// line would TypeError and block subsequent navigations.
// History restores have their own handler below
// (htmx:historyRestore), so we just skip here.
if (!evt.detail || !evt.detail.xhr) return;
var serverResponse = evt.detail.xhr.response;
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
}
})
htmx.on('htmx:historyRestore', function(evt){
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
if (evt.detail.cacheMiss) {
mergeHead(evt.detail.serverResponse, "merge");
} else {
mergeHead(evt.detail.item.head, "merge");
}
}
})
htmx.on('htmx:historyItemCreated', function(evt){
var historyItem = evt.detail.item;
historyItem.head = document.head.outerHTML;
})
}
});
})()