@moku-labs/web 1.0.0 → 1.0.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/README.md CHANGED
@@ -15,7 +15,7 @@ Built on the [@moku-labs/core](https://github.com/moku-labs/core) micro-kernel
15
15
  [![CI](https://github.com/moku-labs/web/actions/workflows/ci.yml/badge.svg)](https://github.com/moku-labs/web/actions/workflows/ci.yml)
16
16
  [![npm](https://img.shields.io/npm/v/@moku-labs/web?logo=npm&color=cb3837&label=npm)](https://www.npmjs.com/package/@moku-labs/web)
17
17
  [![types](https://img.shields.io/badge/types-included-3178c6?logo=typescript&logoColor=white)](#requirements)
18
- [![browser bundle](https://img.shields.io/badge/browser%20entry-~41%20kB%20gzip-2da44e)](#the-browser-entry-is-guaranteed-node-free)
18
+ [![browser bundle](https://img.shields.io/badge/browser%20entry-~45%20kB%20gzip-2da44e)](#the-browser-entry-is-guaranteed-node-free)
19
19
  [![node](https://img.shields.io/badge/node-%3E%3D24-339933?logo=node.js&logoColor=white)](#requirements)
20
20
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
21
21
 
@@ -45,7 +45,7 @@ bun add @moku-labs/web
45
45
  - **SSG first, SPA when you want it.** Render [Preact](https://preactjs.com) pages to static HTML for SEO and instant first paint, then progressively enhance with island hydration and client-side navigation — opt in per project with a single switch.
46
46
  - **The route is the contract.** One typed `route()` builder owns `load` → `render` → `head`. The build and the client run the *same* `render`, so there's no second code path to keep in sync. [Jump to the example ↓](#the-route-is-the-contract)
47
47
  - **SEO complete out of the box.** Title templates, canonical + `hreflang`, Open Graph / Twitter cards, JSON-LD, RSS / Atom / JSON feeds, `sitemap.xml`, and generated OG images.
48
- - **The `/browser` entry is guaranteed node-free.** A dedicated client entry whose static import graph references *zero* node modules — native code can never leak into your bundle, no matter your bundler or tree-shaking. A CI gate keeps it under budget (~41 kB gzip today). [Why this matters ↓](#the-browser-entry-is-guaranteed-node-free)
48
+ - **The `/browser` entry is guaranteed node-free.** A dedicated client entry whose static import graph references *zero* node modules — native code can never leak into your bundle, no matter your bundler or tree-shaking. A CI gate keeps it under budget (~45 kB gzip today). [Why this matters ↓](#the-browser-entry-is-guaranteed-node-free)
49
49
  - **Plugins all the way down.** A tiny isomorphic core (`site`, `i18n`, `router`, `head`, `spa`) plus opt-in node-only plugins (`content`, `build`, `deploy`, `cli`), each [independently documented](#plugins) and composed in one `createApp` call.
50
50
  - **Types do the heavy lifting.** `ctx.data` is inferred from your `.load()`, path params from the route pattern, plugin APIs from their specs — no codegen, no `as`.
51
51
  - **i18n is built in.** Locale-aware routes, default-locale fallback, `hreflang` / `og:locale` maps.
package/dist/browser.mjs CHANGED
@@ -375,6 +375,40 @@ function isPlainObject$1(value) {
375
375
  return typeof value === "object" && value !== null && !Array.isArray(value);
376
376
  }
377
377
  /**
378
+ * Tests whether `actual` is an array that recursively matches every element of
379
+ * the `partial` array (element-wise, with equal length).
380
+ *
381
+ * @param actual - The value to test against (must be an array of equal length).
382
+ * @param partial - The expected partial array shape.
383
+ * @returns `true` when `actual` is an equal-length array matching `partial` element-wise.
384
+ * @example
385
+ * ```ts
386
+ * matchesPartialArray([1, 2], [1, 2]); // true
387
+ * matchesPartialArray([1], [1, 2]); // false (length mismatch)
388
+ * ```
389
+ */
390
+ function matchesPartialArray(actual, partial) {
391
+ if (!Array.isArray(actual) || actual.length !== partial.length) return false;
392
+ return partial.every((value, index) => matchesPartial(actual[index], value));
393
+ }
394
+ /**
395
+ * Tests whether `actual` is a plain object in which every `partial` key
396
+ * recursively matches (extra `actual` keys are ignored).
397
+ *
398
+ * @param actual - The value to test against (must be a plain object).
399
+ * @param partial - The expected partial object shape.
400
+ * @returns `true` when every `partial` key exists in `actual` and matches recursively.
401
+ * @example
402
+ * ```ts
403
+ * matchesPartialObject({ a: 1, b: 2 }, { a: 1 }); // true
404
+ * matchesPartialObject({ a: 1 }, { b: 1 }); // false (missing key)
405
+ * ```
406
+ */
407
+ function matchesPartialObject(actual, partial) {
408
+ if (!isPlainObject$1(actual)) return false;
409
+ return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
410
+ }
411
+ /**
378
412
  * Subset-equality matcher: is `partial` a recursive subset of `actual`?
379
413
  *
380
414
  * Fast path via `Object.is` (covers identical primitives/references and
@@ -393,14 +427,8 @@ function isPlainObject$1(value) {
393
427
  */
394
428
  function matchesPartial(actual, partial) {
395
429
  if (Object.is(actual, partial)) return true;
396
- if (Array.isArray(partial)) {
397
- if (!Array.isArray(actual) || actual.length !== partial.length) return false;
398
- return partial.every((value, index) => matchesPartial(actual[index], value));
399
- }
400
- if (isPlainObject$1(partial)) {
401
- if (!isPlainObject$1(actual)) return false;
402
- return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
403
- }
430
+ if (Array.isArray(partial)) return matchesPartialArray(actual, partial);
431
+ if (isPlainObject$1(partial)) return matchesPartialObject(actual, partial);
404
432
  return false;
405
433
  }
406
434
  /**
@@ -1417,6 +1445,25 @@ function hasValidLangCount(pattern) {
1417
1445
  return (pattern.match(/\{lang:\?\}/g) ?? []).length <= MAX_LANG_SEGMENTS;
1418
1446
  }
1419
1447
  /**
1448
+ * Assert a single route's pattern is well-formed, throwing the `[web]`-prefixed
1449
+ * error for the first failure: not rooted at `/`, unbalanced `{…}` braces, or
1450
+ * more than one `{lang:?}` segment. Extracted from {@link validateRoutes} so the
1451
+ * loop body stays flat.
1452
+ *
1453
+ * @param name - The route name key, surfaced in any error message.
1454
+ * @param pattern - The route's user pattern to validate.
1455
+ * @throws {Error} When the pattern is malformed.
1456
+ * @example
1457
+ * ```ts
1458
+ * assertRouteValid("home", "/{slug}/");
1459
+ * ```
1460
+ */
1461
+ function assertRouteValid(name, pattern) {
1462
+ if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern must start with "/" (got "${pattern}").`);
1463
+ if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
1464
+ if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
1465
+ }
1466
+ /**
1420
1467
  * Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
1421
1468
  * naming the offending route/pattern on any failure: empty map, a pattern not
1422
1469
  * starting with `/`, unbalanced `{…}` braces, or more than one `{lang:?}` segment.
@@ -1431,12 +1478,7 @@ function hasValidLangCount(pattern) {
1431
1478
  function validateRoutes(routes) {
1432
1479
  const names = Object.keys(routes);
1433
1480
  if (names.length === 0) throw new Error(`${ERROR_PREFIX$6}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
1434
- for (const name of names) {
1435
- const pattern = routes[name]?.pattern ?? "";
1436
- if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern must start with "/" (got "${pattern}").`);
1437
- if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
1438
- if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
1439
- }
1481
+ for (const name of names) assertRouteValid(name, routes[name]?.pattern ?? "");
1440
1482
  }
1441
1483
  /**
1442
1484
  * Convert a user pattern to a `URLPattern` source string, in a `withLang` or
@@ -2990,6 +3032,22 @@ const ERROR_PREFIX$2 = "[web]";
2990
3032
  /** The set of legal hook names, frozen for O(1) membership checks. */
2991
3033
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
2992
3034
  /**
3035
+ * Validate a single hook entry: its key must be a known hook name and its value
3036
+ * must be a function. Throws fail-fast on the first violation.
3037
+ *
3038
+ * @param componentName - The owning component name (for error messages).
3039
+ * @param hooks - The hooks object being validated.
3040
+ * @param key - The hook key to validate.
3041
+ * @throws {Error} If `key` is not in `COMPONENT_HOOK_NAMES`.
3042
+ * @throws {TypeError} If the hook value is not a function.
3043
+ * @example
3044
+ * validateHookEntry("counter", hooks, "onMount");
3045
+ */
3046
+ function validateHookEntry(componentName, hooks, key) {
3047
+ if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${componentName}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
3048
+ if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${componentName}" must be a function\n → provide a function or omit the hook`);
3049
+ }
3050
+ /**
2993
3051
  * Create a validated component definition. Validates hook names at registration
2994
3052
  * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
2995
3053
  * each provided hook is a function.
@@ -3006,10 +3064,7 @@ const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
3006
3064
  */
3007
3065
  function createComponent(name, hooks) {
3008
3066
  if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2} component name must be a non-empty string\n → pass a unique name to createComponent("name", hooks)`);
3009
- for (const key of Object.keys(hooks)) {
3010
- if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${name}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
3011
- if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${name}" must be a function\n → provide a function or omit the hook`);
3012
- }
3067
+ for (const key of Object.keys(hooks)) validateHookEntry(name, hooks, key);
3013
3068
  return {
3014
3069
  name,
3015
3070
  hooks
@@ -3079,6 +3134,36 @@ function makeContext(element, data) {
3079
3134
  };
3080
3135
  }
3081
3136
  /**
3137
+ * Mounts a single `data-component` element: classifies persistent vs
3138
+ * page-specific, builds the instance, fires `onCreate` then `onMount`, records
3139
+ * it in state, and emits `spa:component-mount`. No-ops if the element is already
3140
+ * mounted, has no component name, or names an unregistered component.
3141
+ *
3142
+ * @param state - The plugin state (registeredComponents + instances).
3143
+ * @param emit - The event emitter for spa:component-mount.
3144
+ * @param swapArea - The swap-region element, or null when none was found.
3145
+ * @param data - The current page data payload.
3146
+ * @param element - The candidate element carrying a `data-component` attribute.
3147
+ * @example
3148
+ * mountElement(state, emit, swapArea, data, element);
3149
+ */
3150
+ function mountElement(state, emit, swapArea, data, element) {
3151
+ if (state.instances.has(element)) return;
3152
+ const name = element.dataset.component;
3153
+ if (!name) return;
3154
+ const definition = state.registeredComponents.get(name);
3155
+ if (!definition) return;
3156
+ const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
3157
+ const ctx = makeContext(element, data);
3158
+ runHook(instance, "onCreate", ctx);
3159
+ runHook(instance, "onMount", ctx);
3160
+ state.instances.set(element, instance);
3161
+ emit("spa:component-mount", {
3162
+ name: definition.name,
3163
+ el: element
3164
+ });
3165
+ }
3166
+ /**
3082
3167
  * Scans the swap region, mounts components for matching `data-component`
3083
3168
  * elements, classifies persistent (outside swap area) vs page-specific (inside),
3084
3169
  * fires `onCreate` then `onMount`, and emits `spa:component-mount` per instance.
@@ -3094,22 +3179,7 @@ function scanAndMount(state, emit, swapSelector) {
3094
3179
  if (typeof document === "undefined") return;
3095
3180
  const swapArea = document.querySelector(swapSelector);
3096
3181
  const data = extractPageData(document);
3097
- for (const element of document.querySelectorAll("[data-component]")) {
3098
- if (state.instances.has(element)) continue;
3099
- const name = element.dataset.component;
3100
- if (!name) continue;
3101
- const definition = state.registeredComponents.get(name);
3102
- if (!definition) continue;
3103
- const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
3104
- const ctx = makeContext(element, data);
3105
- runHook(instance, "onCreate", ctx);
3106
- runHook(instance, "onMount", ctx);
3107
- state.instances.set(element, instance);
3108
- emit("spa:component-mount", {
3109
- name: definition.name,
3110
- el: element
3111
- });
3112
- }
3182
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
3113
3183
  }
3114
3184
  /**
3115
3185
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -3580,6 +3650,8 @@ function attachRouter(handlers, navigate) {
3580
3650
  //#region src/plugins/spa/state.ts
3581
3651
  /** Error prefix for spa config-validation failures (spec/11 Part-3). */
3582
3652
  const ERROR_PREFIX$1 = "[web]";
3653
+ /** Last-resort `swapSelector` when neither config nor defaults supply one. */
3654
+ const FALLBACK_SWAP_SELECTOR = "main > section";
3583
3655
  /** Default SPA config (declared as a value — no inline assertion). */
3584
3656
  const defaultSpaConfig = {
3585
3657
  swapSelector: "main > section",
@@ -3617,7 +3689,7 @@ function isValidSelector(selector) {
3617
3689
  * const resolved = resolveSpaConfig({ swapSelector: "main > section" });
3618
3690
  */
3619
3691
  function resolveSpaConfig(config) {
3620
- const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? "main > section";
3692
+ const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? FALLBACK_SWAP_SELECTOR;
3621
3693
  if (swapSelector.trim() === "") throw new Error(`${ERROR_PREFIX$1} spa.swapSelector must be a non-empty string.\n Set a CSS selector for the page region to swap (e.g. "main > section").`);
3622
3694
  if (!isValidSelector(swapSelector)) throw new Error(`${ERROR_PREFIX$1} spa.swapSelector is not a valid CSS selector: "${swapSelector}".\n Provide a syntactically valid selector.`);
3623
3695
  return {