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).
301 lines
9 KiB
JavaScript
301 lines
9 KiB
JavaScript
/*
|
|
Server Sent Events Extension
|
|
============================
|
|
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
|
|
|
*/
|
|
|
|
(function() {
|
|
/** @type {import("../htmx").HtmxInternalApi} */
|
|
var api
|
|
|
|
htmx.defineExtension('sse', {
|
|
|
|
/**
|
|
* Init saves the provided reference to the internal HTMX API.
|
|
*
|
|
* @param {import("../htmx").HtmxInternalApi} api
|
|
* @returns void
|
|
*/
|
|
init: function(apiRef) {
|
|
// store a reference to the internal API.
|
|
api = apiRef
|
|
|
|
// set a function in the public API for creating new EventSource objects
|
|
if (htmx.createEventSource == undefined) {
|
|
htmx.createEventSource = createEventSource
|
|
}
|
|
},
|
|
|
|
/**
|
|
* onEvent handles all events passed to this extension.
|
|
*
|
|
* @param {string} name
|
|
* @param {Event} evt
|
|
* @returns void
|
|
*/
|
|
onEvent: function(name, evt) {
|
|
var parent = evt.target || evt.detail.elt
|
|
switch (name) {
|
|
case 'htmx:beforeCleanupElement':
|
|
var internalData = api.getInternalData(parent)
|
|
// Try to remove remove an EventSource when elements are removed
|
|
if (internalData.sseEventSource) {
|
|
internalData.sseEventSource.close()
|
|
}
|
|
|
|
return
|
|
|
|
// Try to create EventSources when elements are processed
|
|
case 'htmx:afterProcessNode':
|
|
ensureEventSourceOnElement(parent)
|
|
}
|
|
}
|
|
})
|
|
|
|
/// ////////////////////////////////////////////
|
|
// HELPER FUNCTIONS
|
|
/// ////////////////////////////////////////////
|
|
|
|
/**
|
|
* createEventSource is the default method for creating new EventSource objects.
|
|
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
|
*
|
|
* @param {string} url
|
|
* @returns EventSource
|
|
*/
|
|
function createEventSource(url) {
|
|
return new EventSource(url, { withCredentials: true })
|
|
}
|
|
|
|
/**
|
|
* registerSSE looks for attributes that can contain sse events, right
|
|
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
|
* the closest event source
|
|
*
|
|
* @param {HTMLElement} elt
|
|
*/
|
|
function registerSSE(elt) {
|
|
// Add message handlers for every `sse-swap` attribute
|
|
queryAttributeOnThisOrChildren(elt, 'sse-swap').forEach(function(child) {
|
|
// Find closest existing event source
|
|
var sourceElement = api.getClosestMatch(child, hasEventSource)
|
|
if (sourceElement == null) {
|
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
|
return null // no eventsource in parentage, orphaned element
|
|
}
|
|
|
|
// Set internalData and source
|
|
var internalData = api.getInternalData(sourceElement)
|
|
var source = internalData.sseEventSource
|
|
|
|
var sseSwapAttr = api.getAttributeValue(child, 'sse-swap')
|
|
var sseEventNames = sseSwapAttr.split(',')
|
|
|
|
for (var i = 0; i < sseEventNames.length; i++) {
|
|
var sseEventName = sseEventNames[i].trim()
|
|
var listener = function(event) {
|
|
// If the source is missing then close SSE
|
|
if (maybeCloseSSESource(sourceElement)) {
|
|
return
|
|
}
|
|
|
|
// If the body no longer contains the element, remove the listener
|
|
if (!api.bodyContains(child)) {
|
|
source.removeEventListener(sseEventName, listener)
|
|
return
|
|
}
|
|
|
|
// swap the response into the DOM and trigger a notification
|
|
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
|
return
|
|
}
|
|
swap(child, event.data)
|
|
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
|
}
|
|
|
|
// Register the new listener
|
|
api.getInternalData(child).sseEventListener = listener
|
|
source.addEventListener(sseEventName, listener)
|
|
}
|
|
})
|
|
|
|
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
|
queryAttributeOnThisOrChildren(elt, 'hx-trigger').forEach(function(child) {
|
|
// Find closest existing event source
|
|
var sourceElement = api.getClosestMatch(child, hasEventSource)
|
|
if (sourceElement == null) {
|
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
|
return null // no eventsource in parentage, orphaned element
|
|
}
|
|
|
|
// Set internalData and source
|
|
var internalData = api.getInternalData(sourceElement)
|
|
var source = internalData.sseEventSource
|
|
|
|
var sseEventName = api.getAttributeValue(child, 'hx-trigger')
|
|
if (sseEventName == null) {
|
|
return
|
|
}
|
|
|
|
// Only process hx-triggers for events with the "sse:" prefix
|
|
if (sseEventName.slice(0, 4) != 'sse:') {
|
|
return
|
|
}
|
|
|
|
var listener = function(event) {
|
|
if (maybeCloseSSESource(sourceElement)) {
|
|
return
|
|
}
|
|
|
|
if (!api.bodyContains(child)) {
|
|
source.removeEventListener(sseEventName, listener)
|
|
}
|
|
|
|
// Trigger events to be handled by the rest of htmx
|
|
htmx.trigger(child, sseEventName, event)
|
|
htmx.trigger(child, 'htmx:sseMessage', event)
|
|
}
|
|
|
|
// Register the new listener
|
|
api.getInternalData(elt).sseEventListener = listener
|
|
source.addEventListener(sseEventName.slice(4), listener)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
|
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
|
* is created and stored in the element's internalData.
|
|
* @param {HTMLElement} elt
|
|
* @param {number} retryCount
|
|
* @returns {EventSource | null}
|
|
*/
|
|
function ensureEventSourceOnElement(elt, retryCount) {
|
|
if (elt == null) {
|
|
return null
|
|
}
|
|
|
|
// handle extension source creation attribute
|
|
queryAttributeOnThisOrChildren(elt, 'sse-connect').forEach(function(child) {
|
|
var sseURL = api.getAttributeValue(child, 'sse-connect')
|
|
if (sseURL == null) {
|
|
return
|
|
}
|
|
|
|
ensureEventSource(child, sseURL, retryCount)
|
|
})
|
|
|
|
registerSSE(elt)
|
|
}
|
|
|
|
function ensureEventSource(elt, url, retryCount) {
|
|
var source = htmx.createEventSource(url)
|
|
|
|
source.onerror = function(err) {
|
|
// Log an error event
|
|
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
|
|
|
// If parent no longer exists in the document, then clean up this EventSource
|
|
if (maybeCloseSSESource(elt)) {
|
|
return
|
|
}
|
|
|
|
// Otherwise, try to reconnect the EventSource
|
|
if (source.readyState === EventSource.CLOSED) {
|
|
retryCount = retryCount || 0
|
|
var timeout = Math.random() * (2 ^ retryCount) * 500
|
|
window.setTimeout(function() {
|
|
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1))
|
|
}, timeout)
|
|
}
|
|
}
|
|
|
|
source.onopen = function(evt) {
|
|
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
|
}
|
|
|
|
api.getInternalData(elt).sseEventSource = source
|
|
}
|
|
|
|
/**
|
|
* maybeCloseSSESource confirms that the parent element still exists.
|
|
* If not, then any associated SSE source is closed and the function returns true.
|
|
*
|
|
* @param {HTMLElement} elt
|
|
* @returns boolean
|
|
*/
|
|
function maybeCloseSSESource(elt) {
|
|
if (!api.bodyContains(elt)) {
|
|
var source = api.getInternalData(elt).sseEventSource
|
|
if (source != undefined) {
|
|
source.close()
|
|
// source = null
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
|
*
|
|
* @param {HTMLElement} elt
|
|
* @param {string} attributeName
|
|
*/
|
|
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
|
var result = []
|
|
|
|
// If the parent element also contains the requested attribute, then add it to the results too.
|
|
if (api.hasAttribute(elt, attributeName)) {
|
|
result.push(elt)
|
|
}
|
|
|
|
// Search all child nodes that match the requested attribute
|
|
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + ']').forEach(function(node) {
|
|
result.push(node)
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} elt
|
|
* @param {string} content
|
|
*/
|
|
function swap(elt, content) {
|
|
api.withExtensions(elt, function(extension) {
|
|
content = extension.transformResponse(content, null, elt)
|
|
})
|
|
|
|
var swapSpec = api.getSwapSpecification(elt)
|
|
var target = api.getTarget(elt)
|
|
api.swap(target, content, swapSpec)
|
|
}
|
|
|
|
/**
|
|
* doSettle mirrors much of the functionality in htmx that
|
|
* settles elements after their content has been swapped.
|
|
* TODO: this should be published by htmx, and not duplicated here
|
|
* @param {import("../htmx").HtmxSettleInfo} settleInfo
|
|
* @returns () => void
|
|
*/
|
|
function doSettle(settleInfo) {
|
|
return function() {
|
|
settleInfo.tasks.forEach(function(task) {
|
|
task.call()
|
|
})
|
|
|
|
settleInfo.elts.forEach(function(elt) {
|
|
if (elt.classList) {
|
|
elt.classList.remove(htmx.config.settlingClass)
|
|
}
|
|
api.triggerEvent(elt, 'htmx:afterSettle')
|
|
})
|
|
}
|
|
}
|
|
|
|
function hasEventSource(node) {
|
|
return api.getInternalData(node).sseEventSource != null
|
|
}
|
|
})()
|