// Head / metadata management — `title` / `titleTemplate` / `meta` / `link` /
// `htmlAttr` / `bodyAttr`.
//
// A head primitive is a reactive binding whose `commit` target is a node in
// `document.head` (or an attribute on ``/`
`) rather than an inline
// element. It rides the component's one chunked-mask reconciler via
// `registerBinding` (so a reactive value re-fires only when its dep chunks go
// dirty, and a `batch()` burst coalesces into one commit), returns an inline
// placeholder comment (the `portal`/`onMount` pattern), and registers a teardown.
//
// Coordination + dedup live in a `HeadSink`:
// - `domHeadSink` (client) writes to a live `document.head`, adopting any
// server-rendered element marked `data-llui-head` so hydration neither
// duplicates nor flashes.
// - `collectHeadSink` (server) accumulates entries and serializes to the head /
// html-attrs / body-attrs strings the SSR adapter stitches into the document.
// Both keep a per-key LAST-WRITER-WINS stack: a nested page's `title`/`meta`
// overrides its layout's, and on unmount the next-most-recent live writer's
// CURRENT value is restored (writers keep their value live even while shadowed).
//
// Resolution: a sink seeded via the `HEAD_SINK` context wins (SSR collector, or an
// explicit provider); otherwise the client falls back to one sink PER DOCUMENT
// (the resource being coordinated is the single shared `document.head`), never a
// cross-document module global.
import {
currentDoc,
mountable,
onTeardown,
registerBinding,
__nextHeadAnon,
type Mountable,
type SignalDoc,
} from './build-context.js'
import { applyAttr } from './element.js'
import { createContext, useContext, type Context } from './context.js'
import { isSignalHandle } from './handle.js'
import { serializeNodes, escapeAttr } from './ssr.js'
import type { Signal } from './types.js'
// ── Public value type ───────────────────────────────────────────────
/** A head value: a plain value (committed once) or a `Signal` (committed on
* mount and on every change). Mirrors how `foreign` accepts handles-or-values. */
export type HeadValue = T | Signal
const EMPTY_DEPS: readonly string[] = []
function toReactive(v: HeadValue): {
produce: (state: unknown) => unknown
deps: readonly string[]
} {
if (isSignalHandle(v)) return { produce: v.produce, deps: v.deps }
return { produce: () => v, deps: EMPTY_DEPS }
}
/** The static string of a head value, or `undefined` if it is reactive (a handle
* has no compile-time value — such entries can't contribute to a dedup key). */
function staticStr(v: unknown): string | undefined {
if (isSignalHandle(v)) return undefined
return v == null ? undefined : String(v)
}
// ── HeadSink contract ───────────────────────────────────────────────
/** Where a head entry lands: a `` element, an attribute on ``/``,
* or the (template-composed) ``. */
export type HeadTarget =
| { kind: 'element'; tag: string }
| { kind: 'attr'; on: 'html' | 'body'; name: string }
| { kind: 'title' }
| { kind: 'titleTemplate' }
/** A single registered writer's handle: the binding `set`s its value(s) here;
* teardown `release`s it. */
export interface HeadController {
set(attrs: Record, text?: string): void
release(): void
}
/** The coordinator a head primitive commits through. One per app document (client)
* or one per render (server collector). */
export interface HeadSink {
register(key: string, target: HeadTarget): HeadController
}
/** Context carrying the active sink. Default `null` → the client per-document
* fallback; SSR / explicit coordination seed a sink here. */
export const HEAD_SINK: Context = createContext(null, 'head-sink')
interface Writer {
attrs: Record
text?: string
}
const topOf = (a: readonly T[]): T | undefined => a[a.length - 1]
function composeTitle(title: string | undefined, template: string | undefined): string | undefined {
// A template only applies when a title is set (React-Helmet semantics): no
// title → the is unmanaged regardless of any template.
if (title === undefined) return undefined
return template === undefined ? title : template.replace('%s', title)
}
// ── domHeadSink: live document.head ─────────────────────────────────
const NOOP_CONTROLLER: HeadController = { set() {}, release() {} }
/** Build a sink that writes to `doc`'s live `` / `` / ``. Returns
* an inert sink when the document has no `` (server `DomEnv` — those go
* through the collector instead). */
export function domHeadSink(doc: SignalDoc): HeadSink {
const head = doc.head
if (!head) return { register: () => NOOP_CONTROLLER }
const htmlEl = doc.documentElement ?? null
const bodyEl = doc.body ?? null
interface ElRec {
el: Element
// `true` only when we adopted a genuinely FOREIGN pre-existing element (an
// unmarked static `` in the HTML template): it is not ours, so on
// clear we RESTORE its original text/attrs rather than remove it. An
// LLui-owned element — client-created, OR an SSR element we emitted and
// re-adopted on hydration via its `data-llui-head` marker — is `false`: once
// no writer claims it (the last `show`/`branch`/component arm that placed it
// unmounts), it is stale and must be REMOVED from ``. Restoring it
// instead would leak the server-rendered tag onto the next client route.
foreign: boolean
baseText: string | null
baseAttrs: Map
managed: Set
}
const elements = new Map()
const elementWriters = new Map()
const attrWriters = new Map()
const attrBases = new Map()
const titleWriters: Writer[] = []
const templateWriters: Writer[] = []
const targetEl = (on: 'html' | 'body'): Element | null => (on === 'html' ? htmlEl : bodyEl)
function findMarked(key: string): Element | null {
const kids = head!.children
for (let i = 0; i < kids.length; i++) {
if (kids[i]!.getAttribute('data-llui-head') === key) return kids[i]!
}
return null
}
function ensureEl(key: string, tag: string): ElRec {
const existing = elements.get(key)
if (existing) return existing
const marked = findMarked(key)
// A marked element is LLui's own SSR output (re-adopted, not foreign). The
// `` fallback can adopt a genuinely foreign static title to override.
let el = marked
if (!el && tag === 'title') el = head!.querySelector('title')
let rec: ElRec
if (el) {
const baseAttrs = new Map()
const attrs = el.attributes
for (let i = 0; i < attrs.length; i++) baseAttrs.set(attrs[i]!.name, attrs[i]!.value)
rec = {
el,
foreign: marked === null,
baseText: el.textContent,
baseAttrs,
managed: new Set(),
}
} else {
el = doc.createElement(tag)
el.setAttribute('data-llui-head', key)
head!.appendChild(el)
rec = { el, foreign: false, baseText: null, baseAttrs: new Map(), managed: new Set() }
}
elements.set(key, rec)
return rec
}
function writeEl(rec: ElRec, attrs: Record, text: string | undefined): void {
// Drop managed attrs no longer present in the active writer (restore the
// adopted base, else remove); then apply the active writer's attrs.
for (const name of rec.managed) {
if (!(name in attrs)) {
if (rec.baseAttrs.has(name)) applyAttr(rec.el, name, rec.baseAttrs.get(name))
else applyAttr(rec.el, name, null)
}
}
const managed = new Set()
for (const [name, val] of Object.entries(attrs)) {
if (name === 'data-llui-head') continue
applyAttr(rec.el, name, val)
managed.add(name)
}
rec.managed = managed
if (text !== undefined) rec.el.textContent = text
}
function clearEl(key: string): void {
const rec = elements.get(key)
if (!rec) return
if (rec.foreign) {
for (const name of rec.managed) if (!rec.baseAttrs.has(name)) applyAttr(rec.el, name, null)
for (const [name, val] of rec.baseAttrs) applyAttr(rec.el, name, val)
rec.el.textContent = rec.baseText ?? ''
} else {
rec.el.parentNode?.removeChild(rec.el)
}
elements.delete(key)
}
function renderTitle(): void {
const text = composeTitle(topOf(titleWriters)?.text, topOf(templateWriters)?.text)
if (text === undefined) clearEl('title')
else writeEl(ensureEl('title', 'title'), {}, text)
}
function makeController(stack: Writer[], onTop: () => void, onEmpty: () => void): HeadController {
const writer: Writer = { attrs: {} }
stack.push(writer)
return {
set(attrs, text) {
Object.assign(writer.attrs, attrs)
if (text !== undefined) writer.text = text
if (topOf(stack) === writer) onTop()
},
release() {
const i = stack.indexOf(writer)
if (i === -1) return
const wasTop = i === stack.length - 1
stack.splice(i, 1)
if (wasTop) (stack.length ? onTop : onEmpty)()
},
}
}
return {
register(key, target) {
switch (target.kind) {
case 'title':
return makeController(titleWriters, renderTitle, renderTitle)
case 'titleTemplate':
return makeController(templateWriters, renderTitle, renderTitle)
case 'element': {
let stack = elementWriters.get(key)
if (!stack) elementWriters.set(key, (stack = []))
const apply = (): void => {
const top = topOf(stack!)!
writeEl(ensureEl(key, target.tag), top.attrs, top.text)
}
return makeController(stack, apply, () => clearEl(key))
}
case 'attr': {
let stack = attrWriters.get(key)
if (!stack) attrWriters.set(key, (stack = []))
if (stack.length === 0) {
const el = targetEl(target.on)
attrBases.set(key, el ? el.getAttribute(target.name) : null)
}
const apply = (): void => {
const el = targetEl(target.on)
if (el) applyAttr(el, target.name, topOf(stack!)!.attrs[target.name])
}
const clear = (): void => {
const el = targetEl(target.on)
if (el) applyAttr(el, target.name, attrBases.get(key) ?? null)
attrBases.delete(key)
}
return makeController(stack, apply, clear)
}
}
},
}
}
// ── collectHeadSink: SSR string collection ──────────────────────────
/** The serialized output of a server render's head collection. */
export interface CollectedHead {
/** `` element markup (title/meta/link/…), each marked `data-llui-head`. */
head: string
/** Attribute string for `` (leading space included), already escaped. */
htmlAttrs: string
/** Attribute string for `` (leading space included), already escaped. */
bodyAttrs: string
/** Dedup keys present in `head` (e.g. `title`, `meta:name=description`). Used by
* {@link mergeStaticHead} to strip colliding tags from a static `+Head.ts`. */
keys: readonly string[]
}
/** A server-side {@link HeadSink} that collects entries and serializes them. Seed
* it via {@link HEAD_SINK} before rendering, then call `serialize(env)`. */
export interface CollectHeadSink extends HeadSink {
serialize(doc: SignalDoc): CollectedHead
}
export function collectHeadSink(): CollectHeadSink {
interface Slot {
target: HeadTarget
writers: Writer[]
}
const slots = new Map()
const titleWriters: Writer[] = []
const templateWriters: Writer[] = []
function makeController(writers: Writer[]): HeadController {
const writer: Writer = { attrs: {} }
writers.push(writer)
return {
set(attrs, text) {
Object.assign(writer.attrs, attrs)
if (text !== undefined) writer.text = text
},
release() {
const i = writers.indexOf(writer)
if (i !== -1) writers.splice(i, 1)
},
}
}
return {
register(key, target) {
if (target.kind === 'title') return makeController(titleWriters)
if (target.kind === 'titleTemplate') return makeController(templateWriters)
let slot = slots.get(key)
if (!slot) slots.set(key, (slot = { target, writers: [] }))
return makeController(slot.writers)
},
serialize(doc) {
let head = ''
let htmlAttrs = ''
let bodyAttrs = ''
const keys: string[] = []
const titleText = composeTitle(topOf(titleWriters)?.text, topOf(templateWriters)?.text)
if (titleText !== undefined) {
const el = doc.createElement('title')
el.setAttribute('data-llui-head', 'title')
el.textContent = titleText
head += serializeNodes([el])
keys.push('title')
}
for (const [key, slot] of slots) {
const top = topOf(slot.writers)
if (!top) continue
if (slot.target.kind === 'element') {
const el = doc.createElement(slot.target.tag)
el.setAttribute('data-llui-head', key)
for (const [name, val] of Object.entries(top.attrs)) {
if (name !== 'data-llui-head') applyAttr(el, name, val)
}
if (top.text !== undefined) el.textContent = top.text
head += serializeNodes([el])
keys.push(key)
} else if (slot.target.kind === 'attr') {
const val = top.attrs[slot.target.name]
if (val == null || val === false) continue
const s = ` ${slot.target.name}="${escapeAttr(val === true ? '' : String(val))}"`
if (slot.target.on === 'html') htmlAttrs += s
else bodyAttrs += s
}
}
return { head, htmlAttrs, bodyAttrs, keys }
},
}
}
// ── Static head merge (SSR adapter) ─────────────────────────────────
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** Merge a static `+Head.ts` head string with collected component head, letting
* component entries WIN: any `` / `` in `staticHead`
* whose key the component also set is stripped, so the document never carries two
* ``s (the browser would silently use the first). Returns
* `strippedStatic + collected.head`. */
export function mergeStaticHead(staticHead: string, collected: CollectedHead): string {
let s = staticHead
for (const key of collected.keys) {
if (key === 'title') {
s = s.replace(/]*>[\s\S]*?<\/title>/i, '')
} else if (key.startsWith('meta:name=') || key.startsWith('meta:property=')) {
const attr = key.startsWith('meta:name=') ? 'name' : 'property'
const val = key.slice(attr.length + 6) // 'meta:' + attr + '='
const re = new RegExp(`]*\\b${attr}=["']${escapeRegExp(val)}["'][^>]*?/?>`, 'i')
s = s.replace(re, '')
}
}
return s + collected.head
}
// ── Sink resolution ─────────────────────────────────────────────────
const fallbackSinks = new WeakMap