/** * Islands: named interactive regions inside server-rendered pages. The server marks a host - * *
* 2 * *
* * - and the client registers behavior by name. `state(key, fallback)` returns a signal seeded * from the host's `data-island-state` JSON: the server's loader data becomes client signals with * zero extra serialization ceremony (the attribute IS the wire format; `@nifrajs/web-vanilla`'s * escaping makes it safe to emit). */ import { type IslandStrategy, MAX_MEDIA_QUERY_LENGTH, scheduleTrigger, } from "@nifrajs/island-trigger" import { type BindableElement, type BindableRoot, bindScope, type IslandScope } from "./bind.ts" import type { Signal } from "./signals.ts" import { signal } from "./signals.ts" export { type IslandStrategy, MAX_MEDIA_QUERY_LENGTH } export interface IslandContext { /** The island's host element. */ readonly root: BindableElement & BindableRoot /** * A named signal, seeded from `data-island-state`'s value for `key` when present, else * `fallback`. Each key returns the SAME signal across calls - bindings and setup share state. */ state(key: string, fallback: T): Signal } /** An island's setup function: read/seed state, return the event handlers the markup names. */ export type IslandSetup = (ctx: IslandContext) => Record void> | undefined const registry = new Map() /** Register an island's behavior by name (the markup's `data-island` value). */ export function island(name: string, setup: IslandSetup): void { registry.set(name, setup) } /** The host-element surface `mountIslands` needs beyond bindings. */ export interface IslandHost extends BindableElement, BindableRoot { setAttribute(name: string, value: string): void } export interface MountIslandOptions { readonly root?: BindableRoot } /** * Mount every registered island under `root` (default: the document). Idempotent - a host is * marked once mounted, so calling again (e.g. after a soft navigation swapped content in) only * mounts new hosts. Unregistered island names are skipped silently: markup may ship ahead of * its script, and progressive enhancement means the static content is already correct. */ export function mountIslands( rootOrOptions: BindableRoot | MountIslandOptions = document as unknown as BindableRoot, ): () => void { const root = "querySelectorAll" in rootOrOptions ? rootOrOptions : (rootOrOptions.root ?? (document as unknown as BindableRoot)) const disposers: Array<() => void> = [] for (const host of root.querySelectorAll("[data-island]") as Iterable) { if (host.getAttribute("data-island-mounted") !== null) continue const name = host.getAttribute("data-island") ?? "" const setup = registry.get(name) if (setup === undefined) continue host.setAttribute("data-island-mounted", "") const strategyAttr = host.getAttribute("data-island-strategy") const media = host.getAttribute("data-island-media") const strategy: IslandStrategy | undefined = strategyAttr === "idle" || strategyAttr === "visible" || strategyAttr === "load" ? strategyAttr : strategyAttr === "media" && media !== null && media.length > 0 && media.length <= MAX_MEDIA_QUERY_LENGTH ? { media } : strategyAttr === null ? "load" : undefined if (strategy === undefined) continue // Seed state from the host's JSON attribute. Malformed JSON is a server bug - fail loud in // the console, mount with fallbacks only (the static markup stays usable). let seeded: Record = {} const rawState = host.getAttribute("data-island-state") if (rawState !== null && rawState !== "") { try { const parsed: unknown = JSON.parse(rawState) if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { seeded = parsed as Record } else { console.error(`[nifra/islets] data-island-state on "${name}" must be a JSON object`) } } catch { console.error(`[nifra/islets] malformed data-island-state JSON on island "${name}"`) } } const run = (): void => { const signals: Record> = {} const ctx: IslandContext = { root: host, state(key: string, fallback: T): Signal { const existing = signals[key] if (existing !== undefined) return existing as Signal const initial = Object.hasOwn(seeded, key) ? (seeded[key] as T) : fallback const s = signal(initial) signals[key] = s as Signal return s }, } const handlers = setup(ctx) ?? {} const scope: IslandScope = { signals, handlers } disposers.push(...bindScope(host, scope)) } disposers.push( scheduleTrigger(strategy, run, { target: host as unknown as Element, }), ) } return () => { for (const dispose of disposers) dispose() } } /** * Server-side helper: the value for a host's `data-island-state` attribute. Plain JSON - emit it * through an escaping renderer (`@nifrajs/web-vanilla`'s `html` escapes quotes in attributes), e.g. * `html\`
…\``. */ export function islandState(state: Record): string { return JSON.stringify(state) }