@moku-labs/web 1.15.0 → 1.16.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.
@@ -871,7 +871,7 @@ type Api$1 = {
871
871
  composeTitle(head: HeadConfig | undefined): string;
872
872
  };
873
873
  declare namespace types_d_exports$4 {
874
- export { COMPONENT_HOOK_NAMES, ComponentContext, ComponentDef, ComponentHooks, ComponentInstance, ExtractApi, PageData, ResolvedSpaConfig, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState };
874
+ export { COMPONENT_HOOK_NAMES, ComponentContext, ComponentDef, ComponentEventHandler, ComponentEvents, ComponentHooks, ComponentInstance, ComponentRender, ComponentRouteSlice, ComponentSpec, ComponentSpecExtras, ComponentStateFactory, ExtractApi, PageData, RenderResult, ResolvedSpaConfig, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState };
875
875
  }
876
876
  /** Payload map for the events `spa` emits, used to type the kernel's `emit` closure. */
877
877
  type SpaEvents = {
@@ -972,12 +972,77 @@ interface ResolvedSpaConfig {
972
972
  components: ComponentDef[];
973
973
  }
974
974
  /**
975
- * Context handed to every component lifecycle hook — the bound element + page data,
976
- * plus the matched route's `params`/`meta`/`locale` and a link builder, so an island
977
- * can read its route context (e.g. a `card` route's `ctx.meta.focus` + `ctx.params.id`)
978
- * directly, without the page bridging it through `data-*` attributes.
975
+ * What a component's `render` may return:
976
+ * - a Preact `VNode` committed into the host through the lazy Preact gate (`commitVNode`);
977
+ * - a `Node` replaces the host's children;
978
+ * - a `string` set as the host's `innerHTML`;
979
+ * - `void`/`undefined` — the render mutated the DOM itself (DOM-only islands → no Preact loaded).
979
980
  */
980
- interface ComponentContext {
981
+ type RenderResult = import("preact").VNode | Node | string | void;
982
+ /**
983
+ * Factory that builds a component's typed per-instance state (mirrors a plugin's
984
+ * `createState`). Called ONCE at mount; the returned object is stored on the
985
+ * {@link ComponentInstance} and exposed read-only as `ctx.state`.
986
+ *
987
+ * @param ctx - The component context for this instance (state is not yet set).
988
+ * @returns The initial per-instance state.
989
+ * @example
990
+ * state: (ctx): BoardState => ({ boardId: ctx.params.id ?? "", cards: [] })
991
+ */
992
+ type ComponentStateFactory<S extends object> = (ctx: ComponentContext<S>) => S;
993
+ /**
994
+ * Pure render of `(state, ctx)` → {@link RenderResult}. Called after mount-state-init
995
+ * and again (microtask-batched) after every `ctx.set`. Must be free of side effects
996
+ * beyond producing its result.
997
+ *
998
+ * @param state - The current per-instance state (read-only).
999
+ * @param ctx - The component context for this instance.
1000
+ * @returns The render result to commit into the host.
1001
+ * @example
1002
+ * render: (state) => h(BoardView, { snapshot: state.snapshot })
1003
+ */
1004
+ type ComponentRender<S extends object> = (state: Readonly<S>, ctx: ComponentContext<S>) => RenderResult;
1005
+ /**
1006
+ * A delegated DOM event handler. `target` is the element matched by the key's selector
1007
+ * (already resolved via `closest` — no `instanceof`/`closest` ceremony in the body).
1008
+ *
1009
+ * Typed `void` for ergonomics (the void-return rule accepts async handlers returning
1010
+ * `Promise<void>` too); the kernel ignores any returned value.
1011
+ *
1012
+ * @param ctx - The component context (carries the live per-instance `state`).
1013
+ * @param event - The raw DOM event.
1014
+ * @param target - The element matched by the selector (the host when no selector).
1015
+ * @returns void (a returned promise is ignored by the kernel).
1016
+ * @example
1017
+ * (ctx, event, button) => { event.preventDefault(); ctx.set({ open: true }); }
1018
+ */
1019
+ type ComponentEventHandler<S extends object> = (ctx: ComponentContext<S>, event: Event, target: Element) => void;
1020
+ /**
1021
+ * Declarative delegated event map. Each key is `"<type> <selector>"` (the selector is
1022
+ * optional → a host-level listener). ONE real listener per event TYPE is attached to
1023
+ * the host; dispatch walks `event.target.closest(selector)` within the host. All
1024
+ * listeners are auto-removed on destroy.
1025
+ *
1026
+ * @example
1027
+ * events: {
1028
+ * "click [data-action='delete']": (ctx, _e, btn) => ctx.set(removeCard(ctx.state, btn)),
1029
+ * "submit [data-add]": (ctx, e) => { e.preventDefault(); add(ctx); }
1030
+ * }
1031
+ */
1032
+ type ComponentEvents<S extends object> = Record<string, ComponentEventHandler<S>>;
1033
+ /**
1034
+ * Context handed to every component lifecycle hook, render, and event handler — the
1035
+ * bound element + page data, plus the matched route's `params`/`meta`/`locale` and a
1036
+ * link builder, so an island can read its route context (e.g. a `card` route's
1037
+ * `ctx.meta.focus` + `ctx.params.id`) directly, without the page bridging it through
1038
+ * `data-*` attributes.
1039
+ *
1040
+ * Generic over the per-instance state `S` (default `undefined` so every existing
1041
+ * hooks-only island still type-checks). The additive members (`state`/`set`/`flush`/
1042
+ * `cleanup`/`component`) are ALWAYS-PRESENT functions — never optional keys — so they
1043
+ * never trip `exactOptionalPropertyTypes`.
1044
+ */
1045
+ interface ComponentContext<S = undefined> {
981
1046
  /** The element the component instance is bound to. */
982
1047
  el: Element;
983
1048
  /** Page data extracted from the `script#__DATA__` payload. */
@@ -990,9 +1055,52 @@ interface ComponentContext {
990
1055
  readonly locale: string;
991
1056
  /** Build a link to a named route by pattern substitution (same output as `app.router.toUrl`). */
992
1057
  readonly url: (name: string, params?: Record<string, string>) => string;
1058
+ /** The live per-instance state (the object returned by `spec.state`). `undefined` for legacy hooks-only islands. */
1059
+ readonly state: S;
1060
+ /**
1061
+ * Merge a patch into the per-instance state, then schedule ONE batched render.
1062
+ * Accepts a partial object or an updater `(prev) => partial`. A no-op for legacy
1063
+ * islands with no `state`/`render`.
1064
+ *
1065
+ * @param patch - A partial state object, or an updater returning one.
1066
+ * @returns void
1067
+ * @example
1068
+ * ctx.set({ open: true });
1069
+ * ctx.set(prev => ({ count: prev.count + 1 }));
1070
+ */
1071
+ set(patch: Partial<S> | ((prev: Readonly<S>) => Partial<S>)): void;
1072
+ /**
1073
+ * Force a synchronous render now (drains any pending scheduled render). Rarely
1074
+ * needed in app code — `ctx.set` already schedules one; mainly a test seam.
1075
+ *
1076
+ * @returns void
1077
+ * @example
1078
+ * ctx.flush();
1079
+ */
1080
+ flush(): void;
1081
+ /**
1082
+ * Register a disposer run on `onDestroy` (subscriptions, timers, manual/global
1083
+ * listeners the declarative `events` map cannot cover). Disposers run LIFO.
1084
+ *
1085
+ * @param dispose - The teardown function.
1086
+ * @returns void
1087
+ * @example
1088
+ * ctx.cleanup(onPatch(p => applyPatch(ctx, p)));
1089
+ */
1090
+ cleanup(dispose: () => void): void;
1091
+ /**
1092
+ * Resolve another island's registered `api` by name. Returns `undefined` when no
1093
+ * provider is registered (optional-dependency semantics, mirroring `ctx.has`).
1094
+ *
1095
+ * @param name - The provider island's component name.
1096
+ * @returns The provider's api, or `undefined`.
1097
+ * @example
1098
+ * ctx.component<LightboxApi>("lightbox")?.open(slides, index);
1099
+ */
1100
+ component<T = unknown>(name: string): T | undefined;
993
1101
  }
994
- /** Lifecycle hooks a component may implement. */
995
- interface ComponentHooks {
1102
+ /** Lifecycle hooks a component may implement. Generic over the per-instance state `S`. */
1103
+ interface ComponentHooks<S = undefined> {
996
1104
  /**
997
1105
  * Called once when the instance is created (before DOM attach).
998
1106
  *
@@ -1001,7 +1109,7 @@ interface ComponentHooks {
1001
1109
  * @example
1002
1110
  * onCreate({ el }) { el.dataset.ready = "1"; }
1003
1111
  */
1004
- onCreate?(ctx: ComponentContext): void;
1112
+ onCreate?(ctx: ComponentContext<S>): void;
1005
1113
  /**
1006
1114
  * Called after the instance is attached to its element.
1007
1115
  *
@@ -1009,8 +1117,10 @@ interface ComponentHooks {
1009
1117
  * @returns void
1010
1118
  * @example
1011
1119
  * onMount({ el }) { el.textContent = "0"; }
1120
+ * @example
1121
+ * async onMount(ctx) { ctx.set({ items: await load() }); } // async is allowed; the harness awaits it via settle()
1012
1122
  */
1013
- onMount?(ctx: ComponentContext): void;
1123
+ onMount?(ctx: ComponentContext<S>): void;
1014
1124
  /**
1015
1125
  * Called when a navigation begins while this instance is mounted.
1016
1126
  *
@@ -1019,7 +1129,7 @@ interface ComponentHooks {
1019
1129
  * @example
1020
1130
  * onNavStart({ el }) { el.dataset.loading = ""; }
1021
1131
  */
1022
- onNavStart?(ctx: ComponentContext): void;
1132
+ onNavStart?(ctx: ComponentContext<S>): void;
1023
1133
  /**
1024
1134
  * Called when a navigation completes while this instance is mounted.
1025
1135
  *
@@ -1028,7 +1138,7 @@ interface ComponentHooks {
1028
1138
  * @example
1029
1139
  * onNavEnd({ el }) { delete el.dataset.loading; }
1030
1140
  */
1031
- onNavEnd?(ctx: ComponentContext): void;
1141
+ onNavEnd?(ctx: ComponentContext<S>): void;
1032
1142
  /**
1033
1143
  * Called before the instance is detached from its element.
1034
1144
  *
@@ -1037,7 +1147,7 @@ interface ComponentHooks {
1037
1147
  * @example
1038
1148
  * onUnMount({ el }) { el.replaceChildren(); }
1039
1149
  */
1040
- onUnMount?(ctx: ComponentContext): void;
1150
+ onUnMount?(ctx: ComponentContext<S>): void;
1041
1151
  /**
1042
1152
  * Called once when the instance is destroyed (after detach).
1043
1153
  *
@@ -1046,17 +1156,60 @@ interface ComponentHooks {
1046
1156
  * @example
1047
1157
  * onDestroy({ el }) { delete el.dataset.ready; }
1048
1158
  */
1049
- onDestroy?(ctx: ComponentContext): void;
1159
+ onDestroy?(ctx: ComponentContext<S>): void;
1050
1160
  }
1051
1161
  /** Allowed hook names — single source of truth for fail-fast validation. */
1052
1162
  declare const COMPONENT_HOOK_NAMES: readonly ["onCreate", "onMount", "onNavStart", "onNavEnd", "onUnMount", "onDestroy"];
1053
- /** A registered component definition. */
1163
+ /**
1164
+ * The plugin-mirror authoring form for {@link createComponent}: typed per-instance
1165
+ * `state`, `render`, declarative `events`, and a cross-island `api` on top of the
1166
+ * lifecycle hooks. All keys optional + additive; the presence of any spec-only key
1167
+ * (`state`/`render`/`events`/`api`) selects the spec overload of `createComponent`.
1168
+ *
1169
+ * @example
1170
+ * createComponent<{ boards: Board[] }>("board-list", {
1171
+ * state: () => ({ boards: [] }),
1172
+ * async onMount(ctx) { ctx.set({ boards: await ctx.component<Api>("api")!.list() }); },
1173
+ * render: (s) => h(BoardList, { boards: s.boards }),
1174
+ * events: { "submit [data-create]": (ctx, e) => { e.preventDefault(); create(ctx); } }
1175
+ * });
1176
+ */
1177
+ interface ComponentSpec<S extends object = object, A = unknown> extends ComponentHooks<S> {
1178
+ /** Build typed per-instance state at mount (stored on the instance, not a module WeakMap). */
1179
+ state?: ComponentStateFactory<S>;
1180
+ /** Pure render re-invoked (microtask-batched) on every `ctx.set`. */
1181
+ render?: ComponentRender<S>;
1182
+ /** Declarative delegated DOM events with auto-teardown. */
1183
+ events?: ComponentEvents<S>;
1184
+ /** Public api factory — registered under the component name; reached via `app.spa.component(name)`. */
1185
+ api?: (ctx: ComponentContext<S>) => A;
1186
+ }
1187
+ /**
1188
+ * The spec extras carried on a {@link ComponentDef}, type-erased to `object` state
1189
+ * (authors keep full `S` inference at the `createComponent` call site; the registry
1190
+ * stores the runtime-only erased form). Absent for legacy `(name, hooks)` defs.
1191
+ */
1192
+ interface ComponentSpecExtras {
1193
+ /** Per-instance state factory. */
1194
+ state?: ComponentStateFactory<object>;
1195
+ /** Render called on mount + after every `ctx.set`. */
1196
+ render?: ComponentRender<object>;
1197
+ /** Declarative delegated events. */
1198
+ events?: ComponentEvents<object>;
1199
+ /** Public api factory registered under the component name. */
1200
+ api?: (ctx: ComponentContext<object>) => unknown;
1201
+ }
1202
+ /** A registered component definition (an opaque token; author inference lives on `createComponent`). */
1054
1203
  interface ComponentDef {
1055
1204
  /** Unique component name (matched against `data-component`). */
1056
1205
  name: string;
1057
- /** Lifecycle hooks. */
1058
- hooks: ComponentHooks;
1206
+ /** Lifecycle hooks (the subset shared with the legacy form). */
1207
+ hooks: ComponentHooks<object>;
1208
+ /** Plugin-mirror extras (state/render/events/api). Absent for legacy `(name, hooks)` defs. */
1209
+ spec?: ComponentSpecExtras;
1059
1210
  }
1211
+ /** The matched-route slice carried on a live instance (params/meta/locale + link builder). */
1212
+ type ComponentRouteSlice = Pick<ComponentContext, "params" | "meta" | "locale" | "url">;
1060
1213
  /** A live, mounted component instance. */
1061
1214
  interface ComponentInstance {
1062
1215
  /** The definition this instance was created from. */
@@ -1069,6 +1222,26 @@ interface ComponentInstance {
1069
1222
  * page-specific: full unmount/destroy on every navigation.
1070
1223
  */
1071
1224
  persistent: boolean;
1225
+ /** The single per-instance context reused by every hook, event handler, and render. */
1226
+ ctx: ComponentContext<object>;
1227
+ /** Live per-instance state (the object returned by `spec.state`), or undefined for hooks-only islands. */
1228
+ state: object | undefined;
1229
+ /** This instance's public api (the object returned by `spec.api`), or undefined when none declared. */
1230
+ api: unknown;
1231
+ /** Current matched-route slice (updated on navigation; read by `ctx.params/meta/locale/url`). */
1232
+ route: ComponentRouteSlice;
1233
+ /** Current page data payload (updated on navigation; read by `ctx.data`). */
1234
+ data: PageData;
1235
+ /** Disposers from `ctx.cleanup` + the declarative `events` listeners — run LIFO on destroy. */
1236
+ cleanups: Array<() => void>;
1237
+ /** Synchronously drain a pending render (the `ctx.flush` implementation). */
1238
+ flush: () => void;
1239
+ /** True while a render is queued for the next microtask — coalesces multiple `set` calls. */
1240
+ renderScheduled: boolean;
1241
+ /** Re-entrancy depth guard for the render scheduler (a render that calls `ctx.set`). */
1242
+ renderDepth: number;
1243
+ /** onMount's returned promise (+ render-module load) — awaited by the test harness's `settle()`. */
1244
+ mountPromise: Promise<void> | undefined;
1072
1245
  }
1073
1246
  /** Page data payload parsed from the inline `script#__DATA__` element. */
1074
1247
  type PageData = Record<string, unknown>;
@@ -1152,6 +1325,8 @@ interface SpaState {
1152
1325
  registeredComponents: Map<string, ComponentDef>;
1153
1326
  /** Live component instances keyed by their bound element. */
1154
1327
  instances: Map<Element, ComponentInstance>;
1328
+ /** Registered island apis by component name (the cross-island `ctx.component`/`app.spa.component` seam). */
1329
+ componentApis: Map<string, unknown>;
1155
1330
  /** The current resolved URL (pathname + search). */
1156
1331
  currentUrl: string;
1157
1332
  /** Teardown handle for the attached router listeners (null when detached). */
@@ -1189,6 +1364,16 @@ type SpaApi = {
1189
1364
  * const url = app.spa.current(); // "/about"
1190
1365
  */
1191
1366
  current(): string;
1367
+ /**
1368
+ * Resolve a registered island's api by name (the cross-island seam). Returns
1369
+ * `undefined` when no provider with that name is currently registered.
1370
+ *
1371
+ * @param name - The provider island's component name.
1372
+ * @returns The provider's api, or `undefined`.
1373
+ * @example
1374
+ * app.spa.component<LightboxApi>("lightbox")?.open(slides, 0);
1375
+ */
1376
+ component<T = unknown>(name: string): T | undefined;
1192
1377
  };
1193
1378
  //#endregion
1194
1379
  //#region src/plugins/site/index.d.ts
@@ -1408,21 +1593,26 @@ declare const headPlugin: import("@moku-labs/core").PluginInstance<"head", Confi
1408
1593
  //#endregion
1409
1594
  //#region src/plugins/spa/components.d.ts
1410
1595
  /**
1411
- * Create a validated component definition. Validates hook names at registration
1412
- * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
1413
- * each provided hook is a function.
1596
+ * Create a validated component definition. Accepts either the legacy hooks-only form
1597
+ * (`createComponent("counter", { onMount() {} })`) or the plugin-mirror spec form
1598
+ * (`createComponent("board", { state, render, events, api, ...hooks })`). Spec-only
1599
+ * keys (`state`/`render`/`events`/`api`) are partitioned out before hook-name
1600
+ * validation, so a real typo (e.g. `onMout`) still throws immediately while the spec
1601
+ * keys are accepted.
1414
1602
  *
1415
1603
  * @param name - Unique component name.
1416
- * @param hooks - Lifecycle hook implementations.
1604
+ * @param spec - Lifecycle hooks, or the `{ state, render, events, api, ...hooks }` spec.
1417
1605
  * @returns A `ComponentDef` ready to `register`.
1418
- * @throws {Error} If `name` is empty, any hook key is not in
1419
- * `COMPONENT_HOOK_NAMES`, or any provided hook value is not a function.
1606
+ * @throws {Error} If `name` is empty, a hook key is unknown, or an extra/hook value has the wrong shape.
1607
+ * @example
1608
+ * const counter = createComponent("counter", { onMount({ el }) { el.textContent = "0"; } });
1420
1609
  * @example
1421
- * const counter = createComponent("counter", {
1422
- * onMount({ el }) { el.textContent = "0"; }
1610
+ * const list = createComponent<{ items: string[] }>("list", {
1611
+ * state: () => ({ items: [] }),
1612
+ * render: (s) => h(List, { items: s.items })
1423
1613
  * });
1424
1614
  */
1425
- declare function createComponent(name: string, hooks: ComponentHooks): ComponentDef;
1615
+ declare function createComponent<S extends object = object, A = unknown>(name: string, spec: ComponentSpec<S, A>): ComponentDef;
1426
1616
  //#endregion
1427
1617
  //#region src/plugins/spa/lazy-embed.d.ts
1428
1618
  /**