@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.cjs
CHANGED
|
@@ -8039,17 +8039,22 @@ function createApi$1(ctx) {
|
|
|
8039
8039
|
* @example
|
|
8040
8040
|
* await api.update(["src/islands/board.ts"]);
|
|
8041
8041
|
*/
|
|
8042
|
-
update(changes, options = {}) {
|
|
8042
|
+
async update(changes, options = {}) {
|
|
8043
8043
|
const overrides = devBuildOverrides({
|
|
8044
8044
|
og: options.og ?? false,
|
|
8045
8045
|
sitemap: options.sitemap ?? false,
|
|
8046
8046
|
feeds: options.feeds ?? false
|
|
8047
8047
|
});
|
|
8048
|
-
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8048
|
+
ctx.state.render.setDriven(true);
|
|
8049
|
+
try {
|
|
8050
|
+
return await ctx.require(buildPlugin).run({
|
|
8051
|
+
skipClean: true,
|
|
8052
|
+
overrides,
|
|
8053
|
+
changed: changes
|
|
8054
|
+
});
|
|
8055
|
+
} finally {
|
|
8056
|
+
ctx.state.render.setDriven(false);
|
|
8057
|
+
}
|
|
8053
8058
|
},
|
|
8054
8059
|
/**
|
|
8055
8060
|
* Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
|
|
@@ -8342,6 +8347,7 @@ function createPanelRenderer(options = {}) {
|
|
|
8342
8347
|
let idle = false;
|
|
8343
8348
|
let idleStartedAt = 0;
|
|
8344
8349
|
let serveMode = false;
|
|
8350
|
+
let driven = false;
|
|
8345
8351
|
let ticker;
|
|
8346
8352
|
/**
|
|
8347
8353
|
* Render one phase-tree row: a spinning cyan glyph + dim name while running, or a green
|
|
@@ -8503,7 +8509,7 @@ function createPanelRenderer(options = {}) {
|
|
|
8503
8509
|
* render.phase({ phase: "pages", status: "done", durationMs: 12 });
|
|
8504
8510
|
*/
|
|
8505
8511
|
phase(phase) {
|
|
8506
|
-
if (rebuilding) return;
|
|
8512
|
+
if (rebuilding || driven) return;
|
|
8507
8513
|
if (!color) {
|
|
8508
8514
|
if (phase.status === "done") write(` ${palette.green("✓")} ${phase.phase}${durationSuffix(palette, phase.durationMs)}`);
|
|
8509
8515
|
return;
|
|
@@ -8537,7 +8543,7 @@ function createPanelRenderer(options = {}) {
|
|
|
8537
8543
|
* render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
|
|
8538
8544
|
*/
|
|
8539
8545
|
built(summary) {
|
|
8540
|
-
if (rebuilding) return;
|
|
8546
|
+
if (rebuilding || driven) return;
|
|
8541
8547
|
if (color && phaseOpen) {
|
|
8542
8548
|
let frame = (0, _moku_labs_common_cli.cursorUp)(phaseDrawn);
|
|
8543
8549
|
for (const row of phaseRows) frame += `${_moku_labs_common_cli.CLEAR_LINE}${renderPhaseRow(row)}\n`;
|
|
@@ -8711,6 +8717,20 @@ function createPanelRenderer(options = {}) {
|
|
|
8711
8717
|
if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
|
|
8712
8718
|
},
|
|
8713
8719
|
/**
|
|
8720
|
+
* Mark the build TUI as externally driven: when `on`, the per-phase build tree and the BUILD
|
|
8721
|
+
* summary are suppressed so an external dev driver (e.g. the worker's `dev({ onChange })` loop,
|
|
8722
|
+
* which calls `update()` and renders its own concise rebuild line) is the sole source of rebuild
|
|
8723
|
+
* output. Off by default; a standalone `build()` / `serve()` renders the full TUI as before.
|
|
8724
|
+
*
|
|
8725
|
+
* @param on - Whether an external driver owns the dev TUI.
|
|
8726
|
+
* @example
|
|
8727
|
+
* render.setDriven(true); // before an externally-driven update()
|
|
8728
|
+
* render.setDriven(false); // after it settles
|
|
8729
|
+
*/
|
|
8730
|
+
setDriven(on) {
|
|
8731
|
+
driven = on;
|
|
8732
|
+
},
|
|
8733
|
+
/**
|
|
8714
8734
|
* Stop every animation and release the interval timer (serve()'s teardown calls this).
|
|
8715
8735
|
*
|
|
8716
8736
|
* @example
|
|
@@ -9127,15 +9147,15 @@ const cliPlugin = createPlugin$1("cli", {
|
|
|
9127
9147
|
function createApi(ctx) {
|
|
9128
9148
|
return {
|
|
9129
9149
|
/**
|
|
9130
|
-
* Register a
|
|
9150
|
+
* Register a island definition (last-registered-wins); warns on collision.
|
|
9131
9151
|
*
|
|
9132
|
-
* @param
|
|
9152
|
+
* @param island - The island definition created via `createIsland`.
|
|
9133
9153
|
* @example
|
|
9134
9154
|
* app.spa.register(counter);
|
|
9135
9155
|
*/
|
|
9136
|
-
register(
|
|
9137
|
-
if (ctx.state.
|
|
9138
|
-
ctx.state.kernel?.register(
|
|
9156
|
+
register(island) {
|
|
9157
|
+
if (ctx.state.registeredIslands.has(island.name)) ctx.log.warn("spa:island-collision", { name: island.name });
|
|
9158
|
+
ctx.state.kernel?.register(island);
|
|
9139
9159
|
},
|
|
9140
9160
|
/**
|
|
9141
9161
|
* Programmatically navigate to a path (client runtime; no-op without a DOM).
|
|
@@ -9161,13 +9181,13 @@ function createApi(ctx) {
|
|
|
9161
9181
|
* Resolve a registered island's api by name (the cross-island seam). Returns
|
|
9162
9182
|
* `undefined` when no provider with that name is currently registered.
|
|
9163
9183
|
*
|
|
9164
|
-
* @param name - The provider island's
|
|
9184
|
+
* @param name - The provider island's island name.
|
|
9165
9185
|
* @returns The provider's api, or `undefined`.
|
|
9166
9186
|
* @example
|
|
9167
|
-
* app.spa.
|
|
9187
|
+
* app.spa.island("lightbox");
|
|
9168
9188
|
*/
|
|
9169
|
-
|
|
9170
|
-
return ctx.state.
|
|
9189
|
+
island(name) {
|
|
9190
|
+
return ctx.state.islandApis.get(name);
|
|
9171
9191
|
}
|
|
9172
9192
|
};
|
|
9173
9193
|
}
|
|
@@ -9186,15 +9206,89 @@ function spaEvents(register) {
|
|
|
9186
9206
|
return {
|
|
9187
9207
|
"spa:navigate": register("A navigation has been intercepted and is starting."),
|
|
9188
9208
|
"spa:navigated": register("The swap completed and the new URL is active."),
|
|
9189
|
-
"spa:
|
|
9190
|
-
"spa:
|
|
9209
|
+
"spa:island-mount": register("A island instance attached to an element."),
|
|
9210
|
+
"spa:island-unmount": register("A island instance detached from an element.")
|
|
9191
9211
|
};
|
|
9192
9212
|
}
|
|
9193
9213
|
//#endregion
|
|
9214
|
+
//#region src/plugins/spa/head.ts
|
|
9215
|
+
/** Single-element head selectors synced by replace/append/remove on navigation. */
|
|
9216
|
+
const META_SELECTORS = [
|
|
9217
|
+
"meta[name=\"description\"]",
|
|
9218
|
+
"meta[property=\"og:title\"]",
|
|
9219
|
+
"meta[property=\"og:description\"]",
|
|
9220
|
+
"meta[property=\"og:url\"]",
|
|
9221
|
+
"meta[property=\"og:image\"]",
|
|
9222
|
+
"meta[property=\"og:type\"]",
|
|
9223
|
+
"meta[property=\"og:locale\"]",
|
|
9224
|
+
"meta[name=\"twitter:card\"]",
|
|
9225
|
+
"meta[name=\"twitter:title\"]",
|
|
9226
|
+
"meta[name=\"twitter:description\"]",
|
|
9227
|
+
"meta[name=\"twitter:image\"]",
|
|
9228
|
+
"meta[name=\"twitter:site\"]",
|
|
9229
|
+
"link[rel=\"canonical\"]"
|
|
9230
|
+
];
|
|
9231
|
+
/** Head element groups fully replaced (remove-all-then-clone) on navigation. */
|
|
9232
|
+
const REPLACE_ALL_SELECTORS = [
|
|
9233
|
+
"script[type=\"application/ld+json\"]",
|
|
9234
|
+
"link[rel=\"alternate\"][hreflang]",
|
|
9235
|
+
"meta[property^=\"article:\"]"
|
|
9236
|
+
];
|
|
9237
|
+
/**
|
|
9238
|
+
* Sync a single head element by selector between the fetched and live document:
|
|
9239
|
+
* replace when both exist, append when only the new doc has it, remove when only
|
|
9240
|
+
* the live doc has it.
|
|
9241
|
+
*
|
|
9242
|
+
* @param selector - CSS selector for the head element to sync.
|
|
9243
|
+
* @param doc - The fetched document (DOMParser-parsed).
|
|
9244
|
+
* @example
|
|
9245
|
+
* syncElement('link[rel="canonical"]', doc);
|
|
9246
|
+
*/
|
|
9247
|
+
function syncElement(selector, doc) {
|
|
9248
|
+
const newElement = doc.querySelector(selector);
|
|
9249
|
+
const oldElement = document.querySelector(selector);
|
|
9250
|
+
if (newElement && oldElement) oldElement.replaceWith(newElement.cloneNode(true));
|
|
9251
|
+
else if (newElement) document.head.append(newElement.cloneNode(true));
|
|
9252
|
+
else if (oldElement) oldElement.remove();
|
|
9253
|
+
}
|
|
9254
|
+
/**
|
|
9255
|
+
* Remove all live matches for a selector and re-clone the fetched document's
|
|
9256
|
+
* matches into the live `<head>`.
|
|
9257
|
+
*
|
|
9258
|
+
* @param selector - CSS selector for the element group to replace wholesale.
|
|
9259
|
+
* @param doc - The fetched document (DOMParser-parsed).
|
|
9260
|
+
* @example
|
|
9261
|
+
* replaceAllBySelector('script[type="application/ld+json"]', doc);
|
|
9262
|
+
*/
|
|
9263
|
+
function replaceAllBySelector(selector, doc) {
|
|
9264
|
+
for (const element of document.querySelectorAll(selector)) element.remove();
|
|
9265
|
+
for (const element of doc.querySelectorAll(selector)) document.head.append(element.cloneNode(true));
|
|
9266
|
+
}
|
|
9267
|
+
/**
|
|
9268
|
+
* Syncs the live document `<head>` after a navigation from the fetched document
|
|
9269
|
+
* (whose head was composed by the `head` plugin). Recomputes
|
|
9270
|
+
* title/meta/canonical/JSON-LD/hreflang/`<html lang>` once and applies them.
|
|
9271
|
+
* The `head` API is accepted to bind the structural dependency (spec/09 deps).
|
|
9272
|
+
*
|
|
9273
|
+
* @param _head - The head plugin API (dependency binding; composition reused via the fetched doc).
|
|
9274
|
+
* @param doc - The fetched document parsed from the navigated page's HTML.
|
|
9275
|
+
* @example
|
|
9276
|
+
* syncHead(headApi, parsedDoc);
|
|
9277
|
+
*/
|
|
9278
|
+
function syncHead(_head, doc) {
|
|
9279
|
+
if (typeof document === "undefined") return;
|
|
9280
|
+
const newTitle = doc.querySelector("title")?.textContent;
|
|
9281
|
+
if (newTitle) document.title = newTitle;
|
|
9282
|
+
const newLang = doc.documentElement.lang;
|
|
9283
|
+
if (newLang) document.documentElement.lang = newLang;
|
|
9284
|
+
for (const selector of META_SELECTORS) syncElement(selector, doc);
|
|
9285
|
+
for (const selector of REPLACE_ALL_SELECTORS) replaceAllBySelector(selector, doc);
|
|
9286
|
+
}
|
|
9287
|
+
//#endregion
|
|
9194
9288
|
//#region src/plugins/spa/types.ts
|
|
9195
|
-
var types_exports$7 = /* @__PURE__ */ require_convention.__exportAll({
|
|
9289
|
+
var types_exports$7 = /* @__PURE__ */ require_convention.__exportAll({ ISLAND_HOOK_NAMES: () => ISLAND_HOOK_NAMES });
|
|
9196
9290
|
/** Allowed hook names — single source of truth for fail-fast validation. */
|
|
9197
|
-
const
|
|
9291
|
+
const ISLAND_HOOK_NAMES = [
|
|
9198
9292
|
"onCreate",
|
|
9199
9293
|
"onMount",
|
|
9200
9294
|
"onNavStart",
|
|
@@ -9203,10 +9297,10 @@ const COMPONENT_HOOK_NAMES = [
|
|
|
9203
9297
|
"onDestroy"
|
|
9204
9298
|
];
|
|
9205
9299
|
//#endregion
|
|
9206
|
-
//#region src/plugins/spa/
|
|
9300
|
+
//#region src/plugins/spa/islands.ts
|
|
9207
9301
|
/**
|
|
9208
|
-
* @file spa plugin —
|
|
9209
|
-
* surface (`
|
|
9302
|
+
* @file spa plugin — island lifecycle, mounting, the plugin-mirror authoring
|
|
9303
|
+
* surface (`createIsland` with a typed `{ state, render, events, api }` spec),
|
|
9210
9304
|
* the per-instance state + microtask-batched render scheduler, declarative
|
|
9211
9305
|
* delegated events, and the cross-island api registry.
|
|
9212
9306
|
* @see README.md
|
|
@@ -9214,8 +9308,8 @@ const COMPONENT_HOOK_NAMES = [
|
|
|
9214
9308
|
/** Error prefix for spa fail-fast failures (spec/11 Part-3). */
|
|
9215
9309
|
const ERROR_PREFIX$2 = "[web]";
|
|
9216
9310
|
/** The set of legal hook names, frozen for O(1) membership checks. */
|
|
9217
|
-
const HOOK_NAME_SET = new Set(
|
|
9218
|
-
/** The spec-only keys that select the plugin-mirror form of {@link
|
|
9311
|
+
const HOOK_NAME_SET = new Set(ISLAND_HOOK_NAMES);
|
|
9312
|
+
/** The spec-only keys that select the plugin-mirror form of {@link createIsland}. */
|
|
9219
9313
|
const SPEC_KEYS = new Set([
|
|
9220
9314
|
"state",
|
|
9221
9315
|
"render",
|
|
@@ -9254,7 +9348,7 @@ let renderChunk;
|
|
|
9254
9348
|
let commitVNodeFunction;
|
|
9255
9349
|
/**
|
|
9256
9350
|
* Load the lazy `./render` chunk (once) and cache its `commitVNode` for synchronous
|
|
9257
|
-
* use by later renders. Awaited by a
|
|
9351
|
+
* use by later renders. Awaited by a island's `mountPromise` so the test harness's
|
|
9258
9352
|
* `settle()` can deterministically flush a VNode render.
|
|
9259
9353
|
*
|
|
9260
9354
|
* @returns A promise that resolves once `commitVNode` is available.
|
|
@@ -9262,7 +9356,7 @@ let commitVNodeFunction;
|
|
|
9262
9356
|
* await loadRenderChunk();
|
|
9263
9357
|
*/
|
|
9264
9358
|
async function loadRenderChunk() {
|
|
9265
|
-
renderChunk ??= Promise.resolve().then(() => require("./render-
|
|
9359
|
+
renderChunk ??= Promise.resolve().then(() => require("./render-DHUcHCYs.cjs"));
|
|
9266
9360
|
commitVNodeFunction = (await renderChunk).commitVNode;
|
|
9267
9361
|
}
|
|
9268
9362
|
/**
|
|
@@ -9271,7 +9365,7 @@ async function loadRenderChunk() {
|
|
|
9271
9365
|
* a Preact `VNode` → committed through the lazy gate (loading it on demand if needed).
|
|
9272
9366
|
*
|
|
9273
9367
|
* @param host - The island host element to render into.
|
|
9274
|
-
* @param result - The value returned by the
|
|
9368
|
+
* @param result - The value returned by the island's `render`.
|
|
9275
9369
|
* @example
|
|
9276
9370
|
* commitResult(host, h(View, { items }));
|
|
9277
9371
|
*/
|
|
@@ -9293,7 +9387,7 @@ function commitResult(host, result) {
|
|
|
9293
9387
|
loadRenderChunk().then(() => commitVNodeFunction?.(vnode, host)).catch(() => {});
|
|
9294
9388
|
}
|
|
9295
9389
|
/**
|
|
9296
|
-
* Run a
|
|
9390
|
+
* Run a island's `render(state, ctx)` and commit the result now. Guards against
|
|
9297
9391
|
* synchronous re-entrancy (a render that calls `ctx.flush`) with a depth cap.
|
|
9298
9392
|
*
|
|
9299
9393
|
* @param instance - The instance to render.
|
|
@@ -9304,7 +9398,7 @@ function commitResult(host, result) {
|
|
|
9304
9398
|
function runRender(instance) {
|
|
9305
9399
|
const render = instance.def.spec?.render;
|
|
9306
9400
|
if (!render) return;
|
|
9307
|
-
if (instance.renderDepth > MAX_RENDER_DEPTH) throw new Error(`${ERROR_PREFIX$2}
|
|
9401
|
+
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)`);
|
|
9308
9402
|
instance.renderDepth += 1;
|
|
9309
9403
|
try {
|
|
9310
9404
|
commitResult(instance.el, render(instance.state ?? {}, instance.ctx));
|
|
@@ -9330,12 +9424,12 @@ function scheduleRender(instance) {
|
|
|
9330
9424
|
});
|
|
9331
9425
|
}
|
|
9332
9426
|
/**
|
|
9333
|
-
* Build the single per-instance {@link
|
|
9427
|
+
* Build the single per-instance {@link IslandContext} reused by every hook, event
|
|
9334
9428
|
* handler, and render. Route fields (`params`/`meta`/`locale`/`url`) and `data` read
|
|
9335
9429
|
* through the instance so a navigation update is reflected without rebuilding the ctx;
|
|
9336
|
-
* `state`/`set`/`flush`/`cleanup`/`
|
|
9430
|
+
* `state`/`set`/`flush`/`cleanup`/`island` are bound to the instance + plugin state.
|
|
9337
9431
|
*
|
|
9338
|
-
* @param state - The plugin state (for the cross-island `
|
|
9432
|
+
* @param state - The plugin state (for the cross-island `island` resolver).
|
|
9339
9433
|
* @param instance - The instance the context is bound to.
|
|
9340
9434
|
* @returns The instance-bound context.
|
|
9341
9435
|
* @example
|
|
@@ -9439,13 +9533,13 @@ function buildContext(state, instance) {
|
|
|
9439
9533
|
/**
|
|
9440
9534
|
* Resolve another island's registered api by name (`undefined` when absent).
|
|
9441
9535
|
*
|
|
9442
|
-
* @param name - The provider island's
|
|
9536
|
+
* @param name - The provider island's island name.
|
|
9443
9537
|
* @returns The provider's api, or `undefined`.
|
|
9444
9538
|
* @example
|
|
9445
|
-
* ctx.
|
|
9539
|
+
* ctx.island("lightbox");
|
|
9446
9540
|
*/
|
|
9447
|
-
|
|
9448
|
-
return state.
|
|
9541
|
+
island(name) {
|
|
9542
|
+
return state.islandApis.get(name);
|
|
9449
9543
|
}
|
|
9450
9544
|
};
|
|
9451
9545
|
}
|
|
@@ -9469,7 +9563,7 @@ function matchTarget(host, event, selector) {
|
|
|
9469
9563
|
return matched && host.contains(matched) ? matched : void 0;
|
|
9470
9564
|
}
|
|
9471
9565
|
/**
|
|
9472
|
-
* Attach a
|
|
9566
|
+
* Attach a island's declarative `events` map: one real listener per event TYPE on
|
|
9473
9567
|
* the host (dispatch walks `closest(selector)` for each registered selector), each
|
|
9474
9568
|
* removed via the instance's cleanup registry on destroy.
|
|
9475
9569
|
*
|
|
@@ -9486,7 +9580,7 @@ function attachEvents(instance, events) {
|
|
|
9486
9580
|
const space = key.indexOf(" ");
|
|
9487
9581
|
const type = (space === -1 ? key : key.slice(0, space)).trim();
|
|
9488
9582
|
const selector = space === -1 ? "" : key.slice(space + 1).trim();
|
|
9489
|
-
if (type === "") throw new Error(`${ERROR_PREFIX$2}
|
|
9583
|
+
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]")`);
|
|
9490
9584
|
const list = byType.get(type) ?? [];
|
|
9491
9585
|
list.push({
|
|
9492
9586
|
selector,
|
|
@@ -9509,62 +9603,62 @@ function attachEvents(instance, events) {
|
|
|
9509
9603
|
* Validate a single hook entry: its key must be a known hook name and its value
|
|
9510
9604
|
* must be a function. Throws fail-fast on the first violation.
|
|
9511
9605
|
*
|
|
9512
|
-
* @param
|
|
9606
|
+
* @param islandName - The owning island name (for error messages).
|
|
9513
9607
|
* @param source - The raw authoring object being validated.
|
|
9514
9608
|
* @param key - The hook key to validate.
|
|
9515
|
-
* @throws {Error} If `key` is not in `
|
|
9609
|
+
* @throws {Error} If `key` is not in `ISLAND_HOOK_NAMES`.
|
|
9516
9610
|
* @throws {TypeError} If the hook value is not a function.
|
|
9517
9611
|
* @example
|
|
9518
9612
|
* validateHookEntry("counter", source, "onMount");
|
|
9519
9613
|
*/
|
|
9520
|
-
function validateHookEntry(
|
|
9521
|
-
if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown
|
|
9522
|
-
if (typeof source[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2}
|
|
9614
|
+
function validateHookEntry(islandName, source, key) {
|
|
9615
|
+
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`);
|
|
9616
|
+
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`);
|
|
9523
9617
|
}
|
|
9524
9618
|
/**
|
|
9525
9619
|
* Validate the spec extras (`state`/`render`/`api` must be functions; `events` must be
|
|
9526
9620
|
* a plain object of functions). Throws fail-fast on the first violation.
|
|
9527
9621
|
*
|
|
9528
|
-
* @param
|
|
9622
|
+
* @param islandName - The owning island name (for error messages).
|
|
9529
9623
|
* @param extras - The partitioned spec extras to validate.
|
|
9530
9624
|
* @throws {TypeError} If a present extra has the wrong shape.
|
|
9531
9625
|
* @example
|
|
9532
9626
|
* validateSpecExtras("board", { state: () => ({}) });
|
|
9533
9627
|
*/
|
|
9534
|
-
function validateSpecExtras(
|
|
9628
|
+
function validateSpecExtras(islandName, extras) {
|
|
9535
9629
|
for (const key of [
|
|
9536
9630
|
"state",
|
|
9537
9631
|
"render",
|
|
9538
9632
|
"api"
|
|
9539
|
-
]) if (extras[key] !== void 0 && typeof extras[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2}
|
|
9633
|
+
]) 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`);
|
|
9540
9634
|
if (extras.events !== void 0) {
|
|
9541
9635
|
const events = extras.events;
|
|
9542
|
-
if (!(typeof events === "object")) throw new TypeError(`${ERROR_PREFIX$2}
|
|
9543
|
-
for (const [key, handler] of Object.entries(events)) if (typeof handler !== "function") throw new TypeError(`${ERROR_PREFIX$2}
|
|
9636
|
+
if (!(typeof events === "object")) throw new TypeError(`${ERROR_PREFIX$2} island "events" on "${islandName}" must be an object of handlers`);
|
|
9637
|
+
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`);
|
|
9544
9638
|
}
|
|
9545
9639
|
}
|
|
9546
9640
|
/**
|
|
9547
|
-
* Create a validated
|
|
9548
|
-
* (`
|
|
9549
|
-
* (`
|
|
9641
|
+
* Create a validated island definition. Accepts either the legacy hooks-only form
|
|
9642
|
+
* (`createIsland("counter", { onMount() {} })`) or the plugin-mirror spec form
|
|
9643
|
+
* (`createIsland("board", { state, render, events, api, ...hooks })`). Spec-only
|
|
9550
9644
|
* keys (`state`/`render`/`events`/`api`) are partitioned out before hook-name
|
|
9551
9645
|
* validation, so a real typo (e.g. `onMout`) still throws immediately while the spec
|
|
9552
9646
|
* keys are accepted.
|
|
9553
9647
|
*
|
|
9554
|
-
* @param name - Unique
|
|
9648
|
+
* @param name - Unique island name.
|
|
9555
9649
|
* @param spec - Lifecycle hooks, or the `{ state, render, events, api, ...hooks }` spec.
|
|
9556
|
-
* @returns A `
|
|
9650
|
+
* @returns A `IslandDef` ready to `register`.
|
|
9557
9651
|
* @throws {Error} If `name` is empty, a hook key is unknown, or an extra/hook value has the wrong shape.
|
|
9558
9652
|
* @example
|
|
9559
|
-
* const counter =
|
|
9653
|
+
* const counter = createIsland("counter", { onMount({ el }) { el.textContent = "0"; } });
|
|
9560
9654
|
* @example
|
|
9561
|
-
* const list =
|
|
9655
|
+
* const list = createIsland<{ items: string[] }>("list", {
|
|
9562
9656
|
* state: () => ({ items: [] }),
|
|
9563
9657
|
* render: (s) => h(List, { items: s.items })
|
|
9564
9658
|
* });
|
|
9565
9659
|
*/
|
|
9566
|
-
function
|
|
9567
|
-
if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2}
|
|
9660
|
+
function createIsland(name, spec) {
|
|
9661
|
+
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)`);
|
|
9568
9662
|
const source = spec;
|
|
9569
9663
|
const hooks = {};
|
|
9570
9664
|
const extras = {};
|
|
@@ -9641,30 +9735,30 @@ function disposeInstance(state, instance) {
|
|
|
9641
9735
|
} catch {}
|
|
9642
9736
|
instance.cleanups.length = 0;
|
|
9643
9737
|
instance.renderScheduled = false;
|
|
9644
|
-
if (instance.api !== void 0 && state.
|
|
9738
|
+
if (instance.api !== void 0 && state.islandApis.get(instance.def.name) === instance.api) state.islandApis.delete(instance.def.name);
|
|
9645
9739
|
}
|
|
9646
9740
|
/**
|
|
9647
|
-
* Mounts a single `data-
|
|
9741
|
+
* Mounts a single `data-island` element: classifies persistent vs page-specific,
|
|
9648
9742
|
* builds the instance + its bound context, initializes per-instance `state`, registers
|
|
9649
9743
|
* its `api`, attaches declarative `events`, fires `onCreate` then `onMount` (capturing
|
|
9650
9744
|
* an async `onMount` + render-chunk load as `mountPromise`), schedules the initial
|
|
9651
|
-
* render, records it, and emits `spa:
|
|
9652
|
-
* mounted, has no
|
|
9745
|
+
* render, records it, and emits `spa:island-mount`. No-ops if the element is already
|
|
9746
|
+
* mounted, has no island name, or names an unregistered island.
|
|
9653
9747
|
*
|
|
9654
|
-
* @param state - The plugin state (
|
|
9655
|
-
* @param emit - The event emitter for spa:
|
|
9748
|
+
* @param state - The plugin state (registeredIslands + instances + islandApis).
|
|
9749
|
+
* @param emit - The event emitter for spa:island-mount.
|
|
9656
9750
|
* @param swapArea - The swap-region element, or null when none was found.
|
|
9657
9751
|
* @param data - The current page data payload.
|
|
9658
|
-
* @param element - The candidate element carrying a `data-
|
|
9752
|
+
* @param element - The candidate element carrying a `data-island` attribute.
|
|
9659
9753
|
* @param route - The matched-route slice for the current URL (params/meta/locale/url).
|
|
9660
9754
|
* @example
|
|
9661
9755
|
* mountElement(state, emit, swapArea, data, element, route);
|
|
9662
9756
|
*/
|
|
9663
9757
|
function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE) {
|
|
9664
9758
|
if (state.instances.has(element)) return;
|
|
9665
|
-
const name = element.dataset.
|
|
9759
|
+
const name = element.dataset.island;
|
|
9666
9760
|
if (!name) return;
|
|
9667
|
-
const definition = state.
|
|
9761
|
+
const definition = state.registeredIslands.get(name);
|
|
9668
9762
|
if (!definition) return;
|
|
9669
9763
|
const instance = {
|
|
9670
9764
|
def: definition,
|
|
@@ -9690,7 +9784,7 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
|
|
|
9690
9784
|
if (spec?.state) instance.state = spec.state(instance.ctx);
|
|
9691
9785
|
if (spec?.api) {
|
|
9692
9786
|
instance.api = spec.api(instance.ctx);
|
|
9693
|
-
state.
|
|
9787
|
+
state.islandApis.set(definition.name, instance.api);
|
|
9694
9788
|
}
|
|
9695
9789
|
if (spec?.events) attachEvents(instance, spec.events);
|
|
9696
9790
|
runHook(instance, "onCreate");
|
|
@@ -9701,20 +9795,20 @@ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE)
|
|
|
9701
9795
|
if (onMountResult && typeof onMountResult.then === "function") pending.push(onMountResult);
|
|
9702
9796
|
instance.mountPromise = pending.length > 0 ? Promise.all(pending).then(() => {}) : void 0;
|
|
9703
9797
|
state.instances.set(element, instance);
|
|
9704
|
-
emit("spa:
|
|
9798
|
+
emit("spa:island-mount", {
|
|
9705
9799
|
name: definition.name,
|
|
9706
9800
|
el: element
|
|
9707
9801
|
});
|
|
9708
9802
|
}
|
|
9709
9803
|
/**
|
|
9710
|
-
* Scans the swap region, mounts
|
|
9804
|
+
* Scans the swap region, mounts islands for matching `data-island` elements,
|
|
9711
9805
|
* classifies persistent (outside swap area) vs page-specific (inside), runs
|
|
9712
|
-
* `onCreate`/`onMount` + initial render, and emits `spa:
|
|
9806
|
+
* `onCreate`/`onMount` + initial render, and emits `spa:island-mount` per instance.
|
|
9713
9807
|
* Already-mounted elements are skipped.
|
|
9714
9808
|
*
|
|
9715
|
-
* @param state - The plugin state (
|
|
9716
|
-
* @param emit - The event emitter for spa:
|
|
9717
|
-
* @param swapSelector - CSS selector bounding page-specific
|
|
9809
|
+
* @param state - The plugin state (registeredIslands + instances + islandApis).
|
|
9810
|
+
* @param emit - The event emitter for spa:island-mount.
|
|
9811
|
+
* @param swapSelector - CSS selector bounding page-specific islands.
|
|
9718
9812
|
* @param route - The matched-route slice for the current URL (params/meta/locale/url).
|
|
9719
9813
|
* @example
|
|
9720
9814
|
* scanAndMount(state, emit, "main > section", route);
|
|
@@ -9723,16 +9817,16 @@ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
|
|
|
9723
9817
|
if (typeof document === "undefined") return;
|
|
9724
9818
|
const swapArea = document.querySelector(swapSelector);
|
|
9725
9819
|
const data = extractPageData(document);
|
|
9726
|
-
for (const element of document.querySelectorAll("[data-
|
|
9820
|
+
for (const element of document.querySelectorAll("[data-island]")) mountElement(state, emit, swapArea, data, element, route);
|
|
9727
9821
|
}
|
|
9728
9822
|
/**
|
|
9729
9823
|
* Unmounts page-specific instances inside the swap region (runs `onUnMount` then
|
|
9730
9824
|
* `onDestroy`, then their cleanup disposers + api unregister), removes them from state,
|
|
9731
|
-
* and emits `spa:
|
|
9825
|
+
* and emits `spa:island-unmount`. Persistent instances (outside the swap area) are
|
|
9732
9826
|
* left in place.
|
|
9733
9827
|
*
|
|
9734
9828
|
* @param state - The plugin state holding live instances.
|
|
9735
|
-
* @param emit - The event emitter for spa:
|
|
9829
|
+
* @param emit - The event emitter for spa:island-unmount.
|
|
9736
9830
|
* @example
|
|
9737
9831
|
* unmountPageSpecific(state, emit);
|
|
9738
9832
|
*/
|
|
@@ -9745,7 +9839,7 @@ function unmountPageSpecific(state, emit) {
|
|
|
9745
9839
|
runHook(instance, "onDestroy");
|
|
9746
9840
|
disposeInstance(state, instance);
|
|
9747
9841
|
state.instances.delete(element);
|
|
9748
|
-
emit("spa:
|
|
9842
|
+
emit("spa:island-unmount", {
|
|
9749
9843
|
name: instance.def.name,
|
|
9750
9844
|
el: element
|
|
9751
9845
|
});
|
|
@@ -9754,11 +9848,11 @@ function unmountPageSpecific(state, emit) {
|
|
|
9754
9848
|
/**
|
|
9755
9849
|
* Disposes ALL live instances (persistent and page-specific) on teardown: runs
|
|
9756
9850
|
* `onUnMount` then `onDestroy`, then their cleanup disposers + api unregister, emits
|
|
9757
|
-
* `spa:
|
|
9851
|
+
* `spa:island-unmount`, and clears the instance + api maps. Used by the kernel's
|
|
9758
9852
|
* `dispose` on plugin stop.
|
|
9759
9853
|
*
|
|
9760
9854
|
* @param state - The plugin state holding live instances.
|
|
9761
|
-
* @param emit - The event emitter for spa:
|
|
9855
|
+
* @param emit - The event emitter for spa:island-unmount.
|
|
9762
9856
|
* @example
|
|
9763
9857
|
* unmountAll(state, emit);
|
|
9764
9858
|
*/
|
|
@@ -9769,13 +9863,13 @@ function unmountAll(state, emit) {
|
|
|
9769
9863
|
runHook(instance, "onUnMount");
|
|
9770
9864
|
runHook(instance, "onDestroy");
|
|
9771
9865
|
disposeInstance(state, instance);
|
|
9772
|
-
emit("spa:
|
|
9866
|
+
emit("spa:island-unmount", {
|
|
9773
9867
|
name: instance.def.name,
|
|
9774
9868
|
el: element
|
|
9775
9869
|
});
|
|
9776
9870
|
}
|
|
9777
9871
|
state.instances.clear();
|
|
9778
|
-
state.
|
|
9872
|
+
state.islandApis.clear();
|
|
9779
9873
|
}
|
|
9780
9874
|
/**
|
|
9781
9875
|
* Fires `onNavStart` on every currently-mounted instance (persistent instances
|
|
@@ -9812,80 +9906,6 @@ function notifyNavEnd(state, route = EMPTY_ROUTE) {
|
|
|
9812
9906
|
}
|
|
9813
9907
|
}
|
|
9814
9908
|
//#endregion
|
|
9815
|
-
//#region src/plugins/spa/head.ts
|
|
9816
|
-
/** Single-element head selectors synced by replace/append/remove on navigation. */
|
|
9817
|
-
const META_SELECTORS = [
|
|
9818
|
-
"meta[name=\"description\"]",
|
|
9819
|
-
"meta[property=\"og:title\"]",
|
|
9820
|
-
"meta[property=\"og:description\"]",
|
|
9821
|
-
"meta[property=\"og:url\"]",
|
|
9822
|
-
"meta[property=\"og:image\"]",
|
|
9823
|
-
"meta[property=\"og:type\"]",
|
|
9824
|
-
"meta[property=\"og:locale\"]",
|
|
9825
|
-
"meta[name=\"twitter:card\"]",
|
|
9826
|
-
"meta[name=\"twitter:title\"]",
|
|
9827
|
-
"meta[name=\"twitter:description\"]",
|
|
9828
|
-
"meta[name=\"twitter:image\"]",
|
|
9829
|
-
"meta[name=\"twitter:site\"]",
|
|
9830
|
-
"link[rel=\"canonical\"]"
|
|
9831
|
-
];
|
|
9832
|
-
/** Head element groups fully replaced (remove-all-then-clone) on navigation. */
|
|
9833
|
-
const REPLACE_ALL_SELECTORS = [
|
|
9834
|
-
"script[type=\"application/ld+json\"]",
|
|
9835
|
-
"link[rel=\"alternate\"][hreflang]",
|
|
9836
|
-
"meta[property^=\"article:\"]"
|
|
9837
|
-
];
|
|
9838
|
-
/**
|
|
9839
|
-
* Sync a single head element by selector between the fetched and live document:
|
|
9840
|
-
* replace when both exist, append when only the new doc has it, remove when only
|
|
9841
|
-
* the live doc has it.
|
|
9842
|
-
*
|
|
9843
|
-
* @param selector - CSS selector for the head element to sync.
|
|
9844
|
-
* @param doc - The fetched document (DOMParser-parsed).
|
|
9845
|
-
* @example
|
|
9846
|
-
* syncElement('link[rel="canonical"]', doc);
|
|
9847
|
-
*/
|
|
9848
|
-
function syncElement(selector, doc) {
|
|
9849
|
-
const newElement = doc.querySelector(selector);
|
|
9850
|
-
const oldElement = document.querySelector(selector);
|
|
9851
|
-
if (newElement && oldElement) oldElement.replaceWith(newElement.cloneNode(true));
|
|
9852
|
-
else if (newElement) document.head.append(newElement.cloneNode(true));
|
|
9853
|
-
else if (oldElement) oldElement.remove();
|
|
9854
|
-
}
|
|
9855
|
-
/**
|
|
9856
|
-
* Remove all live matches for a selector and re-clone the fetched document's
|
|
9857
|
-
* matches into the live `<head>`.
|
|
9858
|
-
*
|
|
9859
|
-
* @param selector - CSS selector for the element group to replace wholesale.
|
|
9860
|
-
* @param doc - The fetched document (DOMParser-parsed).
|
|
9861
|
-
* @example
|
|
9862
|
-
* replaceAllBySelector('script[type="application/ld+json"]', doc);
|
|
9863
|
-
*/
|
|
9864
|
-
function replaceAllBySelector(selector, doc) {
|
|
9865
|
-
for (const element of document.querySelectorAll(selector)) element.remove();
|
|
9866
|
-
for (const element of doc.querySelectorAll(selector)) document.head.append(element.cloneNode(true));
|
|
9867
|
-
}
|
|
9868
|
-
/**
|
|
9869
|
-
* Syncs the live document `<head>` after a navigation from the fetched document
|
|
9870
|
-
* (whose head was composed by the `head` plugin). Recomputes
|
|
9871
|
-
* title/meta/canonical/JSON-LD/hreflang/`<html lang>` once and applies them.
|
|
9872
|
-
* The `head` API is accepted to bind the structural dependency (spec/09 deps).
|
|
9873
|
-
*
|
|
9874
|
-
* @param _head - The head plugin API (dependency binding; composition reused via the fetched doc).
|
|
9875
|
-
* @param doc - The fetched document parsed from the navigated page's HTML.
|
|
9876
|
-
* @example
|
|
9877
|
-
* syncHead(headApi, parsedDoc);
|
|
9878
|
-
*/
|
|
9879
|
-
function syncHead(_head, doc) {
|
|
9880
|
-
if (typeof document === "undefined") return;
|
|
9881
|
-
const newTitle = doc.querySelector("title")?.textContent;
|
|
9882
|
-
if (newTitle) document.title = newTitle;
|
|
9883
|
-
const newLang = doc.documentElement.lang;
|
|
9884
|
-
if (newLang) document.documentElement.lang = newLang;
|
|
9885
|
-
for (const selector of META_SELECTORS) syncElement(selector, doc);
|
|
9886
|
-
for (const selector of REPLACE_ALL_SELECTORS) replaceAllBySelector(selector, doc);
|
|
9887
|
-
}
|
|
9888
|
-
//#endregion
|
|
9889
9909
|
//#region src/plugins/spa/progress.ts
|
|
9890
9910
|
/** Delay before the bar appears, so fast navigations show no indicator. */
|
|
9891
9911
|
const START_DELAY_MS = 150;
|
|
@@ -10292,7 +10312,7 @@ const defaultSpaConfig = {
|
|
|
10292
10312
|
swapSelector: "main > section",
|
|
10293
10313
|
viewTransitions: false,
|
|
10294
10314
|
progressBar: true,
|
|
10295
|
-
|
|
10315
|
+
islands: []
|
|
10296
10316
|
};
|
|
10297
10317
|
/**
|
|
10298
10318
|
* Whether a selector is syntactically valid (parseable by the DOM). Falls back
|
|
@@ -10314,8 +10334,8 @@ function isValidSelector(selector) {
|
|
|
10314
10334
|
}
|
|
10315
10335
|
/**
|
|
10316
10336
|
* Validates the spa config and applies defaults (Part-3 errors on an empty or
|
|
10317
|
-
* syntactically-invalid `swapSelector`).
|
|
10318
|
-
* `
|
|
10337
|
+
* syntactically-invalid `swapSelector`). Island-hook validation runs later in
|
|
10338
|
+
* `createIsland` when the islands are registered.
|
|
10319
10339
|
*
|
|
10320
10340
|
* @param config - The raw spa config to validate.
|
|
10321
10341
|
* @returns The fully-resolved config with defaults applied.
|
|
@@ -10331,7 +10351,7 @@ function resolveSpaConfig(config) {
|
|
|
10331
10351
|
swapSelector,
|
|
10332
10352
|
viewTransitions: config.viewTransitions ?? false,
|
|
10333
10353
|
progressBar: config.progressBar ?? true,
|
|
10334
|
-
|
|
10354
|
+
islands: config.islands ?? []
|
|
10335
10355
|
};
|
|
10336
10356
|
}
|
|
10337
10357
|
/**
|
|
@@ -10348,9 +10368,9 @@ function resolveSpaConfig(config) {
|
|
|
10348
10368
|
*/
|
|
10349
10369
|
function createState(_ctx) {
|
|
10350
10370
|
return {
|
|
10351
|
-
|
|
10371
|
+
registeredIslands: /* @__PURE__ */ new Map(),
|
|
10352
10372
|
instances: /* @__PURE__ */ new Map(),
|
|
10353
|
-
|
|
10373
|
+
islandApis: /* @__PURE__ */ new Map(),
|
|
10354
10374
|
currentUrl: "",
|
|
10355
10375
|
destroyRouter: null,
|
|
10356
10376
|
started: false,
|
|
@@ -10370,15 +10390,15 @@ function createState(_ctx) {
|
|
|
10370
10390
|
/** Error prefix for spa kernel failures (spec/11 Part-3). */
|
|
10371
10391
|
const ERROR_PREFIX = "[web]";
|
|
10372
10392
|
/**
|
|
10373
|
-
* Registers a
|
|
10393
|
+
* Registers a island definition into state (last-registered-wins).
|
|
10374
10394
|
*
|
|
10375
|
-
* @param state - The plugin state holding
|
|
10376
|
-
* @param
|
|
10395
|
+
* @param state - The plugin state holding registeredIslands.
|
|
10396
|
+
* @param island - The island definition to register.
|
|
10377
10397
|
* @example
|
|
10378
|
-
*
|
|
10398
|
+
* registerIsland(state, counter);
|
|
10379
10399
|
*/
|
|
10380
|
-
function
|
|
10381
|
-
state.
|
|
10400
|
+
function registerIsland(state, island) {
|
|
10401
|
+
state.registeredIslands.set(island.name, island);
|
|
10382
10402
|
}
|
|
10383
10403
|
/**
|
|
10384
10404
|
* Resolve the current document URL (pathname + search), or `""` when headless.
|
|
@@ -10453,15 +10473,15 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10453
10473
|
});
|
|
10454
10474
|
};
|
|
10455
10475
|
/**
|
|
10456
|
-
* Build the matched-route slice (params/meta/locale/url) for the
|
|
10476
|
+
* Build the matched-route slice (params/meta/locale/url) for the island context at `path`,
|
|
10457
10477
|
* so islands read their route's params/meta directly. An unmatched path yields an empty slice.
|
|
10458
10478
|
*
|
|
10459
10479
|
* @param path - The URL (pathname + search) to match.
|
|
10460
10480
|
* @returns The route slice for the matched route.
|
|
10461
10481
|
* @example
|
|
10462
|
-
* scanAndMount(state, emit, resolved.swapSelector,
|
|
10482
|
+
* scanAndMount(state, emit, resolved.swapSelector, islandRouteContext(pathname));
|
|
10463
10483
|
*/
|
|
10464
|
-
const
|
|
10484
|
+
const islandRouteContext = (path) => {
|
|
10465
10485
|
const matchPath = path.split("?")[0] ?? path;
|
|
10466
10486
|
const hit = deps.router.match(matchPath);
|
|
10467
10487
|
const locale = hit?.params.lang ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
|
|
@@ -10490,7 +10510,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10490
10510
|
syncHead(deps.head, doc);
|
|
10491
10511
|
unmountPageSpecific(state, emit);
|
|
10492
10512
|
if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
|
|
10493
|
-
const routeSlice =
|
|
10513
|
+
const routeSlice = islandRouteContext(pathname);
|
|
10494
10514
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10495
10515
|
notifyNavEnd(state, routeSlice);
|
|
10496
10516
|
}, applyPendingScroll)) {
|
|
@@ -10503,7 +10523,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10503
10523
|
emit("spa:navigated", { url: pathname });
|
|
10504
10524
|
};
|
|
10505
10525
|
/**
|
|
10506
|
-
* Begin a navigation: start progress, notify
|
|
10526
|
+
* Begin a navigation: start progress, notify islands, emit navigate.
|
|
10507
10527
|
*
|
|
10508
10528
|
* @param pathname - The destination pathname.
|
|
10509
10529
|
* @example
|
|
@@ -10589,11 +10609,11 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10589
10609
|
const commitDataRender = async (pathname, resolvedRender, signal) => {
|
|
10590
10610
|
if (signal?.aborted) return;
|
|
10591
10611
|
const { route, vnode, routeContext, region } = resolvedRender;
|
|
10592
|
-
const { renderVNode } = await Promise.resolve().then(() => require("./render-
|
|
10612
|
+
const { renderVNode } = await Promise.resolve().then(() => require("./render-DHUcHCYs.cjs"));
|
|
10593
10613
|
if (signal?.aborted) return;
|
|
10594
10614
|
syncDataHead(deps.head, route, routeContext);
|
|
10595
10615
|
unmountPageSpecific(state, emit);
|
|
10596
|
-
const routeSlice =
|
|
10616
|
+
const routeSlice = islandRouteContext(pathname);
|
|
10597
10617
|
/**
|
|
10598
10618
|
* Render the VNode into the region and re-mount its islands in one paint — the
|
|
10599
10619
|
* swap body handed to `runSwap` (optionally wrapped in a View Transition).
|
|
@@ -10651,14 +10671,14 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10651
10671
|
* await bootRender("/b/abc123");
|
|
10652
10672
|
*/
|
|
10653
10673
|
const bootRender = async (pathname) => {
|
|
10654
|
-
const routeSlice =
|
|
10674
|
+
const routeSlice = islandRouteContext(pathname);
|
|
10655
10675
|
const resolvedRender = await resolveDataRender(pathname);
|
|
10656
10676
|
if (resolvedRender === false) {
|
|
10657
10677
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10658
10678
|
return;
|
|
10659
10679
|
}
|
|
10660
10680
|
const { vnode, region } = resolvedRender;
|
|
10661
|
-
const { renderVNode } = await Promise.resolve().then(() => require("./render-
|
|
10681
|
+
const { renderVNode } = await Promise.resolve().then(() => require("./render-DHUcHCYs.cjs"));
|
|
10662
10682
|
renderVNode(vnode, region);
|
|
10663
10683
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10664
10684
|
};
|
|
@@ -10686,13 +10706,13 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10686
10706
|
};
|
|
10687
10707
|
return {
|
|
10688
10708
|
/**
|
|
10689
|
-
* Register config
|
|
10709
|
+
* Register config islands and seed currentUrl from the document.
|
|
10690
10710
|
*
|
|
10691
10711
|
* @example
|
|
10692
10712
|
* kernel.init();
|
|
10693
10713
|
*/
|
|
10694
10714
|
init() {
|
|
10695
|
-
for (const
|
|
10715
|
+
for (const island of resolved.islands) registerIsland(state, island);
|
|
10696
10716
|
state.currentUrl = currentLocationUrl();
|
|
10697
10717
|
},
|
|
10698
10718
|
/**
|
|
@@ -10710,18 +10730,18 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10710
10730
|
const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
|
|
10711
10731
|
const hit = deps.router.match(matchPath);
|
|
10712
10732
|
if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
|
|
10713
|
-
else scanAndMount(state, emit, resolved.swapSelector,
|
|
10733
|
+
else scanAndMount(state, emit, resolved.swapSelector, islandRouteContext(state.currentUrl));
|
|
10714
10734
|
state.started = true;
|
|
10715
10735
|
},
|
|
10716
10736
|
/**
|
|
10717
|
-
* Register a
|
|
10737
|
+
* Register a island definition (last-registered-wins).
|
|
10718
10738
|
*
|
|
10719
|
-
* @param
|
|
10739
|
+
* @param island - The island definition to register.
|
|
10720
10740
|
* @example
|
|
10721
10741
|
* kernel.register(counter);
|
|
10722
10742
|
*/
|
|
10723
|
-
register(
|
|
10724
|
-
|
|
10743
|
+
register(island) {
|
|
10744
|
+
registerIsland(state, island);
|
|
10725
10745
|
},
|
|
10726
10746
|
/**
|
|
10727
10747
|
* Process a navigation to `path` (fetch then swap; full reload on error).
|
|
@@ -10735,13 +10755,13 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10735
10755
|
navigate(path).catch(() => {});
|
|
10736
10756
|
},
|
|
10737
10757
|
/**
|
|
10738
|
-
* Scan the swap region and mount
|
|
10758
|
+
* Scan the swap region and mount islands for matching elements.
|
|
10739
10759
|
*
|
|
10740
10760
|
* @example
|
|
10741
10761
|
* kernel.scan();
|
|
10742
10762
|
*/
|
|
10743
10763
|
scan() {
|
|
10744
|
-
scanAndMount(state, emit, resolved.swapSelector,
|
|
10764
|
+
scanAndMount(state, emit, resolved.swapSelector, islandRouteContext(state.currentUrl));
|
|
10745
10765
|
},
|
|
10746
10766
|
/**
|
|
10747
10767
|
* Tear down router listeners, dispose all instances, reset boot state.
|
|
@@ -10760,7 +10780,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10760
10780
|
}
|
|
10761
10781
|
/**
|
|
10762
10782
|
* Builds the shared kernel from the plugin context, stores it on `ctx.state`,
|
|
10763
|
-
* and runs its init step (validate config, register config.
|
|
10783
|
+
* and runs its init step (validate config, register config.islands, seed
|
|
10764
10784
|
* currentUrl). Captures the OPTIONAL `data` reader when the `data` plugin is
|
|
10765
10785
|
* composed (enabling client DATA navigation) — resolved by instance via
|
|
10766
10786
|
* `ctx.require(dataPlugin)`, guarded by `ctx.has("data")` so `data` stays optional
|
|
@@ -10828,10 +10848,10 @@ function disposeSpa() {
|
|
|
10828
10848
|
/**
|
|
10829
10849
|
* @file `lazyEmbed` island — activates the static embed facades emitted by the
|
|
10830
10850
|
* content pipeline's `::embed` directive (pipeline/embed.ts). Mounts on every
|
|
10831
|
-
* `[data-
|
|
10851
|
+
* `[data-island="lazy-embed"]` figure; a click on the facade's button swaps
|
|
10832
10852
|
* it for the real `<iframe loading="lazy">`. Until that click the embedded
|
|
10833
10853
|
* document costs the page nothing — no request, no third-party JS, no
|
|
10834
|
-
* scroll-jacking. Register it in `pluginConfigs.spa.
|
|
10854
|
+
* scroll-jacking. Register it in `pluginConfigs.spa.islands`; all visual
|
|
10835
10855
|
* chrome (`.lazy-embed*` classes) is consumer CSS.
|
|
10836
10856
|
*/
|
|
10837
10857
|
/** CSS class on the injected `<iframe>` (consumer CSS sizes it). */
|
|
@@ -10884,7 +10904,7 @@ function onFacadeClick(event) {
|
|
|
10884
10904
|
* Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
|
|
10885
10905
|
* The companion of the content pipeline's `::embed` directive.
|
|
10886
10906
|
*/
|
|
10887
|
-
const lazyEmbed =
|
|
10907
|
+
const lazyEmbed = createIsland("lazy-embed", {
|
|
10888
10908
|
/**
|
|
10889
10909
|
* Bind the activation click handler when a facade mounts.
|
|
10890
10910
|
*
|
|
@@ -10910,18 +10930,18 @@ const lazyEmbed = createComponent("lazy-embed", {
|
|
|
10910
10930
|
//#region src/plugins/spa/index.ts
|
|
10911
10931
|
/**
|
|
10912
10932
|
* @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
|
|
10913
|
-
* domain files (kernel/router/head/progress/
|
|
10933
|
+
* domain files (kernel/router/head/progress/islands/lifecycle); index wires.
|
|
10914
10934
|
*
|
|
10915
10935
|
* Depends: router, head.
|
|
10916
|
-
* Emits: spa:navigate, spa:navigated, spa:
|
|
10936
|
+
* Emits: spa:navigate, spa:navigated, spa:island-mount, spa:island-unmount.
|
|
10917
10937
|
* @see README.md
|
|
10918
10938
|
*/
|
|
10919
10939
|
/**
|
|
10920
10940
|
* SPA plugin — progressive client-side navigation layered over the static site:
|
|
10921
10941
|
* swaps a page region on navigation, with an optional progress bar and View
|
|
10922
|
-
* Transitions. Register interactive islands with {@link
|
|
10923
|
-
* on router and head; emits `spa:navigate`, `spa:navigated`, `spa:
|
|
10924
|
-
* and `spa:
|
|
10942
|
+
* Transitions. Register interactive islands with {@link createIsland}. Depends
|
|
10943
|
+
* on router and head; emits `spa:navigate`, `spa:navigated`, `spa:island-mount`,
|
|
10944
|
+
* and `spa:island-unmount`.
|
|
10925
10945
|
*
|
|
10926
10946
|
* @example Enable view transitions and a custom swap region
|
|
10927
10947
|
* ```ts
|
|
@@ -11904,8 +11924,8 @@ function EmbedFacadeButton(props) {
|
|
|
11904
11924
|
//#region src/plugins/content/pipeline/embed.ts
|
|
11905
11925
|
/** CSS class on the `<figure>` facade wrapping each embed. */
|
|
11906
11926
|
const EMBED_FIGURE_CLASS = "lazy-embed";
|
|
11907
|
-
/** `data-
|
|
11908
|
-
const
|
|
11927
|
+
/** `data-island` name binding the facade to the `lazyEmbed` SPA island. */
|
|
11928
|
+
const EMBED_ISLAND_NAME = "lazy-embed";
|
|
11909
11929
|
/**
|
|
11910
11930
|
* Type guard for an `::embed` leaf directive.
|
|
11911
11931
|
*
|
|
@@ -12015,7 +12035,7 @@ function collectAttributes$1(attributes) {
|
|
|
12015
12035
|
* ```
|
|
12016
12036
|
*/
|
|
12017
12037
|
function embedFacadeHtml(facade, props, dimensions) {
|
|
12018
|
-
return `<figure class="${EMBED_FIGURE_CLASS}" data-
|
|
12038
|
+
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;"` : ""}>${(0, preact_render_to_string.renderToString)((0, preact.h)(facade, props))}</figure>`;
|
|
12019
12039
|
}
|
|
12020
12040
|
/**
|
|
12021
12041
|
* Normalize the provider's `embed` config value (`boolean | options`) to a plain
|
|
@@ -12128,7 +12148,7 @@ function GalleryTrack(props) {
|
|
|
12128
12148
|
*
|
|
12129
12149
|
* Rewrites `::gallery{src="./images/dir/" caption="…"}` leaf directives into a
|
|
12130
12150
|
* static swipeable image set at the mdast stage (BEFORE the remark-rehype bridge):
|
|
12131
|
-
* a framework-owned `<div class="gallery" data-
|
|
12151
|
+
* a framework-owned `<div class="gallery" data-island="gallery">` carrying the
|
|
12132
12152
|
* island hook, wrapping inner content rendered (at build time, to static markup)
|
|
12133
12153
|
* by a Preact component — the built-in {@link GalleryTrack} by default, or a
|
|
12134
12154
|
* consumer component via `gallery.component`.
|
|
@@ -12140,12 +12160,12 @@ function GalleryTrack(props) {
|
|
|
12140
12160
|
* transform reads `<contentDir>/<slug>/<src>` from disk, sorts its images, and
|
|
12141
12161
|
* resolves each to its shared `/<slug>/<dir>/<file>` URL (identical from every
|
|
12142
12162
|
* locale page, mirroring co-located images). The companion gallery SPA island
|
|
12143
|
-
* (consumer-provided) wires swipe/keyboard/lightbox on `[data-
|
|
12163
|
+
* (consumer-provided) wires swipe/keyboard/lightbox on `[data-island="gallery"]`.
|
|
12144
12164
|
*/
|
|
12145
12165
|
/** CSS class on the `<div>` wrapping each gallery. */
|
|
12146
12166
|
const GALLERY_WRAPPER_CLASS = "gallery";
|
|
12147
|
-
/** `data-
|
|
12148
|
-
const
|
|
12167
|
+
/** `data-island` name binding the gallery to its SPA island. */
|
|
12168
|
+
const GALLERY_ISLAND_NAME = "gallery";
|
|
12149
12169
|
/** Image file extensions a gallery folder expands over. */
|
|
12150
12170
|
const IMAGE_EXTENSIONS = new Set([
|
|
12151
12171
|
".webp",
|
|
@@ -12238,7 +12258,7 @@ function collectAttributes(attributes) {
|
|
|
12238
12258
|
}
|
|
12239
12259
|
/**
|
|
12240
12260
|
* Build the static gallery HTML for one directive: the framework-owned `<div>`
|
|
12241
|
-
* (island hook in `data-
|
|
12261
|
+
* (island hook in `data-island`) wrapping the component's inner content, SSR'd
|
|
12242
12262
|
* to static markup.
|
|
12243
12263
|
*
|
|
12244
12264
|
* @param component - The gallery component (default {@link GalleryTrack}).
|
|
@@ -12252,7 +12272,7 @@ function collectAttributes(attributes) {
|
|
|
12252
12272
|
* ```
|
|
12253
12273
|
*/
|
|
12254
12274
|
function galleryHtml(component, slides, caption, attributes) {
|
|
12255
|
-
return `<div class="${GALLERY_WRAPPER_CLASS}" data-
|
|
12275
|
+
return `<div class="${GALLERY_WRAPPER_CLASS}" data-island="${GALLERY_ISLAND_NAME}">${(0, preact_render_to_string.renderToString)((0, preact.h)(component, {
|
|
12256
12276
|
slides,
|
|
12257
12277
|
caption,
|
|
12258
12278
|
attributes
|
|
@@ -13237,7 +13257,7 @@ Object.defineProperty(exports, "cloudflareBindings", {
|
|
|
13237
13257
|
});
|
|
13238
13258
|
exports.contentPlugin = contentPlugin;
|
|
13239
13259
|
exports.createApp = createApp;
|
|
13240
|
-
exports.
|
|
13260
|
+
exports.createIsland = createIsland;
|
|
13241
13261
|
exports.createPlugin = createPlugin;
|
|
13242
13262
|
exports.createUrls = createUrls;
|
|
13243
13263
|
exports.dataPlugin = dataPlugin;
|