@moku-labs/web 1.15.1 → 1.16.1

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