Preserve child head content in HTMX embeds

This commit is contained in:
Your Name 2026-08-06 17:41:16 -04:00
parent c8e50cb629
commit cda04f48ed

View file

@ -416,15 +416,32 @@ function decoratePage(html, { headInjection, source } = {}) {
return $.html(); return $.html();
} }
// HTMX partial: stamp provenance on the swapped-in elements (an oob swap loses // Carry rendered head content through HTMX with a neutral OOB wrapper. A
// its own attrs, so we descend into its children) and append an out-of-band // literal <head> in an HTMX response is discarded by the browser's fragment
// <head> so head injection still reaches the page. // parser, and HTMX does not discover a top-level <style hx-swap-oob>. The div
// survives long enough for HTMX to append its children to the host's real
// #head; the browser then keeps the valid style/link/meta children there.
function headOobSwaps(headHtml, headInjection) {
const $ = cheerio.load(
`<head>${headHtml || ""}${headInjection || ""}</head>`,
);
const contents = $("head").html();
return contents ? `<div hx-swap-oob="beforeend:#head">${contents}</div>` : "";
}
// HTMX partial: stamp provenance on the swapped-in body elements (an OOB swap
// loses its own attrs, so we descend into its children), then carry the child
// structure's rendered head and configured head injection into the host page.
function decorateFragment(html, { headInjection, source } = {}) { function decorateFragment(html, { headInjection, source } = {}) {
const $ = cheerio.load(html, null, false); // Parse as a document so browser-valid top-level styles and links are
// collected into head. `cheerio.load(..., null, false)` leaves those nodes as
// fragment siblings, which makes them get swapped into body and not apply.
const $ = cheerio.load(html);
const body = $("body");
const attrs = blissAttrs(source); const attrs = blissAttrs(source);
if (attrs) { if (attrs) {
const targets = []; const targets = [];
for (const child of $.root().children()) { for (const child of body.children()) {
if (child.attribs && child.attribs["hx-swap-oob"]) { if (child.attribs && child.attribs["hx-swap-oob"]) {
for (const grandchild of child.children) { for (const grandchild of child.children) {
if (grandchild.attribs) targets.push(grandchild); if (grandchild.attribs) targets.push(grandchild);
@ -435,10 +452,7 @@ function decorateFragment(html, { headInjection, source } = {}) {
} }
for (const target of targets) Object.assign(target.attribs, attrs); for (const target of targets) Object.assign(target.attribs, attrs);
} }
return ( return body.html() + headOobSwaps($("head").html(), headInjection);
$.html() +
`<head id="head" hx-oob-swap="beforeend">${headInjection || ""}</head>`
);
} }
// `res.render()` commonly returns a complete document even when it is serving // `res.render()` commonly returns a complete document even when it is serving