@moku-labs/web 1.16.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/browser.d.mts +111 -108
- package/dist/browser.mjs +189 -189
- package/dist/index.cjs +226 -206
- package/dist/index.d.cts +124 -108
- package/dist/index.d.mts +124 -108
- package/dist/index.mjs +226 -206
- package/dist/{render-KdufA3_b.cjs → render-DHUcHCYs.cjs} +4 -4
- package/dist/{render-UO4nimWr.mjs → render-yXHc9BWI.mjs} +4 -4
- package/dist/testing.d.mts +59 -56
- package/dist/testing.mjs +50 -50
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -8026,17 +8026,22 @@ function createApi$1(ctx) {
|
|
|
8026
8026
|
* @example
|
|
8027
8027
|
* await api.update(["src/islands/board.ts"]);
|
|
8028
8028
|
*/
|
|
8029
|
-
update(changes, options = {}) {
|
|
8029
|
+
async update(changes, options = {}) {
|
|
8030
8030
|
const overrides = devBuildOverrides({
|
|
8031
8031
|
og: options.og ?? false,
|
|
8032
8032
|
sitemap: options.sitemap ?? false,
|
|
8033
8033
|
feeds: options.feeds ?? false
|
|
8034
8034
|
});
|
|
8035
|
-
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8035
|
+
ctx.state.render.setDriven(true);
|
|
8036
|
+
try {
|
|
8037
|
+
return await ctx.require(buildPlugin).run({
|
|
8038
|
+
skipClean: true,
|
|
8039
|
+
overrides,
|
|
8040
|
+
changed: changes
|
|
8041
|
+
});
|
|
8042
|
+
} finally {
|
|
8043
|
+
ctx.state.render.setDriven(false);
|
|
8044
|
+
}
|
|
8040
8045
|
},
|
|
8041
8046
|
/**
|
|
8042
8047
|
* Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
|
|
@@ -8329,6 +8334,7 @@ function createPanelRenderer(options = {}) {
|
|
|
8329
8334
|
let idle = false;
|
|
8330
8335
|
let idleStartedAt = 0;
|
|
8331
8336
|
let serveMode = false;
|
|
8337
|
+
let driven = false;
|
|
8332
8338
|
let ticker;
|
|
8333
8339
|
/**
|
|
8334
8340
|
* Render one phase-tree row: a spinning cyan glyph + dim name while running, or a green
|
|
@@ -8490,7 +8496,7 @@ function createPanelRenderer(options = {}) {
|
|
|
8490
8496
|
* render.phase({ phase: "pages", status: "done", durationMs: 12 });
|
|
8491
8497
|
*/
|
|
8492
8498
|
phase(phase) {
|
|
8493
|
-
if (rebuilding) return;
|
|
8499
|
+
if (rebuilding || driven) return;
|
|
8494
8500
|
if (!color) {
|
|
8495
8501
|
if (phase.status === "done") write(` ${palette.green("✓")} ${phase.phase}${durationSuffix(palette, phase.durationMs)}`);
|
|
8496
8502
|
return;
|
|
@@ -8524,7 +8530,7 @@ function createPanelRenderer(options = {}) {
|
|
|
8524
8530
|
* render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
|
|
8525
8531
|
*/
|
|
8526
8532
|
built(summary) {
|
|
8527
|
-
if (rebuilding) return;
|
|
8533
|
+
if (rebuilding || driven) return;
|
|
8528
8534
|
if (color && phaseOpen) {
|
|
8529
8535
|
let frame = cursorUp(phaseDrawn);
|
|
8530
8536
|
for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
|
|
@@ -8698,6 +8704,20 @@ function createPanelRenderer(options = {}) {
|
|
|
8698
8704
|
if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
|
|
8699
8705
|
},
|
|
8700
8706
|
/**
|
|
8707
|
+
* Mark the build TUI as externally driven: when `on`, the per-phase build tree and the BUILD
|
|
8708
|
+
* summary are suppressed so an external dev driver (e.g. the worker's `dev({ onChange })` loop,
|
|
8709
|
+
* which calls `update()` and renders its own concise rebuild line) is the sole source of rebuild
|
|
8710
|
+
* output. Off by default; a standalone `build()` / `serve()` renders the full TUI as before.
|
|
8711
|
+
*
|
|
8712
|
+
* @param on - Whether an external driver owns the dev TUI.
|
|
8713
|
+
* @example
|
|
8714
|
+
* render.setDriven(true); // before an externally-driven update()
|
|
8715
|
+
* render.setDriven(false); // after it settles
|
|
8716
|
+
*/
|
|
8717
|
+
setDriven(on) {
|
|
8718
|
+
driven = on;
|
|
8719
|
+
},
|
|
8720
|
+
/**
|
|
8701
8721
|
* Stop every animation and release the interval timer (serve()'s teardown calls this).
|
|
8702
8722
|
*
|
|
8703
8723
|
* @example
|
|
@@ -9114,15 +9134,15 @@ const cliPlugin = createPlugin$1("cli", {
|
|
|
9114
9134
|
function createApi(ctx) {
|
|
9115
9135
|
return {
|
|
9116
9136
|
/**
|
|
9117
|
-
* Register a
|
|
9137
|
+
* Register a island definition (last-registered-wins); warns on collision.
|
|
9118
9138
|
*
|
|
9119
|
-
* @param
|
|
9139
|
+
* @param island - The island definition created via `createIsland`.
|
|
9120
9140
|
* @example
|
|
9121
9141
|
* app.spa.register(counter);
|
|
9122
9142
|
*/
|
|
9123
|
-
register(
|
|
9124
|
-
if (ctx.state.
|
|
9125
|
-
ctx.state.kernel?.register(
|
|
9143
|
+
register(island) {
|
|
9144
|
+
if (ctx.state.registeredIslands.has(island.name)) ctx.log.warn("spa:island-collision", { name: island.name });
|
|
9145
|
+
ctx.state.kernel?.register(island);
|
|
9126
9146
|
},
|
|
9127
9147
|
/**
|
|
9128
9148
|
* Programmatically navigate to a path (client runtime; no-op without a DOM).
|
|
@@ -9148,13 +9168,13 @@ function createApi(ctx) {
|
|
|
9148
9168
|
* Resolve a registered island's api by name (the cross-island seam). Returns
|
|
9149
9169
|
* `undefined` when no provider with that name is currently registered.
|
|
9150
9170
|
*
|
|
9151
|
-
* @param name - The provider island's
|
|
9171
|
+
* @param name - The provider island's island name.
|
|
9152
9172
|
* @returns The provider's api, or `undefined`.
|
|
9153
9173
|
* @example
|
|
9154
|
-
* app.spa.
|
|
9174
|
+
* app.spa.island("lightbox");
|
|
9155
9175
|
*/
|
|
9156
|
-
|
|
9157
|
-
return ctx.state.
|
|
9176
|
+
island(name) {
|
|
9177
|
+
return ctx.state.islandApis.get(name);
|
|
9158
9178
|
}
|
|
9159
9179
|
};
|
|
9160
9180
|
}
|
|
@@ -9173,15 +9193,89 @@ function spaEvents(register) {
|
|
|
9173
9193
|
return {
|
|
9174
9194
|
"spa:navigate": register("A navigation has been intercepted and is starting."),
|
|
9175
9195
|
"spa:navigated": register("The swap completed and the new URL is active."),
|
|
9176
|
-
"spa:
|
|
9177
|
-
"spa:
|
|
9196
|
+
"spa:island-mount": register("A island instance attached to an element."),
|
|
9197
|
+
"spa:island-unmount": register("A island instance detached from an element.")
|
|
9178
9198
|
};
|
|
9179
9199
|
}
|
|
9180
9200
|
//#endregion
|
|
9201
|
+
//#region src/plugins/spa/head.ts
|
|
9202
|
+
/** Single-element head selectors synced by replace/append/remove on navigation. */
|
|
9203
|
+
const META_SELECTORS = [
|
|
9204
|
+
"meta[name=\"description\"]",
|
|
9205
|
+
"meta[property=\"og:title\"]",
|
|
9206
|
+
"meta[property=\"og:description\"]",
|
|
9207
|
+
"meta[property=\"og:url\"]",
|
|
9208
|
+
"meta[property=\"og:image\"]",
|
|
9209
|
+
"meta[property=\"og:type\"]",
|
|
9210
|
+
"meta[property=\"og:locale\"]",
|
|
9211
|
+
"meta[name=\"twitter:card\"]",
|
|
9212
|
+
"meta[name=\"twitter:title\"]",
|
|
9213
|
+
"meta[name=\"twitter:description\"]",
|
|
9214
|
+
"meta[name=\"twitter:image\"]",
|
|
9215
|
+
"meta[name=\"twitter:site\"]",
|
|
9216
|
+
"link[rel=\"canonical\"]"
|
|
9217
|
+
];
|
|
9218
|
+
/** Head element groups fully replaced (remove-all-then-clone) on navigation. */
|
|
9219
|
+
const REPLACE_ALL_SELECTORS = [
|
|
9220
|
+
"script[type=\"application/ld+json\"]",
|
|
9221
|
+
"link[rel=\"alternate\"][hreflang]",
|
|
9222
|
+
"meta[property^=\"article:\"]"
|
|
9223
|
+
];
|
|
9224
|
+
/**
|
|
9225
|
+
* Sync a single head element by selector between the fetched and live document:
|
|
9226
|
+
* replace when both exist, append when only the new doc has it, remove when only
|
|
9227
|
+
* the live doc has it.
|
|
9228
|
+
*
|
|
9229
|
+
* @param selector - CSS selector for the head element to sync.
|
|
9230
|
+
* @param doc - The fetched document (DOMParser-parsed).
|
|
9231
|
+
* @example
|
|
9232
|
+
* syncElement('link[rel="canonical"]', doc);
|
|
9233
|
+
*/
|
|
9234
|
+
function syncElement(selector, doc) {
|
|
9235
|
+
const newElement = doc.querySelector(selector);
|
|
9236
|
+
const oldElement = document.querySelector(selector);
|
|
9237
|
+
if (newElement && oldElement) oldElement.replaceWith(newElement.cloneNode(true));
|
|
9238
|
+
else if (newElement) document.head.append(newElement.cloneNode(true));
|
|
9239
|
+
else if (oldElement) oldElement.remove();
|
|
9240
|
+
}
|
|
9241
|
+
/**
|
|
9242
|
+
* Remove all live matches for a selector and re-clone the fetched document's
|
|
9243
|
+
* matches into the live `<head>`.
|
|
9244
|
+
*
|
|
9245
|
+
* @param selector - CSS selector for the element group to replace wholesale.
|
|
9246
|
+
* @param doc - The fetched document (DOMParser-parsed).
|
|
9247
|
+
* @example
|
|
9248
|
+
* replaceAllBySelector('script[type="application/ld+json"]', doc);
|
|
9249
|
+
*/
|
|
9250
|
+
function replaceAllBySelector(selector, doc) {
|
|
9251
|
+
for (const element of document.querySelectorAll(selector)) element.remove();
|
|
9252
|
+
for (const element of doc.querySelectorAll(selector)) document.head.append(element.cloneNode(true));
|
|
9253
|
+
}
|
|
9254
|
+
/**
|
|
9255
|
+
* Syncs the live document `<head>` after a navigation from the fetched document
|
|
9256
|
+
* (whose head was composed by the `head` plugin). Recomputes
|
|
9257
|
+
* title/meta/canonical/JSON-LD/hreflang/`<html lang>` once and applies them.
|
|
9258
|
+
* The `head` API is accepted to bind the structural dependency (spec/09 deps).
|
|
9259
|
+
*
|
|
9260
|
+
* @param _head - The head plugin API (dependency binding; composition reused via the fetched doc).
|
|
9261
|
+
* @param doc - The fetched document parsed from the navigated page's HTML.
|
|
9262
|
+
* @example
|
|
9263
|
+
* syncHead(headApi, parsedDoc);
|
|
9264
|
+
*/
|
|
9265
|
+
function syncHead(_head, doc) {
|
|
9266
|
+
if (typeof document === "undefined") return;
|
|
9267
|
+
const newTitle = doc.querySelector("title")?.textContent;
|
|
9268
|
+
if (newTitle) document.title = newTitle;
|
|
9269
|
+
const newLang = doc.documentElement.lang;
|
|
9270
|
+
if (newLang) document.documentElement.lang = newLang;
|
|
9271
|
+
for (const selector of META_SELECTORS) syncElement(selector, doc);
|
|
9272
|
+
for (const selector of REPLACE_ALL_SELECTORS) replaceAllBySelector(selector, doc);
|
|
9273
|
+
}
|
|
9274
|
+
//#endregion
|
|
9181
9275
|
//#region src/plugins/spa/types.ts
|
|
9182
|
-
var types_exports$7 = /* @__PURE__ */ __exportAll({
|
|
9276
|
+
var types_exports$7 = /* @__PURE__ */ __exportAll({ ISLAND_HOOK_NAMES: () => ISLAND_HOOK_NAMES });
|
|
9183
9277
|
/** Allowed hook names — single source of truth for fail-fast validation. */
|
|
9184
|
-
const
|
|
9278
|
+
const ISLAND_HOOK_NAMES = [
|
|
9185
9279
|
"onCreate",
|
|
9186
9280
|
"onMount",
|
|
9187
9281
|
"onNavStart",
|
|
@@ -9190,10 +9284,10 @@ const COMPONENT_HOOK_NAMES = [
|
|
|
9190
9284
|
"onDestroy"
|
|
9191
9285
|
];
|
|
9192
9286
|
//#endregion
|
|
9193
|
-
//#region src/plugins/spa/
|
|
9287
|
+
//#region src/plugins/spa/islands.ts
|
|
9194
9288
|
/**
|
|
9195
|
-
* @file spa plugin —
|
|
9196
|
-
* surface (`
|
|
9289
|
+
* @file spa plugin — island lifecycle, mounting, the plugin-mirror authoring
|
|
9290
|
+
* surface (`createIsland` with a typed `{ state, render, events, api }` spec),
|
|
9197
9291
|
* the per-instance state + microtask-batched render scheduler, declarative
|
|
9198
9292
|
* delegated events, and the cross-island api registry.
|
|
9199
9293
|
* @see README.md
|
|
@@ -9201,8 +9295,8 @@ const COMPONENT_HOOK_NAMES = [
|
|
|
9201
9295
|
/** Error prefix for spa fail-fast failures (spec/11 Part-3). */
|
|
9202
9296
|
const ERROR_PREFIX$2 = "[web]";
|
|
9203
9297
|
/** The set of legal hook names, frozen for O(1) membership checks. */
|
|
9204
|
-
const HOOK_NAME_SET = new Set(
|
|
9205
|
-
/** The spec-only keys that select the plugin-mirror form of {@link
|
|
9298
|
+
const HOOK_NAME_SET = new Set(ISLAND_HOOK_NAMES);
|
|
9299
|
+
/** The spec-only keys that select the plugin-mirror form of {@link createIsland}. */
|
|
9206
9300
|
const SPEC_KEYS = new Set([
|
|
9207
9301
|
"state",
|
|
9208
9302
|
"render",
|
|
@@ -9241,7 +9335,7 @@ let renderChunk;
|
|
|
9241
9335
|
let commitVNodeFunction;
|
|
9242
9336
|
/**
|
|
9243
9337
|
* Load the lazy `./render` chunk (once) and cache its `commitVNode` for synchronous
|
|
9244
|
-
* use by later renders. Awaited by a
|
|
9338
|
+
* use by later renders. Awaited by a island's `mountPromise` so the test harness's
|
|
9245
9339
|
* `settle()` can deterministically flush a VNode render.
|
|
9246
9340
|
*
|
|
9247
9341
|
* @returns A promise that resolves once `commitVNode` is available.
|
|
@@ -9249,7 +9343,7 @@ let commitVNodeFunction;
|
|
|
9249
9343
|
* await loadRenderChunk();
|
|
9250
9344
|
*/
|
|
9251
9345
|
async function loadRenderChunk() {
|
|
9252
|
-
renderChunk ??= import("./render-
|
|
9346
|
+
renderChunk ??= import("./render-yXHc9BWI.mjs");
|
|
9253
9347
|
commitVNodeFunction = (await renderChunk).commitVNode;
|
|
9254
9348
|
}
|
|
9255
9349
|
/**
|
|
@@ -9258,7 +9352,7 @@ async function loadRenderChunk() {
|
|
|
9258
9352
|
* a Preact `VNode` → committed through the lazy gate (loading it on demand if needed).
|
|
9259
9353
|
*
|
|
9260
9354
|
* @param host - The island host element to render into.
|
|
9261
|
-
* @param result - The value returned by the
|
|
9355
|
+
* @param result - The value returned by the island's `render`.
|
|
9262
9356
|
* @example
|
|
9263
9357
|
* commitResult(host, h(View, { items }));
|
|
9264
9358
|
*/
|
|
@@ -9280,7 +9374,7 @@ function commitResult(host, result) {
|
|
|
9280
9374
|
loadRenderChunk().then(() => commitVNodeFunction?.(vnode, host)).catch(() => {});
|
|
9281
9375
|
}
|
|
9282
9376
|
/**
|
|
9283
|
-
* Run a
|
|
9377
|
+
* Run a island's `render(state, ctx)` and commit the result now. Guards against
|
|
9284
9378
|
* synchronous re-entrancy (a render that calls `ctx.flush`) with a depth cap.
|
|
9285
9379
|
*
|
|
9286
9380
|
* @param instance - The instance to render.
|
|
@@ -9291,7 +9385,7 @@ function commitResult(host, result) {
|
|
|
9291
9385
|
function runRender(instance) {
|
|
9292
9386
|
const render = instance.def.spec?.render;
|
|
9293
9387
|
if (!render) return;
|
|
9294
|
-
if (instance.renderDepth > MAX_RENDER_DEPTH) throw new Error(`${ERROR_PREFIX$2}
|
|
9388
|
+
if (instance.renderDepth > MAX_RENDER_DEPTH) throw new Error(`${ERROR_PREFIX$2} island "${instance.def.name}" render re-entered ${MAX_RENDER_DEPTH}+ times\n → a render must not synchronously trigger its own render (avoid ctx.flush() inside render)`);
|
|
9295
9389
|
instance.renderDepth += 1;
|
|
9296
9390
|
try {
|
|
9297
9391
|
commitResult(instance.el, render(instance.state ?? {}, instance.ctx));
|
|
@@ -9317,12 +9411,12 @@ function scheduleRender(instance) {
|
|
|
9317
9411
|
});
|
|
9318
9412
|
}
|
|
9319
9413
|
/**
|
|
9320
|
-
* Build the single per-instance {@link
|
|
9414
|
+
* Build the single per-instance {@link IslandContext} reused by every hook, event
|
|
9321
9415
|
* handler, and render. Route fields (`params`/`meta`/`locale`/`url`) and `data` read
|
|
9322
9416
|
* through the instance so a navigation update is reflected without rebuilding the ctx;
|
|
9323
|
-
* `state`/`set`/`flush`/`cleanup`/`
|
|
9417
|
+
* `state`/`set`/`flush`/`cleanup`/`island` are bound to the instance + plugin state.
|
|
9324
9418
|
*
|
|
9325
|
-
* @param state - The plugin state (for the cross-island `
|
|
9419
|
+
* @param state - The plugin state (for the cross-island `island` resolver).
|
|
9326
9420
|
* @param instance - The instance the context is bound to.
|
|
9327
9421
|
* @returns The instance-bound context.
|
|
9328
9422
|
* @example
|
|
@@ -9426,13 +9520,13 @@ function buildContext(state, instance) {
|
|
|
9426
9520
|
/**
|
|
9427
9521
|
* Resolve another island's registered api by name (`undefined` when absent).
|
|
9428
9522
|
*
|
|
9429
|
-
* @param name - The provider island's
|
|
9523
|
+
* @param name - The provider island's island name.
|
|
9430
9524
|
* @returns The provider's api, or `undefined`.
|
|
9431
9525
|
* @example
|
|
9432
|
-
* ctx.
|
|
9526
|
+
* ctx.island("lightbox");
|
|
9433
9527
|
*/
|
|
9434
|
-
|
|
9435
|
-
return state.
|
|
9528
|
+
island(name) {
|
|
9529
|
+
return state.islandApis.get(name);
|
|
9436
9530
|
}
|
|
9437
9531
|
};
|
|
9438
9532
|
}
|
|
@@ -9456,7 +9550,7 @@ function matchTarget(host, event, selector) {
|
|
|
9456
9550
|
return matched && host.contains(matched) ? matched : void 0;
|
|
9457
9551
|
}
|
|
9458
9552
|
/**
|
|
9459
|
-
* Attach a
|
|
9553
|
+
* Attach a island's declarative `events` map: one real listener per event TYPE on
|
|
9460
9554
|
* the host (dispatch walks `closest(selector)` for each registered selector), each
|
|
9461
9555
|
* removed via the instance's cleanup registry on destroy.
|
|
9462
9556
|
*
|
|
@@ -9473,7 +9567,7 @@ function attachEvents(instance, events) {
|
|
|
9473
9567
|
const space = key.indexOf(" ");
|
|
9474
9568
|
const type = (space === -1 ? key : key.slice(0, space)).trim();
|
|
9475
9569
|
const selector = space === -1 ? "" : key.slice(space + 1).trim();
|
|
9476
|
-
if (type === "") throw new Error(`${ERROR_PREFIX$2}
|
|
9570
|
+
if (type === "") throw new Error(`${ERROR_PREFIX$2} island "${instance.def.name}" event key must start with an event type: "${key}"\n → use "<type>" or "<type> <selector>" (e.g. "click [data-action]")`);
|
|
9477
9571
|
const list = byType.get(type) ?? [];
|
|
9478
9572
|
list.push({
|
|
9479
9573
|
selector,
|
|
@@ -9496,62 +9590,62 @@ function attachEvents(instance, events) {
|
|
|
9496
9590
|
* Validate a single hook entry: its key must be a known hook name and its value
|
|
9497
9591
|
* must be a function. Throws fail-fast on the first violation.
|
|
9498
9592
|
*
|
|
9499
|
-
* @param
|
|
9593
|
+
* @param islandName - The owning island name (for error messages).
|
|
9500
9594
|
* @param source - The raw authoring object being validated.
|
|
9501
9595
|
* @param key - The hook key to validate.
|
|
9502
|
-
* @throws {Error} If `key` is not in `
|
|
9596
|
+
* @throws {Error} If `key` is not in `ISLAND_HOOK_NAMES`.
|
|
9503
9597
|
* @throws {TypeError} If the hook value is not a function.
|
|
9504
9598
|
* @example
|
|
9505
9599
|
* validateHookEntry("counter", source, "onMount");
|
|
9506
9600
|
*/
|
|
9507
|
-
function validateHookEntry(
|
|
9508
|
-
if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown
|
|
9509
|
-
if (typeof source[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2}
|
|
9601
|
+
function validateHookEntry(islandName, source, key) {
|
|
9602
|
+
if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown island hook "${key}" on "${islandName}"\n → valid hooks: ${ISLAND_HOOK_NAMES.join(", ")}\n → spec keys: state, render, events, api`);
|
|
9603
|
+
if (typeof source[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} island hook "${key}" on "${islandName}" must be a function\n → provide a function or omit the hook`);
|
|
9510
9604
|
}
|
|
9511
9605
|
/**
|
|
9512
9606
|
* Validate the spec extras (`state`/`render`/`api` must be functions; `events` must be
|
|
9513
9607
|
* a plain object of functions). Throws fail-fast on the first violation.
|
|
9514
9608
|
*
|
|
9515
|
-
* @param
|
|
9609
|
+
* @param islandName - The owning island name (for error messages).
|
|
9516
9610
|
* @param extras - The partitioned spec extras to validate.
|
|
9517
9611
|
* @throws {TypeError} If a present extra has the wrong shape.
|
|
9518
9612
|
* @example
|
|
9519
9613
|
* validateSpecExtras("board", { state: () => ({}) });
|
|
9520
9614
|
*/
|
|
9521
|
-
function validateSpecExtras(
|
|
9615
|
+
function validateSpecExtras(islandName, extras) {
|
|
9522
9616
|
for (const key of [
|
|
9523
9617
|
"state",
|
|
9524
9618
|
"render",
|
|
9525
9619
|
"api"
|
|
9526
|
-
]) if (extras[key] !== void 0 && typeof extras[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2}
|
|
9620
|
+
]) if (extras[key] !== void 0 && typeof extras[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} island "${key}" on "${islandName}" must be a function\n → provide a function or omit it`);
|
|
9527
9621
|
if (extras.events !== void 0) {
|
|
9528
9622
|
const events = extras.events;
|
|
9529
|
-
if (!(typeof events === "object")) throw new TypeError(`${ERROR_PREFIX$2}
|
|
9530
|
-
for (const [key, handler] of Object.entries(events)) if (typeof handler !== "function") throw new TypeError(`${ERROR_PREFIX$2}
|
|
9623
|
+
if (!(typeof events === "object")) throw new TypeError(`${ERROR_PREFIX$2} island "events" on "${islandName}" must be an object of handlers`);
|
|
9624
|
+
for (const [key, handler] of Object.entries(events)) if (typeof handler !== "function") throw new TypeError(`${ERROR_PREFIX$2} island event "${key}" on "${islandName}" must be a function`);
|
|
9531
9625
|
}
|
|
9532
9626
|
}
|
|
9533
9627
|
/**
|
|
9534
|
-
* Create a validated
|
|
9535
|
-
* (`
|
|
9536
|
-
* (`
|
|
9628
|
+
* Create a validated island definition. Accepts either the legacy hooks-only form
|
|
9629
|
+
* (`createIsland("counter", { onMount() {} })`) or the plugin-mirror spec form
|
|
9630
|
+
* (`createIsland("board", { state, render, events, api, ...hooks })`). Spec-only
|
|
9537
9631
|
* keys (`state`/`render`/`events`/`api`) are partitioned out before hook-name
|
|
9538
9632
|
* validation, so a real typo (e.g. `onMout`) still throws immediately while the spec
|
|
9539
9633
|
* keys are accepted.
|
|
9540
9634
|
*
|
|
9541
|
-
* @param name - Unique
|
|
9635
|
+
* @param name - Unique island name.
|
|
9542
9636
|
* @param spec - Lifecycle hooks, or the `{ state, render, events, api, ...hooks }` spec.
|
|
9543
|
-
* @returns A `
|
|
9637
|
+
* @returns A `IslandDef` ready to `register`.
|
|
9544
9638
|
* @throws {Error} If `name` is empty, a hook key is unknown, or an extra/hook value has the wrong shape.
|
|
9545
9639
|
* @example
|
|
9546
|
-
* const counter =
|
|
9640
|
+
* const counter = createIsland("counter", { onMount({ el }) { el.textContent = "0"; } });
|
|
9547
9641
|
* @example
|
|
9548
|
-
* const list =
|
|
9642
|
+
* const list = createIsland<{ items: string[] }>("list", {
|
|
9549
9643
|
* state: () => ({ items: [] }),
|
|
9550
9644
|
* render: (s) => h(List, { items: s.items })
|
|
9551
9645
|
* });
|
|
9552
9646
|
*/
|
|
9553
|
-
function
|
|
9554
|
-
if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2}
|
|
9647
|
+
function createIsland(name, spec) {
|
|
9648
|
+
if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2} island name must be a non-empty string\n → pass a unique name to createIsland("name", hooks)`);
|
|
9555
9649
|
const source = spec;
|
|
9556
9650
|
const hooks = {};
|
|
9557
9651
|
const extras = {};
|
|
@@ -9628,30 +9722,30 @@ function disposeInstance(state, instance) {
|
|
|
9628
9722
|
} catch {}
|
|
9629
9723
|
instance.cleanups.length = 0;
|
|
9630
9724
|
instance.renderScheduled = false;
|
|
9631
|
-
if (instance.api !== void 0 && state.
|
|
9725
|
+
if (instance.api !== void 0 && state.islandApis.get(instance.def.name) === instance.api) state.islandApis.delete(instance.def.name);
|
|
9632
9726
|
}
|
|
9633
9727
|
/**
|
|
9634
|
-
* Mounts a single `data-
|
|
9728
|
+
* Mounts a single `data-island` element: classifies persistent vs page-specific,
|
|
9635
9729
|
* builds the instance + its bound context, initializes per-instance `state`, registers
|
|
9636
9730
|
* its `api`, attaches declarative `events`, fires `onCreate` then `onMount` (capturing
|
|
9637
9731
|
* an async `onMount` + render-chunk load as `mountPromise`), schedules the initial
|
|
9638
|
-
* render, records it, and emits `spa:
|
|
9639
|
-
* mounted, has no
|
|
9732
|
+
* render, records it, and emits `spa:island-mount`. No-ops if the element is already
|
|
9733
|
+
* mounted, has no island name, or names an unregistered island.
|
|
9640
9734
|
*
|
|
9641
|
-
* @param state - The plugin state (
|
|
9642
|
-
* @param emit - The event emitter for spa:
|
|
9735
|
+
* @param state - The plugin state (registeredIslands + instances + islandApis).
|
|
9736
|
+
* @param emit - The event emitter for spa:island-mount.
|
|
9643
9737
|
* @param swapArea - The swap-region element, or null when none was found.
|
|
9644
9738
|
* @param data - The current page data payload.
|
|
9645
|
-
* @param element - The candidate element carrying a `data-
|
|
9739
|
+
* @param element - The candidate element carrying a `data-island` attribute.
|
|
9646
9740
|
* @param route - The matched-route slice for the current URL (params/meta/locale/url).
|
|
9647
9741
|
* @example
|
|
9648
9742
|
* mountElement(state, emit, swapArea, data, element, route);
|
|
9649
9743
|
*/
|
|
9650
9744
|
function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE) {
|
|
9651
9745
|
if (state.instances.has(element)) return;
|
|
9652
|
-
const name = element.dataset.
|
|
9746
|
+
const name = element.dataset.island;
|
|
9653
9747
|
if (!name) return;
|
|
9654
|
-
const definition = state.
|
|
9748
|
+
const definition = state.registeredIslands.get(name);
|
|
9655
9749
|
if (!definition) return;
|
|
9656
9750
|
const instance = {
|
|
9657
9751
|
def: definition,
|
|
@@ -9677,7 +9771,7 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
|
|
|
9677
9771
|
if (spec?.state) instance.state = spec.state(instance.ctx);
|
|
9678
9772
|
if (spec?.api) {
|
|
9679
9773
|
instance.api = spec.api(instance.ctx);
|
|
9680
|
-
state.
|
|
9774
|
+
state.islandApis.set(definition.name, instance.api);
|
|
9681
9775
|
}
|
|
9682
9776
|
if (spec?.events) attachEvents(instance, spec.events);
|
|
9683
9777
|
runHook(instance, "onCreate");
|
|
@@ -9688,20 +9782,20 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
|
|
|
9688
9782
|
if (onMountResult && typeof onMountResult.then === "function") pending.push(onMountResult);
|
|
9689
9783
|
instance.mountPromise = pending.length > 0 ? Promise.all(pending).then(() => {}) : void 0;
|
|
9690
9784
|
state.instances.set(element, instance);
|
|
9691
|
-
emit("spa:
|
|
9785
|
+
emit("spa:island-mount", {
|
|
9692
9786
|
name: definition.name,
|
|
9693
9787
|
el: element
|
|
9694
9788
|
});
|
|
9695
9789
|
}
|
|
9696
9790
|
/**
|
|
9697
|
-
* Scans the swap region, mounts
|
|
9791
|
+
* Scans the swap region, mounts islands for matching `data-island` elements,
|
|
9698
9792
|
* classifies persistent (outside swap area) vs page-specific (inside), runs
|
|
9699
|
-
* `onCreate`/`onMount` + initial render, and emits `spa:
|
|
9793
|
+
* `onCreate`/`onMount` + initial render, and emits `spa:island-mount` per instance.
|
|
9700
9794
|
* Already-mounted elements are skipped.
|
|
9701
9795
|
*
|
|
9702
|
-
* @param state - The plugin state (
|
|
9703
|
-
* @param emit - The event emitter for spa:
|
|
9704
|
-
* @param swapSelector - CSS selector bounding page-specific
|
|
9796
|
+
* @param state - The plugin state (registeredIslands + instances + islandApis).
|
|
9797
|
+
* @param emit - The event emitter for spa:island-mount.
|
|
9798
|
+
* @param swapSelector - CSS selector bounding page-specific islands.
|
|
9705
9799
|
* @param route - The matched-route slice for the current URL (params/meta/locale/url).
|
|
9706
9800
|
* @example
|
|
9707
9801
|
* scanAndMount(state, emit, "main > section", route);
|
|
@@ -9710,16 +9804,16 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
|
|
|
9710
9804
|
if (typeof document === "undefined") return;
|
|
9711
9805
|
const swapArea = document.querySelector(swapSelector);
|
|
9712
9806
|
const data = extractPageData(document);
|
|
9713
|
-
for (const element of document.querySelectorAll("[data-
|
|
9807
|
+
for (const element of document.querySelectorAll("[data-island]")) mountElement(state, emit, swapArea, data, element, route);
|
|
9714
9808
|
}
|
|
9715
9809
|
/**
|
|
9716
9810
|
* Unmounts page-specific instances inside the swap region (runs `onUnMount` then
|
|
9717
9811
|
* `onDestroy`, then their cleanup disposers + api unregister), removes them from state,
|
|
9718
|
-
* and emits `spa:
|
|
9812
|
+
* and emits `spa:island-unmount`. Persistent instances (outside the swap area) are
|
|
9719
9813
|
* left in place.
|
|
9720
9814
|
*
|
|
9721
9815
|
* @param state - The plugin state holding live instances.
|
|
9722
|
-
* @param emit - The event emitter for spa:
|
|
9816
|
+
* @param emit - The event emitter for spa:island-unmount.
|
|
9723
9817
|
* @example
|
|
9724
9818
|
* unmountPageSpecific(state, emit);
|
|
9725
9819
|
*/
|
|
@@ -9732,7 +9826,7 @@ function unmountPageSpecific(state, emit) {
|
|
|
9732
9826
|
runHook(instance, "onDestroy");
|
|
9733
9827
|
disposeInstance(state, instance);
|
|
9734
9828
|
state.instances.delete(element);
|
|
9735
|
-
emit("spa:
|
|
9829
|
+
emit("spa:island-unmount", {
|
|
9736
9830
|
name: instance.def.name,
|
|
9737
9831
|
el: element
|
|
9738
9832
|
});
|
|
@@ -9741,11 +9835,11 @@ function unmountPageSpecific(state, emit) {
|
|
|
9741
9835
|
/**
|
|
9742
9836
|
* Disposes ALL live instances (persistent and page-specific) on teardown: runs
|
|
9743
9837
|
* `onUnMount` then `onDestroy`, then their cleanup disposers + api unregister, emits
|
|
9744
|
-
* `spa:
|
|
9838
|
+
* `spa:island-unmount`, and clears the instance + api maps. Used by the kernel's
|
|
9745
9839
|
* `dispose` on plugin stop.
|
|
9746
9840
|
*
|
|
9747
9841
|
* @param state - The plugin state holding live instances.
|
|
9748
|
-
* @param emit - The event emitter for spa:
|
|
9842
|
+
* @param emit - The event emitter for spa:island-unmount.
|
|
9749
9843
|
* @example
|
|
9750
9844
|
* unmountAll(state, emit);
|
|
9751
9845
|
*/
|
|
@@ -9756,13 +9850,13 @@ function unmountAll(state, emit) {
|
|
|
9756
9850
|
runHook(instance, "onUnMount");
|
|
9757
9851
|
runHook(instance, "onDestroy");
|
|
9758
9852
|
disposeInstance(state, instance);
|
|
9759
|
-
emit("spa:
|
|
9853
|
+
emit("spa:island-unmount", {
|
|
9760
9854
|
name: instance.def.name,
|
|
9761
9855
|
el: element
|
|
9762
9856
|
});
|
|
9763
9857
|
}
|
|
9764
9858
|
state.instances.clear();
|
|
9765
|
-
state.
|
|
9859
|
+
state.islandApis.clear();
|
|
9766
9860
|
}
|
|
9767
9861
|
/**
|
|
9768
9862
|
* Fires `onNavStart` on every currently-mounted instance (persistent instances
|
|
@@ -9799,80 +9893,6 @@ function notifyNavEnd(state, route = EMPTY_ROUTE) {
|
|
|
9799
9893
|
}
|
|
9800
9894
|
}
|
|
9801
9895
|
//#endregion
|
|
9802
|
-
//#region src/plugins/spa/head.ts
|
|
9803
|
-
/** Single-element head selectors synced by replace/append/remove on navigation. */
|
|
9804
|
-
const META_SELECTORS = [
|
|
9805
|
-
"meta[name=\"description\"]",
|
|
9806
|
-
"meta[property=\"og:title\"]",
|
|
9807
|
-
"meta[property=\"og:description\"]",
|
|
9808
|
-
"meta[property=\"og:url\"]",
|
|
9809
|
-
"meta[property=\"og:image\"]",
|
|
9810
|
-
"meta[property=\"og:type\"]",
|
|
9811
|
-
"meta[property=\"og:locale\"]",
|
|
9812
|
-
"meta[name=\"twitter:card\"]",
|
|
9813
|
-
"meta[name=\"twitter:title\"]",
|
|
9814
|
-
"meta[name=\"twitter:description\"]",
|
|
9815
|
-
"meta[name=\"twitter:image\"]",
|
|
9816
|
-
"meta[name=\"twitter:site\"]",
|
|
9817
|
-
"link[rel=\"canonical\"]"
|
|
9818
|
-
];
|
|
9819
|
-
/** Head element groups fully replaced (remove-all-then-clone) on navigation. */
|
|
9820
|
-
const REPLACE_ALL_SELECTORS = [
|
|
9821
|
-
"script[type=\"application/ld+json\"]",
|
|
9822
|
-
"link[rel=\"alternate\"][hreflang]",
|
|
9823
|
-
"meta[property^=\"article:\"]"
|
|
9824
|
-
];
|
|
9825
|
-
/**
|
|
9826
|
-
* Sync a single head element by selector between the fetched and live document:
|
|
9827
|
-
* replace when both exist, append when only the new doc has it, remove when only
|
|
9828
|
-
* the live doc has it.
|
|
9829
|
-
*
|
|
9830
|
-
* @param selector - CSS selector for the head element to sync.
|
|
9831
|
-
* @param doc - The fetched document (DOMParser-parsed).
|
|
9832
|
-
* @example
|
|
9833
|
-
* syncElement('link[rel="canonical"]', doc);
|
|
9834
|
-
*/
|
|
9835
|
-
function syncElement(selector, doc) {
|
|
9836
|
-
const newElement = doc.querySelector(selector);
|
|
9837
|
-
const oldElement = document.querySelector(selector);
|
|
9838
|
-
if (newElement && oldElement) oldElement.replaceWith(newElement.cloneNode(true));
|
|
9839
|
-
else if (newElement) document.head.append(newElement.cloneNode(true));
|
|
9840
|
-
else if (oldElement) oldElement.remove();
|
|
9841
|
-
}
|
|
9842
|
-
/**
|
|
9843
|
-
* Remove all live matches for a selector and re-clone the fetched document's
|
|
9844
|
-
* matches into the live `<head>`.
|
|
9845
|
-
*
|
|
9846
|
-
* @param selector - CSS selector for the element group to replace wholesale.
|
|
9847
|
-
* @param doc - The fetched document (DOMParser-parsed).
|
|
9848
|
-
* @example
|
|
9849
|
-
* replaceAllBySelector('script[type="application/ld+json"]', doc);
|
|
9850
|
-
*/
|
|
9851
|
-
function replaceAllBySelector(selector, doc) {
|
|
9852
|
-
for (const element of document.querySelectorAll(selector)) element.remove();
|
|
9853
|
-
for (const element of doc.querySelectorAll(selector)) document.head.append(element.cloneNode(true));
|
|
9854
|
-
}
|
|
9855
|
-
/**
|
|
9856
|
-
* Syncs the live document `<head>` after a navigation from the fetched document
|
|
9857
|
-
* (whose head was composed by the `head` plugin). Recomputes
|
|
9858
|
-
* title/meta/canonical/JSON-LD/hreflang/`<html lang>` once and applies them.
|
|
9859
|
-
* The `head` API is accepted to bind the structural dependency (spec/09 deps).
|
|
9860
|
-
*
|
|
9861
|
-
* @param _head - The head plugin API (dependency binding; composition reused via the fetched doc).
|
|
9862
|
-
* @param doc - The fetched document parsed from the navigated page's HTML.
|
|
9863
|
-
* @example
|
|
9864
|
-
* syncHead(headApi, parsedDoc);
|
|
9865
|
-
*/
|
|
9866
|
-
function syncHead(_head, doc) {
|
|
9867
|
-
if (typeof document === "undefined") return;
|
|
9868
|
-
const newTitle = doc.querySelector("title")?.textContent;
|
|
9869
|
-
if (newTitle) document.title = newTitle;
|
|
9870
|
-
const newLang = doc.documentElement.lang;
|
|
9871
|
-
if (newLang) document.documentElement.lang = newLang;
|
|
9872
|
-
for (const selector of META_SELECTORS) syncElement(selector, doc);
|
|
9873
|
-
for (const selector of REPLACE_ALL_SELECTORS) replaceAllBySelector(selector, doc);
|
|
9874
|
-
}
|
|
9875
|
-
//#endregion
|
|
9876
9896
|
//#region src/plugins/spa/progress.ts
|
|
9877
9897
|
/** Delay before the bar appears, so fast navigations show no indicator. */
|
|
9878
9898
|
const START_DELAY_MS = 150;
|
|
@@ -10279,7 +10299,7 @@ const defaultSpaConfig = {
|
|
|
10279
10299
|
swapSelector: "main > section",
|
|
10280
10300
|
viewTransitions: false,
|
|
10281
10301
|
progressBar: true,
|
|
10282
|
-
|
|
10302
|
+
islands: []
|
|
10283
10303
|
};
|
|
10284
10304
|
/**
|
|
10285
10305
|
* Whether a selector is syntactically valid (parseable by the DOM). Falls back
|
|
@@ -10301,8 +10321,8 @@ function isValidSelector(selector) {
|
|
|
10301
10321
|
}
|
|
10302
10322
|
/**
|
|
10303
10323
|
* Validates the spa config and applies defaults (Part-3 errors on an empty or
|
|
10304
|
-
* syntactically-invalid `swapSelector`).
|
|
10305
|
-
* `
|
|
10324
|
+
* syntactically-invalid `swapSelector`). Island-hook validation runs later in
|
|
10325
|
+
* `createIsland` when the islands are registered.
|
|
10306
10326
|
*
|
|
10307
10327
|
* @param config - The raw spa config to validate.
|
|
10308
10328
|
* @returns The fully-resolved config with defaults applied.
|
|
@@ -10318,7 +10338,7 @@ function resolveSpaConfig(config) {
|
|
|
10318
10338
|
swapSelector,
|
|
10319
10339
|
viewTransitions: config.viewTransitions ?? false,
|
|
10320
10340
|
progressBar: config.progressBar ?? true,
|
|
10321
|
-
|
|
10341
|
+
islands: config.islands ?? []
|
|
10322
10342
|
};
|
|
10323
10343
|
}
|
|
10324
10344
|
/**
|
|
@@ -10335,9 +10355,9 @@ function resolveSpaConfig(config) {
|
|
|
10335
10355
|
*/
|
|
10336
10356
|
function createState(_ctx) {
|
|
10337
10357
|
return {
|
|
10338
|
-
|
|
10358
|
+
registeredIslands: /* @__PURE__ */ new Map(),
|
|
10339
10359
|
instances: /* @__PURE__ */ new Map(),
|
|
10340
|
-
|
|
10360
|
+
islandApis: /* @__PURE__ */ new Map(),
|
|
10341
10361
|
currentUrl: "",
|
|
10342
10362
|
destroyRouter: null,
|
|
10343
10363
|
started: false,
|
|
@@ -10357,15 +10377,15 @@ function createState(_ctx) {
|
|
|
10357
10377
|
/** Error prefix for spa kernel failures (spec/11 Part-3). */
|
|
10358
10378
|
const ERROR_PREFIX = "[web]";
|
|
10359
10379
|
/**
|
|
10360
|
-
* Registers a
|
|
10380
|
+
* Registers a island definition into state (last-registered-wins).
|
|
10361
10381
|
*
|
|
10362
|
-
* @param state - The plugin state holding
|
|
10363
|
-
* @param
|
|
10382
|
+
* @param state - The plugin state holding registeredIslands.
|
|
10383
|
+
* @param island - The island definition to register.
|
|
10364
10384
|
* @example
|
|
10365
|
-
*
|
|
10385
|
+
* registerIsland(state, counter);
|
|
10366
10386
|
*/
|
|
10367
|
-
function
|
|
10368
|
-
state.
|
|
10387
|
+
function registerIsland(state, island) {
|
|
10388
|
+
state.registeredIslands.set(island.name, island);
|
|
10369
10389
|
}
|
|
10370
10390
|
/**
|
|
10371
10391
|
* Resolve the current document URL (pathname + search), or `""` when headless.
|
|
@@ -10440,15 +10460,15 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10440
10460
|
});
|
|
10441
10461
|
};
|
|
10442
10462
|
/**
|
|
10443
|
-
* Build the matched-route slice (params/meta/locale/url) for the
|
|
10463
|
+
* Build the matched-route slice (params/meta/locale/url) for the island context at `path`,
|
|
10444
10464
|
* so islands read their route's params/meta directly. An unmatched path yields an empty slice.
|
|
10445
10465
|
*
|
|
10446
10466
|
* @param path - The URL (pathname + search) to match.
|
|
10447
10467
|
* @returns The route slice for the matched route.
|
|
10448
10468
|
* @example
|
|
10449
|
-
* scanAndMount(state, emit, resolved.swapSelector,
|
|
10469
|
+
* scanAndMount(state, emit, resolved.swapSelector, islandRouteContext(pathname));
|
|
10450
10470
|
*/
|
|
10451
|
-
const
|
|
10471
|
+
const islandRouteContext = (path) => {
|
|
10452
10472
|
const matchPath = path.split("?")[0] ?? path;
|
|
10453
10473
|
const hit = deps.router.match(matchPath);
|
|
10454
10474
|
const locale = hit?.params.lang ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
|
|
@@ -10477,7 +10497,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10477
10497
|
syncHead(deps.head, doc);
|
|
10478
10498
|
unmountPageSpecific(state, emit);
|
|
10479
10499
|
if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
|
|
10480
|
-
const routeSlice =
|
|
10500
|
+
const routeSlice = islandRouteContext(pathname);
|
|
10481
10501
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10482
10502
|
notifyNavEnd(state, routeSlice);
|
|
10483
10503
|
}, applyPendingScroll)) {
|
|
@@ -10490,7 +10510,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10490
10510
|
emit("spa:navigated", { url: pathname });
|
|
10491
10511
|
};
|
|
10492
10512
|
/**
|
|
10493
|
-
* Begin a navigation: start progress, notify
|
|
10513
|
+
* Begin a navigation: start progress, notify islands, emit navigate.
|
|
10494
10514
|
*
|
|
10495
10515
|
* @param pathname - The destination pathname.
|
|
10496
10516
|
* @example
|
|
@@ -10576,11 +10596,11 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10576
10596
|
const commitDataRender = async (pathname, resolvedRender, signal) => {
|
|
10577
10597
|
if (signal?.aborted) return;
|
|
10578
10598
|
const { route, vnode, routeContext, region } = resolvedRender;
|
|
10579
|
-
const { renderVNode } = await import("./render-
|
|
10599
|
+
const { renderVNode } = await import("./render-yXHc9BWI.mjs");
|
|
10580
10600
|
if (signal?.aborted) return;
|
|
10581
10601
|
syncDataHead(deps.head, route, routeContext);
|
|
10582
10602
|
unmountPageSpecific(state, emit);
|
|
10583
|
-
const routeSlice =
|
|
10603
|
+
const routeSlice = islandRouteContext(pathname);
|
|
10584
10604
|
/**
|
|
10585
10605
|
* Render the VNode into the region and re-mount its islands in one paint — the
|
|
10586
10606
|
* swap body handed to `runSwap` (optionally wrapped in a View Transition).
|
|
@@ -10638,14 +10658,14 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10638
10658
|
* await bootRender("/b/abc123");
|
|
10639
10659
|
*/
|
|
10640
10660
|
const bootRender = async (pathname) => {
|
|
10641
|
-
const routeSlice =
|
|
10661
|
+
const routeSlice = islandRouteContext(pathname);
|
|
10642
10662
|
const resolvedRender = await resolveDataRender(pathname);
|
|
10643
10663
|
if (resolvedRender === false) {
|
|
10644
10664
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10645
10665
|
return;
|
|
10646
10666
|
}
|
|
10647
10667
|
const { vnode, region } = resolvedRender;
|
|
10648
|
-
const { renderVNode } = await import("./render-
|
|
10668
|
+
const { renderVNode } = await import("./render-yXHc9BWI.mjs");
|
|
10649
10669
|
renderVNode(vnode, region);
|
|
10650
10670
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10651
10671
|
};
|
|
@@ -10673,13 +10693,13 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10673
10693
|
};
|
|
10674
10694
|
return {
|
|
10675
10695
|
/**
|
|
10676
|
-
* Register config
|
|
10696
|
+
* Register config islands and seed currentUrl from the document.
|
|
10677
10697
|
*
|
|
10678
10698
|
* @example
|
|
10679
10699
|
* kernel.init();
|
|
10680
10700
|
*/
|
|
10681
10701
|
init() {
|
|
10682
|
-
for (const
|
|
10702
|
+
for (const island of resolved.islands) registerIsland(state, island);
|
|
10683
10703
|
state.currentUrl = currentLocationUrl();
|
|
10684
10704
|
},
|
|
10685
10705
|
/**
|
|
@@ -10697,18 +10717,18 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10697
10717
|
const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
|
|
10698
10718
|
const hit = deps.router.match(matchPath);
|
|
10699
10719
|
if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
|
|
10700
|
-
else scanAndMount(state, emit, resolved.swapSelector,
|
|
10720
|
+
else scanAndMount(state, emit, resolved.swapSelector, islandRouteContext(state.currentUrl));
|
|
10701
10721
|
state.started = true;
|
|
10702
10722
|
},
|
|
10703
10723
|
/**
|
|
10704
|
-
* Register a
|
|
10724
|
+
* Register a island definition (last-registered-wins).
|
|
10705
10725
|
*
|
|
10706
|
-
* @param
|
|
10726
|
+
* @param island - The island definition to register.
|
|
10707
10727
|
* @example
|
|
10708
10728
|
* kernel.register(counter);
|
|
10709
10729
|
*/
|
|
10710
|
-
register(
|
|
10711
|
-
|
|
10730
|
+
register(island) {
|
|
10731
|
+
registerIsland(state, island);
|
|
10712
10732
|
},
|
|
10713
10733
|
/**
|
|
10714
10734
|
* Process a navigation to `path` (fetch then swap; full reload on error).
|
|
@@ -10722,13 +10742,13 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10722
10742
|
navigate(path).catch(() => {});
|
|
10723
10743
|
},
|
|
10724
10744
|
/**
|
|
10725
|
-
* Scan the swap region and mount
|
|
10745
|
+
* Scan the swap region and mount islands for matching elements.
|
|
10726
10746
|
*
|
|
10727
10747
|
* @example
|
|
10728
10748
|
* kernel.scan();
|
|
10729
10749
|
*/
|
|
10730
10750
|
scan() {
|
|
10731
|
-
scanAndMount(state, emit, resolved.swapSelector,
|
|
10751
|
+
scanAndMount(state, emit, resolved.swapSelector, islandRouteContext(state.currentUrl));
|
|
10732
10752
|
},
|
|
10733
10753
|
/**
|
|
10734
10754
|
* Tear down router listeners, dispose all instances, reset boot state.
|
|
@@ -10747,7 +10767,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10747
10767
|
}
|
|
10748
10768
|
/**
|
|
10749
10769
|
* Builds the shared kernel from the plugin context, stores it on `ctx.state`,
|
|
10750
|
-
* and runs its init step (validate config, register config.
|
|
10770
|
+
* and runs its init step (validate config, register config.islands, seed
|
|
10751
10771
|
* currentUrl). Captures the OPTIONAL `data` reader when the `data` plugin is
|
|
10752
10772
|
* composed (enabling client DATA navigation) — resolved by instance via
|
|
10753
10773
|
* `ctx.require(dataPlugin)`, guarded by `ctx.has("data")` so `data` stays optional
|
|
@@ -10815,10 +10835,10 @@ function disposeSpa() {
|
|
|
10815
10835
|
/**
|
|
10816
10836
|
* @file `lazyEmbed` island — activates the static embed facades emitted by the
|
|
10817
10837
|
* content pipeline's `::embed` directive (pipeline/embed.ts). Mounts on every
|
|
10818
|
-
* `[data-
|
|
10838
|
+
* `[data-island="lazy-embed"]` figure; a click on the facade's button swaps
|
|
10819
10839
|
* it for the real `<iframe loading="lazy">`. Until that click the embedded
|
|
10820
10840
|
* document costs the page nothing — no request, no third-party JS, no
|
|
10821
|
-
* scroll-jacking. Register it in `pluginConfigs.spa.
|
|
10841
|
+
* scroll-jacking. Register it in `pluginConfigs.spa.islands`; all visual
|
|
10822
10842
|
* chrome (`.lazy-embed*` classes) is consumer CSS.
|
|
10823
10843
|
*/
|
|
10824
10844
|
/** CSS class on the injected `<iframe>` (consumer CSS sizes it). */
|
|
@@ -10871,7 +10891,7 @@ function onFacadeClick(event) {
|
|
|
10871
10891
|
* Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
|
|
10872
10892
|
* The companion of the content pipeline's `::embed` directive.
|
|
10873
10893
|
*/
|
|
10874
|
-
const lazyEmbed =
|
|
10894
|
+
const lazyEmbed = createIsland("lazy-embed", {
|
|
10875
10895
|
/**
|
|
10876
10896
|
* Bind the activation click handler when a facade mounts.
|
|
10877
10897
|
*
|
|
@@ -10897,18 +10917,18 @@ const lazyEmbed = createComponent("lazy-embed", {
|
|
|
10897
10917
|
//#region src/plugins/spa/index.ts
|
|
10898
10918
|
/**
|
|
10899
10919
|
* @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
|
|
10900
|
-
* domain files (kernel/router/head/progress/
|
|
10920
|
+
* domain files (kernel/router/head/progress/islands/lifecycle); index wires.
|
|
10901
10921
|
*
|
|
10902
10922
|
* Depends: router, head.
|
|
10903
|
-
* Emits: spa:navigate, spa:navigated, spa:
|
|
10923
|
+
* Emits: spa:navigate, spa:navigated, spa:island-mount, spa:island-unmount.
|
|
10904
10924
|
* @see README.md
|
|
10905
10925
|
*/
|
|
10906
10926
|
/**
|
|
10907
10927
|
* SPA plugin — progressive client-side navigation layered over the static site:
|
|
10908
10928
|
* swaps a page region on navigation, with an optional progress bar and View
|
|
10909
|
-
* Transitions. Register interactive islands with {@link
|
|
10910
|
-
* on router and head; emits `spa:navigate`, `spa:navigated`, `spa:
|
|
10911
|
-
* and `spa:
|
|
10929
|
+
* Transitions. Register interactive islands with {@link createIsland}. Depends
|
|
10930
|
+
* on router and head; emits `spa:navigate`, `spa:navigated`, `spa:island-mount`,
|
|
10931
|
+
* and `spa:island-unmount`.
|
|
10912
10932
|
*
|
|
10913
10933
|
* @example Enable view transitions and a custom swap region
|
|
10914
10934
|
* ```ts
|
|
@@ -11891,8 +11911,8 @@ function EmbedFacadeButton(props) {
|
|
|
11891
11911
|
//#region src/plugins/content/pipeline/embed.ts
|
|
11892
11912
|
/** CSS class on the `<figure>` facade wrapping each embed. */
|
|
11893
11913
|
const EMBED_FIGURE_CLASS = "lazy-embed";
|
|
11894
|
-
/** `data-
|
|
11895
|
-
const
|
|
11914
|
+
/** `data-island` name binding the facade to the `lazyEmbed` SPA island. */
|
|
11915
|
+
const EMBED_ISLAND_NAME = "lazy-embed";
|
|
11896
11916
|
/**
|
|
11897
11917
|
* Type guard for an `::embed` leaf directive.
|
|
11898
11918
|
*
|
|
@@ -12002,7 +12022,7 @@ function collectAttributes$1(attributes) {
|
|
|
12002
12022
|
* ```
|
|
12003
12023
|
*/
|
|
12004
12024
|
function embedFacadeHtml(facade, props, dimensions) {
|
|
12005
|
-
return `<figure class="${EMBED_FIGURE_CLASS}" data-
|
|
12025
|
+
return `<figure class="${EMBED_FIGURE_CLASS}" data-island="${EMBED_ISLAND_NAME}" data-embed-src="${escapeAttribute(props.src)}" data-embed-title="${escapeAttribute(props.title)}"${dimensions ? ` data-embed-width="${dimensions.width}" data-embed-height="${dimensions.height}" style="aspect-ratio: ${dimensions.width} / ${dimensions.height}; max-width: ${dimensions.width}px;"` : ""}>${renderToString(h(facade, props))}</figure>`;
|
|
12006
12026
|
}
|
|
12007
12027
|
/**
|
|
12008
12028
|
* Normalize the provider's `embed` config value (`boolean | options`) to a plain
|
|
@@ -12115,7 +12135,7 @@ function GalleryTrack(props) {
|
|
|
12115
12135
|
*
|
|
12116
12136
|
* Rewrites `::gallery{src="./images/dir/" caption="…"}` leaf directives into a
|
|
12117
12137
|
* static swipeable image set at the mdast stage (BEFORE the remark-rehype bridge):
|
|
12118
|
-
* a framework-owned `<div class="gallery" data-
|
|
12138
|
+
* a framework-owned `<div class="gallery" data-island="gallery">` carrying the
|
|
12119
12139
|
* island hook, wrapping inner content rendered (at build time, to static markup)
|
|
12120
12140
|
* by a Preact component — the built-in {@link GalleryTrack} by default, or a
|
|
12121
12141
|
* consumer component via `gallery.component`.
|
|
@@ -12127,12 +12147,12 @@ function GalleryTrack(props) {
|
|
|
12127
12147
|
* transform reads `<contentDir>/<slug>/<src>` from disk, sorts its images, and
|
|
12128
12148
|
* resolves each to its shared `/<slug>/<dir>/<file>` URL (identical from every
|
|
12129
12149
|
* locale page, mirroring co-located images). The companion gallery SPA island
|
|
12130
|
-
* (consumer-provided) wires swipe/keyboard/lightbox on `[data-
|
|
12150
|
+
* (consumer-provided) wires swipe/keyboard/lightbox on `[data-island="gallery"]`.
|
|
12131
12151
|
*/
|
|
12132
12152
|
/** CSS class on the `<div>` wrapping each gallery. */
|
|
12133
12153
|
const GALLERY_WRAPPER_CLASS = "gallery";
|
|
12134
|
-
/** `data-
|
|
12135
|
-
const
|
|
12154
|
+
/** `data-island` name binding the gallery to its SPA island. */
|
|
12155
|
+
const GALLERY_ISLAND_NAME = "gallery";
|
|
12136
12156
|
/** Image file extensions a gallery folder expands over. */
|
|
12137
12157
|
const IMAGE_EXTENSIONS = new Set([
|
|
12138
12158
|
".webp",
|
|
@@ -12225,7 +12245,7 @@ function collectAttributes(attributes) {
|
|
|
12225
12245
|
}
|
|
12226
12246
|
/**
|
|
12227
12247
|
* Build the static gallery HTML for one directive: the framework-owned `<div>`
|
|
12228
|
-
* (island hook in `data-
|
|
12248
|
+
* (island hook in `data-island`) wrapping the component's inner content, SSR'd
|
|
12229
12249
|
* to static markup.
|
|
12230
12250
|
*
|
|
12231
12251
|
* @param component - The gallery component (default {@link GalleryTrack}).
|
|
@@ -12239,7 +12259,7 @@ function collectAttributes(attributes) {
|
|
|
12239
12259
|
* ```
|
|
12240
12260
|
*/
|
|
12241
12261
|
function galleryHtml(component, slides, caption, attributes) {
|
|
12242
|
-
return `<div class="${GALLERY_WRAPPER_CLASS}" data-
|
|
12262
|
+
return `<div class="${GALLERY_WRAPPER_CLASS}" data-island="${GALLERY_ISLAND_NAME}">${renderToString(h(component, {
|
|
12243
12263
|
slides,
|
|
12244
12264
|
caption,
|
|
12245
12265
|
attributes
|
|
@@ -13156,4 +13176,4 @@ const createApp = core.createApp;
|
|
|
13156
13176
|
*/
|
|
13157
13177
|
const createPlugin = core.createPlugin;
|
|
13158
13178
|
//#endregion
|
|
13159
|
-
export { types_exports as Build, types_exports$1 as Cli, types_exports$2 as Content, types_exports$3 as Data, types_exports$4 as Deploy, EmbedFacadeButton, GalleryTrack, types_exports$5 as Head, types_exports$6 as Router, types_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp,
|
|
13179
|
+
export { types_exports as Build, types_exports$1 as Cli, types_exports$2 as Content, types_exports$3 as Data, types_exports$4 as Deploy, EmbedFacadeButton, GalleryTrack, types_exports$5 as Head, types_exports$6 as Router, types_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|