@nifrajs/web-preact 3.3.0 → 4.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.
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAkBnE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE1C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,OAAO,CAAA;AAExC,oGAAoG;AACpG,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI,CAE/F;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAoB7D"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAgBnE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE1C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,OAAO,CAAA;AAExC,oGAAoG;AACpG,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI,CAE/F;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAQ7D"}
package/dist/client.js CHANGED
@@ -1,6 +1,3 @@
1
- // `/client`, not the root: the root's graph carries the server, and Vite's dev server evaluates it
2
- // instead of tree-shaking it - which broke hydration before the browser ran a line of app code.
3
- import { searchOfChain } from "@nifrajs/web/client";
4
1
  /**
5
2
  * @nifrajs/web-preact/client - Preact client runtime. `hydrate` hydrates a single SSR'd route;
6
3
  * `mountRouter` hydrates a stateful Router that subscribes to the agnostic store (via
@@ -12,6 +9,7 @@ import { h, hydrate as preactHydrate } from "preact";
12
9
  import { useSyncExternalStore } from "preact/compat";
13
10
  import { compose } from "./compose.js";
14
11
  import { setMountedRouter } from "./fetcher.js";
12
+ import { routeProps } from "./route-props.js";
15
13
  // The `_error` boundary chain element - defined in its own (DOM-free) module, re-exported here so
16
14
  // nifra's client codegen resolves it from `@nifrajs/web-preact/client` alongside `mountRouter`.
17
15
  export { errorBoundary } from "./error.js";
@@ -39,23 +37,11 @@ export function hydrate(chain, props, container) {
39
37
  * (no `getServerSnapshot`); `router.snapshot` is deterministic, so hydration matches the SSR markup.
40
38
  */
41
39
  export function mountRouter(options) {
42
- const { router, routes, searchSchemas, container } = options;
40
+ const { router, routes, searchSchemas, matchChains, container } = options;
43
41
  setMountedRouter(router); // expose it to useFetcher/useFetchers (same page, client-only)
44
42
  const Router = () => {
45
43
  const state = useSyncExternalStore(router.subscribe, router.snapshot);
46
- // Derive this route's typed `search` from the URL + the route's schema chain (the SAME `searchOfChain`
47
- // the server ran), so `useSearch` reads an identical value and hydrates with no drift.
48
- const q = state.path.indexOf("?");
49
- const rawSearch = q === -1 ? "" : state.path.slice(q);
50
- return compose(routes[state.routeId] ?? [], {
51
- data: state.data,
52
- actionData: state.actionData,
53
- pending: state.pending,
54
- search: searchOfChain(searchSchemas?.[state.routeId] ?? [], rawSearch),
55
- // The in-flight submission (for optimistic UI) - spread only when present.
56
- ...(state.submission ? { submission: state.submission } : {}),
57
- ...(state.boundaries !== undefined ? { boundaries: state.boundaries } : {}),
58
- });
44
+ return compose(routes[state.routeId] ?? [], routeProps(state, searchSchemas, matchChains));
59
45
  };
60
46
  preactHydrate(h(Router, null), container);
61
47
  }
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,mGAAmG;AACnG,gGAAgG;AAChG,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACnD;;;;;;GAMG;AACH,OAAO,EAA0B,CAAC,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,QAAQ,CAAA;AAC5E,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAA;AAE/C,kGAAkG;AAClG,gGAAgG;AAChG,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE1C;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,IAAI,CAAA;AAExC,oGAAoG;AACpG,MAAM,UAAU,OAAO,CAAC,KAAyB,EAAE,KAAkB,EAAE,SAAkB;IACvF,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,SAAoB,CAAC,CAAA;AAC5D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,OAA2B;IACrD,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,SAAS,EAAE,GAAG,OAAO,CAAA;IAC5D,gBAAgB,CAAC,MAAM,CAAC,CAAA,CAAC,+DAA+D;IACxF,MAAM,MAAM,GAAsB,GAAG,EAAE;QACrC,MAAM,KAAK,GAAG,oBAAoB,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAA;QACrE,uGAAuG;QACvG,uFAAuF;QACvF,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;QACjC,MAAM,SAAS,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;QACrD,OAAO,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE;YAC1C,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,UAAU,EAAE,KAAK,CAAC,UAAU;YAC5B,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,MAAM,EAAE,aAAa,CAAC,aAAa,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,SAAS,CAAC;YACtE,2EAA2E;YAC3E,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC7D,GAAG,CAAC,KAAK,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC5E,CAAC,CAAA;IACJ,CAAC,CAAA;IACD,aAAa,CAAC,CAAC,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,SAAoB,CAAC,CAAA;AACtD,CAAC"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA;;;;;;GAMG;AACH,OAAO,EAA0B,CAAC,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,QAAQ,CAAA;AAC5E,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAE7C,kGAAkG;AAClG,gGAAgG;AAChG,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE1C;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,IAAI,CAAA;AAExC,oGAAoG;AACpG,MAAM,UAAU,OAAO,CAAC,KAAyB,EAAE,KAAkB,EAAE,SAAkB;IACvF,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,SAAoB,CAAC,CAAA;AAC5D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,OAA2B;IACrD,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAE,GAAG,OAAO,CAAA;IACzE,gBAAgB,CAAC,MAAM,CAAC,CAAA,CAAC,+DAA+D;IACxF,MAAM,MAAM,GAAsB,GAAG,EAAE;QACrC,MAAM,KAAK,GAAG,oBAAoB,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAA;QACrE,OAAO,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,UAAU,CAAC,KAAK,EAAE,aAAa,EAAE,WAAW,CAAC,CAAC,CAAA;IAC5F,CAAC,CAAA;IACD,aAAa,CAAC,CAAC,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,SAAoB,CAAC,CAAA;AACtD,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EAAgE,KAAK,KAAK,EAAE,MAAM,QAAQ,CAAA;AAejG;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,KAAK,CAuB5E"}
1
+ {"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EAAgE,KAAK,KAAK,EAAE,MAAM,QAAQ,CAAA;AAoBjG;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,KAAK,CA2B5E"}
package/dist/compose.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { createElement, h } from "preact";
2
- import { SearchContext } from "./router.js";
2
+ import { RenderPropsContext, SearchContext } from "./router.js";
3
3
  // Frozen empty search so a render with no search context has a stable provider value.
4
4
  const EMPTY_SEARCH = Object.freeze({});
5
5
  // Preact's `createElement` overloads drop a typed `Context.Provider`'s required `value` under
6
6
  // exactOptionalPropertyTypes (same issue i18n's provider hits); invoke it through a precisely-typed
7
7
  // factory that states the real `{ value }` + children contract rather than scatter `any`.
8
8
  const createSearchProvider = createElement;
9
+ const createPropsProvider = createElement;
9
10
  /**
10
11
  * Fold a layout chain (outermost layout → page) into a single Preact tree: the page (innermost)
11
12
  * receives `props` (the loader data); each layout wraps the child via its `children` (Preact passes
@@ -32,6 +33,6 @@ export function compose(chain, props) {
32
33
  ...(props.boundaries !== undefined ? { boundaries: props.boundaries } : {}),
33
34
  }, node);
34
35
  }
35
- return createSearchProvider(SearchContext.Provider, { value: props.search ?? EMPTY_SEARCH }, node);
36
+ return createSearchProvider(SearchContext.Provider, { value: props.search ?? EMPTY_SEARCH }, createPropsProvider(RenderPropsContext.Provider, { value: props }, node));
36
37
  }
37
38
  //# sourceMappingURL=compose.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"compose.js","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"AACA,OAAO,EAA8C,aAAa,EAAE,CAAC,EAAc,MAAM,QAAQ,CAAA;AACjG,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAE3C,sFAAsF;AACtF,MAAM,YAAY,GAAsC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAEzE,8FAA8F;AAC9F,oGAAoG;AACpG,0FAA0F;AAC1F,MAAM,oBAAoB,GAAG,aAInB,CAAA;AAEV;;;;;;;GAOG;AACH,MAAM,UAAU,OAAO,CAAC,KAAyB,EAAE,KAAkB;IACnE,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAA;IAC7B,iGAAiG;IACjG,8FAA8F;IAC9F,mGAAmG;IACnG,gGAAgG;IAChG,2FAA2F;IAC3F,IAAI,IAAI,GAAU,CAAC,CAAC,KAAK,CAAC,IAAI,CAA+B,EAAE,KAAK,CAAU,CAAA;IAC9E,KAAK,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACnC,wFAAwF;QACxF,6FAA6F;QAC7F,mGAAmG;QACnG,kEAAkE;QAClE,IAAI,GAAG,CAAC,CACN,KAAK,CAAC,CAAC,CAAqC,EAC5C;YACE,IAAI,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI;YACnC,GAAG,CAAC,KAAK,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC5E,EACD,IAAI,CACI,CAAA;IACZ,CAAC;IACD,OAAO,oBAAoB,CAAC,aAAa,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,MAAM,IAAI,YAAY,EAAE,EAAE,IAAI,CAAC,CAAA;AACpG,CAAC"}
1
+ {"version":3,"file":"compose.js","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"AACA,OAAO,EAA8C,aAAa,EAAE,CAAC,EAAc,MAAM,QAAQ,CAAA;AACjG,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAE/D,sFAAsF;AACtF,MAAM,YAAY,GAAsC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAEzE,8FAA8F;AAC9F,oGAAoG;AACpG,0FAA0F;AAC1F,MAAM,oBAAoB,GAAG,aAInB,CAAA;AACV,MAAM,mBAAmB,GAAG,aAIlB,CAAA;AAEV;;;;;;;GAOG;AACH,MAAM,UAAU,OAAO,CAAC,KAAyB,EAAE,KAAkB;IACnE,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAA;IAC7B,iGAAiG;IACjG,8FAA8F;IAC9F,mGAAmG;IACnG,gGAAgG;IAChG,2FAA2F;IAC3F,IAAI,IAAI,GAAU,CAAC,CAAC,KAAK,CAAC,IAAI,CAA+B,EAAE,KAAK,CAAU,CAAA;IAC9E,KAAK,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACnC,wFAAwF;QACxF,6FAA6F;QAC7F,mGAAmG;QACnG,kEAAkE;QAClE,IAAI,GAAG,CAAC,CACN,KAAK,CAAC,CAAC,CAAqC,EAC5C;YACE,IAAI,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI;YACnC,GAAG,CAAC,KAAK,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC5E,EACD,IAAI,CACI,CAAA;IACZ,CAAC;IACD,OAAO,oBAAoB,CACzB,aAAa,CAAC,QAAQ,EACtB,EAAE,KAAK,EAAE,KAAK,CAAC,MAAM,IAAI,YAAY,EAAE,EACvC,mBAAmB,CAAC,kBAAkB,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,IAAI,CAAC,CACzE,CAAA;AACH,CAAC"}
package/dist/content.d.ts CHANGED
@@ -6,10 +6,11 @@
6
6
  * Security: injects raw HTML, so `html` MUST be trusted (your own Markdown/content, rendered at
7
7
  * build/server time) - never pass user-supplied HTML without sanitizing it first.
8
8
  */
9
+ import type { TrustedHtml } from "@nifrajs/web";
9
10
  import { type JSX, type VNode } from "preact";
10
11
  export interface ContentProps extends Omit<JSX.HTMLAttributes<HTMLElement>, "dangerouslySetInnerHTML"> {
11
12
  /** Trusted, pre-rendered HTML (e.g. `entry.html`). */
12
- readonly html: string;
13
+ readonly html: TrustedHtml;
13
14
  /** Wrapper element (default `"div"`). */
14
15
  readonly as?: string;
15
16
  }
@@ -1 +1 @@
1
- {"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../src/content.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAiB,KAAK,GAAG,EAAE,KAAK,KAAK,EAAE,MAAM,QAAQ,CAAA;AAE5D,MAAM,WAAW,YACf,SAAQ,IAAI,CAAC,GAAG,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,yBAAyB,CAAC;IACxE,sDAAsD;IACtD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,wGAAwG;AACxG,wBAAgB,OAAO,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,IAAI,EAAE,EAAE,YAAY,GAAG,KAAK,CAQlE"}
1
+ {"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../src/content.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EAAiB,KAAK,GAAG,EAAE,KAAK,KAAK,EAAE,MAAM,QAAQ,CAAA;AAE5D,MAAM,WAAW,YACf,SAAQ,IAAI,CAAC,GAAG,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,yBAAyB,CAAC;IACxE,sDAAsD;IACtD,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;IAC1B,yCAAyC;IACzC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,wGAAwG;AACxG,wBAAgB,OAAO,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,IAAI,EAAE,EAAE,YAAY,GAAG,KAAK,CAQlE"}
@@ -1 +1 @@
1
- {"version":3,"file":"content.js","sourceRoot":"","sources":["../src/content.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,aAAa,EAAwB,MAAM,QAAQ,CAAA;AAU5D,wGAAwG;AACxG,MAAM,UAAU,OAAO,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,IAAI,EAAgB;IACzD,sFAAsF;IACtF,oGAAoG;IACpG,MAAM,KAAK,GAAoC;QAC7C,GAAG,IAAI;QACP,uBAAuB,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE;KAC1C,CAAA;IACD,OAAO,aAAa,CAAC,EAAE,IAAI,KAAK,EAAE,KAAK,CAAC,CAAA;AAC1C,CAAC"}
1
+ {"version":3,"file":"content.js","sourceRoot":"","sources":["../src/content.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,aAAa,EAAwB,MAAM,QAAQ,CAAA;AAU5D,wGAAwG;AACxG,MAAM,UAAU,OAAO,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,IAAI,EAAgB;IACzD,sFAAsF;IACtF,oGAAoG;IACpG,MAAM,KAAK,GAAoC;QAC7C,GAAG,IAAI;QACP,uBAAuB,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE;KAC1C,CAAA;IACD,OAAO,aAAa,CAAC,EAAE,IAAI,KAAK,EAAE,KAAK,CAAC,CAAA;AAC1C,CAAC"}
package/dist/i18n.d.ts CHANGED
@@ -5,16 +5,29 @@
5
5
  * and the client rebuilds the same formatter from the same props (no mismatch). Uses `preact` +
6
6
  * `preact/hooks` - no JSX.
7
7
  */
8
- import { type Formatter, type Messages } from "@nifrajs/i18n";
8
+ import { type Formatter, type FormatterOptions, type MessageKey, type RegisteredMessages, type Translation } from "@nifrajs/i18n";
9
9
  import { type ComponentChildren, type VNode } from "preact";
10
- export interface I18nProviderProps {
10
+ export interface I18nProviderProps extends FormatterOptions {
11
11
  readonly locale: string;
12
- readonly messages: Messages;
12
+ /** The catalog for `locale`, checked against the registered catalog type when one is declared. */
13
+ readonly messages: Translation;
13
14
  readonly children?: ComponentChildren;
14
15
  }
15
- /** Provide a {@link Formatter} (built from `locale` + `messages`) to the subtree. Memoized on
16
- * `locale`/`messages`, so switching locale rebuilds it and re-renders consumers. */
16
+ /** Provide a {@link Formatter} (built from `locale` + `messages`, with the optional `fallback`,
17
+ * `onMissing`, `timeZone` and `numberingSystem` of `createFormatter`) to the subtree. Memoized
18
+ * on those props, so switching locale rebuilds it and re-renders consumers; formatters are cached by
19
+ * catalog identity, so an inline `fallback={[en]}` still yields the same instance. */
17
20
  export declare function I18nProvider(props: I18nProviderProps): VNode;
18
- /** Read the current {@link Formatter} (`{ locale, t, n, d }`). Throws if no `<I18nProvider>` is above. */
21
+ /** Read the current {@link Formatter} (`{ locale, t, get, n, d }`). Throws if no `<I18nProvider>` is above. */
19
22
  export declare function useT(): Formatter;
23
+ /** Tag handlers for {@link rich}, by tag name: each receives its tag's content as one node. */
24
+ export type RichTags = Readonly<Record<string, (content: ComponentChildren) => ComponentChildren>>;
25
+ /**
26
+ * The message at `key` with its tags rendered by `tags`, as Preact nodes - no HTML, no
27
+ * `dangerouslySetInnerHTML`. `"Read the <link>terms</link>"` with
28
+ * `{ link: (content) => <a href="/terms">{content}</a> }` renders the link around "terms"; a tag with
29
+ * no handler renders its content as text, `<br/>` is a `<br>`, and interpolated values are always
30
+ * text. See `@nifrajs/i18n/rich`.
31
+ */
32
+ export declare function rich<M extends object = RegisteredMessages>(formatter: Formatter<M>, key: MessageKey<M>, tags?: RichTags, vars?: Readonly<Record<string, unknown>>): ComponentChildren;
20
33
  //# sourceMappingURL=i18n.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAmB,KAAK,SAAS,EAAE,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAA;AAC9E,OAAO,EAAE,KAAK,iBAAiB,EAAgC,KAAK,KAAK,EAAE,MAAM,QAAQ,CAAA;AAezF,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,iBAAiB,CAAA;CACtC;AAED;oFACoF;AACpF,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,KAAK,CAM5D;AAED,0GAA0G;AAC1G,wBAAgB,IAAI,IAAI,SAAS,CAMhC"}
1
+ {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,WAAW,EACjB,MAAM,eAAe,CAAA;AAEtB,OAAO,EAAE,KAAK,iBAAiB,EAA0C,KAAK,KAAK,EAAE,MAAM,QAAQ,CAAA;AAenG,MAAM,WAAW,iBAAkB,SAAQ,gBAAgB;IACzD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,kGAAkG;IAClG,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAA;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,iBAAiB,CAAA;CACtC;AAED;;;sFAGsF;AACtF,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,KAAK,CAO5D;AAED,+GAA+G;AAC/G,wBAAgB,IAAI,IAAI,SAAS,CAMhC;AAED,+FAA+F;AAC/F,MAAM,MAAM,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,iBAAiB,CAAC,CAAC,CAAA;AAalG;;;;;;GAMG;AACH,wBAAgB,IAAI,CAAC,CAAC,SAAS,MAAM,GAAG,kBAAkB,EACxD,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,EACvB,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,EAClB,IAAI,CAAC,EAAE,QAAQ,EACf,IAAI,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GACvC,iBAAiB,CAEnB"}
package/dist/i18n.js CHANGED
@@ -5,8 +5,9 @@
5
5
  * and the client rebuilds the same formatter from the same props (no mismatch). Uses `preact` +
6
6
  * `preact/hooks` - no JSX.
7
7
  */
8
- import { createFormatter } from "@nifrajs/i18n";
9
- import { createContext, createElement } from "preact";
8
+ import { createFormatter, } from "@nifrajs/i18n";
9
+ import { renderRich } from "@nifrajs/i18n/rich";
10
+ import { createContext, createElement, Fragment } from "preact";
10
11
  import { useContext, useMemo } from "preact/hooks";
11
12
  const I18nContext = createContext(null);
12
13
  // Preact's `createElement` overloads don't reconcile a typed `Context.Provider` (required `value`)
@@ -14,13 +15,16 @@ const I18nContext = createContext(null);
14
15
  // The call is correct at runtime (proven by the tests), so invoke it through a precisely-typed element
15
16
  // factory that states the real contract (`{ value }` + children), rather than scatter `any`.
16
17
  const createProvider = createElement;
17
- /** Provide a {@link Formatter} (built from `locale` + `messages`) to the subtree. Memoized on
18
- * `locale`/`messages`, so switching locale rebuilds it and re-renders consumers. */
18
+ /** Provide a {@link Formatter} (built from `locale` + `messages`, with the optional `fallback`,
19
+ * `onMissing`, `timeZone` and `numberingSystem` of `createFormatter`) to the subtree. Memoized
20
+ * on those props, so switching locale rebuilds it and re-renders consumers; formatters are cached by
21
+ * catalog identity, so an inline `fallback={[en]}` still yields the same instance. */
19
22
  export function I18nProvider(props) {
20
- const formatter = useMemo(() => createFormatter(props.locale, props.messages), [props.locale, props.messages]);
23
+ const { locale, messages, fallback, onMissing, timeZone, numberingSystem } = props;
24
+ const formatter = useMemo(() => createFormatter(locale, messages, { fallback, onMissing, timeZone, numberingSystem }), [locale, messages, fallback, onMissing, timeZone, numberingSystem]);
21
25
  return createProvider(I18nContext.Provider, { value: formatter }, props.children);
22
26
  }
23
- /** Read the current {@link Formatter} (`{ locale, t, n, d }`). Throws if no `<I18nProvider>` is above. */
27
+ /** Read the current {@link Formatter} (`{ locale, t, get, n, d }`). Throws if no `<I18nProvider>` is above. */
24
28
  export function useT() {
25
29
  const formatter = useContext(I18nContext);
26
30
  if (formatter === null) {
@@ -28,4 +32,23 @@ export function useT() {
28
32
  }
29
33
  return formatter;
30
34
  }
35
+ const join = (chunks) => chunks.length === 0
36
+ ? null
37
+ : chunks.length === 1
38
+ ? chunks[0]
39
+ : createElement(Fragment, null, ...chunks);
40
+ const PREACT_RICH = {
41
+ join,
42
+ lineBreak: () => createElement("br", null),
43
+ };
44
+ /**
45
+ * The message at `key` with its tags rendered by `tags`, as Preact nodes - no HTML, no
46
+ * `dangerouslySetInnerHTML`. `"Read the <link>terms</link>"` with
47
+ * `{ link: (content) => <a href="/terms">{content}</a> }` renders the link around "terms"; a tag with
48
+ * no handler renders its content as text, `<br/>` is a `<br>`, and interpolated values are always
49
+ * text. See `@nifrajs/i18n/rich`.
50
+ */
51
+ export function rich(formatter, key, tags, vars) {
52
+ return renderRich(PREACT_RICH, formatter, key, tags, vars);
53
+ }
31
54
  //# sourceMappingURL=i18n.js.map
package/dist/i18n.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"i18n.js","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,eAAe,EAAiC,MAAM,eAAe,CAAA;AAC9E,OAAO,EAA0B,aAAa,EAAE,aAAa,EAAc,MAAM,QAAQ,CAAA;AACzF,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAElD,MAAM,WAAW,GAAG,aAAa,CAAmB,IAAI,CAAC,CAAA;AAEzD,mGAAmG;AACnG,uGAAuG;AACvG,uGAAuG;AACvG,6FAA6F;AAC7F,MAAM,cAAc,GAAG,aAIb,CAAA;AAQV;oFACoF;AACpF,MAAM,UAAU,YAAY,CAAC,KAAwB;IACnD,MAAM,SAAS,GAAG,OAAO,CACvB,GAAG,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC,EACnD,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC,CAC/B,CAAA;IACD,OAAO,cAAc,CAAC,WAAW,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAA;AACnF,CAAC;AAED,0GAA0G;AAC1G,MAAM,UAAU,IAAI;IAClB,MAAM,SAAS,GAAG,UAAU,CAAC,WAAW,CAAC,CAAA;IACzC,IAAI,SAAS,KAAK,IAAI,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,iEAAiE,CAAC,CAAA;IACpF,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC"}
1
+ {"version":3,"file":"i18n.js","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EACL,eAAe,GAMhB,MAAM,eAAe,CAAA;AACtB,OAAO,EAAsC,UAAU,EAAE,MAAM,oBAAoB,CAAA;AACnF,OAAO,EAA0B,aAAa,EAAE,aAAa,EAAE,QAAQ,EAAc,MAAM,QAAQ,CAAA;AACnG,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAElD,MAAM,WAAW,GAAG,aAAa,CAAmB,IAAI,CAAC,CAAA;AAEzD,mGAAmG;AACnG,uGAAuG;AACvG,uGAAuG;AACvG,6FAA6F;AAC7F,MAAM,cAAc,GAAG,aAIb,CAAA;AASV;;;sFAGsF;AACtF,MAAM,UAAU,YAAY,CAAC,KAAwB;IACnD,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,eAAe,EAAE,GAAG,KAAK,CAAA;IAClF,MAAM,SAAS,GAAG,OAAO,CACvB,GAAG,EAAE,CAAC,eAAe,CAAC,MAAM,EAAE,QAAQ,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,eAAe,EAAE,CAAC,EAC3F,CAAC,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,eAAe,CAAC,CACnE,CAAA;IACD,OAAO,cAAc,CAAC,WAAW,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAA;AACnF,CAAC;AAED,+GAA+G;AAC/G,MAAM,UAAU,IAAI;IAClB,MAAM,SAAS,GAAG,UAAU,CAAC,WAAW,CAAC,CAAA;IACzC,IAAI,SAAS,KAAK,IAAI,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,iEAAiE,CAAC,CAAA;IACpF,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC;AAKD,MAAM,IAAI,GAAG,CAAC,MAAqC,EAAqB,EAAE,CACxE,MAAM,CAAC,MAAM,KAAK,CAAC;IACjB,CAAC,CAAC,IAAI;IACN,CAAC,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC;QACnB,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;QACX,CAAC,CAAC,aAAa,CAAC,QAAQ,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC,CAAA;AAChD,MAAM,WAAW,GAAoC;IACnD,IAAI;IACJ,SAAS,EAAE,GAAG,EAAE,CAAC,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC;CAC3C,CAAA;AAED;;;;;;GAMG;AACH,MAAM,UAAU,IAAI,CAClB,SAAuB,EACvB,GAAkB,EAClB,IAAe,EACf,IAAwC;IAExC,OAAO,UAAU,CAAC,WAAW,EAAE,SAAS,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,CAAC,CAAA;AAC5D,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAWjD,8EAA8E;AAC9E,eAAO,MAAM,aAAa,EAAE,aAyB3B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAiDjD,8EAA8E;AAC9E,eAAO,MAAM,aAAa,EAAE,aA0B3B,CAAA"}
package/dist/index.js CHANGED
@@ -7,6 +7,39 @@
7
7
  */
8
8
  import { compose } from "./compose.js";
9
9
  import { preactRenderToStream, preactRenderToString } from "./preact-render.js";
10
+ const ISLAND_RUNTIME_OPEN = "<script>(function(){";
11
+ const ISLAND_RUNTIME_OPEN_BYTES = new TextEncoder().encode(ISLAND_RUNTIME_OPEN);
12
+ const startsWithBytes = (chunk, prefix) => {
13
+ if (chunk.length < prefix.length)
14
+ return false;
15
+ for (let i = 0; i < prefix.length; i++)
16
+ if (chunk[i] !== prefix[i])
17
+ return false;
18
+ return true;
19
+ };
20
+ /**
21
+ * `preact-render-to-string` streams the runtime that moves resolved `<Suspense>` content into place as
22
+ * one attribute-less `<script>` chunk, and takes no nonce option. Stamp the document nonce on exactly
23
+ * that chunk (it defines the `preact-island` element); every other chunk passes through untouched, so
24
+ * an app-rendered `<script>` never inherits the nonce.
25
+ */
26
+ function withIslandRuntimeNonce(stream, nonce) {
27
+ const decoder = new TextDecoder();
28
+ const encoder = new TextEncoder();
29
+ const nonced = `<script nonce="${nonce.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;")}">(function(){`;
30
+ return stream.pipeThrough(new TransformStream({
31
+ transform(chunk, controller) {
32
+ if (startsWithBytes(chunk, ISLAND_RUNTIME_OPEN_BYTES)) {
33
+ const text = decoder.decode(chunk);
34
+ if (text.includes('"preact-island"')) {
35
+ controller.enqueue(encoder.encode(nonced + text.slice(ISLAND_RUNTIME_OPEN.length)));
36
+ return;
37
+ }
38
+ }
39
+ controller.enqueue(chunk);
40
+ },
41
+ }));
42
+ }
10
43
  /** The Preact server render adapter - pass to @nifrajs/web's `renderPage`. */
11
44
  export const preactAdapter = {
12
45
  // Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). The sync
@@ -22,11 +55,12 @@ export const preactAdapter = {
22
55
  }
23
56
  return loaded.renderToString(compose(chain, props));
24
57
  },
25
- async renderToStream(chain, props) {
58
+ async renderToStream(chain, props, options) {
26
59
  // `renderToReadableStream` yields a Web ReadableStream<Uint8Array>; <Suspense> boundaries
27
60
  // (preact/compat) stream as they resolve, mirroring the React adapter.
28
61
  const { renderToReadableStream } = await preactRenderToStream();
29
- return renderToReadableStream(compose(chain, props));
62
+ const stream = renderToReadableStream(compose(chain, props));
63
+ return options?.nonce === undefined ? stream : withIslandRuntimeNonce(stream, options.nonce);
30
64
  },
31
65
  // Preact reconciles against the existing DOM on hydrate (like React), so there's no per-document
32
66
  // bootstrap script - the seam allows the empty string (contrast Solid's generateHydrationScript).
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA;;;;;;GAMG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA;AAE/E,8EAA8E;AAC9E,MAAM,CAAC,MAAM,aAAa,GAAkB;IAC1C,iGAAiG;IACjG,gGAAgG;IAChG,EAAE;IACF,gGAAgG;IAChG,oGAAoG;IACpG,kEAAkE;IAClE,cAAc,CAAC,KAAK,EAAE,KAAK;QACzB,MAAM,MAAM,GAAG,oBAAoB,EAAE,CAAA;QACrC,IAAI,MAAM,YAAY,OAAO,EAAE,CAAC;YAC9B,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,cAAc,EAAE,EAAE,EAAE,CAAC,cAAc,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,CAAA;QACnF,CAAC;QACD,OAAO,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAA;IACrD,CAAC;IACD,KAAK,CAAC,cAAc,CAAC,KAAK,EAAE,KAAK;QAC/B,0FAA0F;QAC1F,uEAAuE;QACvE,MAAM,EAAE,sBAAsB,EAAE,GAAG,MAAM,oBAAoB,EAAE,CAAA;QAC/D,OAAO,sBAAsB,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAA;IACtD,CAAC;IACD,iGAAiG;IACjG,kGAAkG;IAClG,aAAa;QACX,OAAO,EAAE,CAAA;IACX,CAAC;CACF,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import type { MountRouterOptions, RenderProps, RouterState } from "@nifrajs/web";
2
+ /** The client half of the server's `RenderProps`, so the first render matches the SSR markup. */
3
+ export declare function routeProps(state: RouterState, searchSchemas: MountRouterOptions["searchSchemas"], matchChains?: MountRouterOptions["matchChains"]): RenderProps;
4
+ //# sourceMappingURL=route-props.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-props.d.ts","sourceRoot":"","sources":["../src/route-props.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAKhF,iGAAiG;AACjG,wBAAgB,UAAU,CACxB,KAAK,EAAE,WAAW,EAClB,aAAa,EAAE,kBAAkB,CAAC,eAAe,CAAC,EAClD,WAAW,CAAC,EAAE,kBAAkB,CAAC,aAAa,CAAC,GAC9C,WAAW,CAuBb"}
@@ -0,0 +1,29 @@
1
+ // `/client`, not the root: the root's graph carries the server, and Vite's dev server evaluates it
2
+ // instead of tree-shaking it - which broke hydration before the browser ran a line of app code.
3
+ import { searchOfChain } from "@nifrajs/web/client";
4
+ /** The client half of the server's `RenderProps`, so the first render matches the SSR markup. */
5
+ export function routeProps(state, searchSchemas, matchChains) {
6
+ const matchChain = matchChains?.[state.routeId];
7
+ // Derive this route's typed `search` from the URL + the route's schema chain (the SAME `searchOfChain`
8
+ // the server ran), so `useSearch` reads an identical value and hydrates with no drift.
9
+ const q = state.path.indexOf("?");
10
+ const rawSearch = q === -1 ? "" : state.path.slice(q);
11
+ return {
12
+ data: state.data,
13
+ // Each layout's own loader data. Without it a layout renders `data: null` on the client while the
14
+ // server rendered it with its data - a hydration mismatch on the first paint.
15
+ ...(state.layoutData !== undefined ? { layoutData: state.layoutData } : {}),
16
+ actionData: state.actionData,
17
+ pending: state.pending,
18
+ // The same `params`/`path` the server render received, so `useMatches` reports the same chain.
19
+ params: state.params,
20
+ path: state.path,
21
+ search: searchOfChain(searchSchemas?.[state.routeId] ?? [], rawSearch),
22
+ // The in-flight submission (for optimistic UI) - spread only when present.
23
+ ...(state.submission ? { submission: state.submission } : {}),
24
+ ...(state.boundaries !== undefined ? { boundaries: state.boundaries } : {}),
25
+ // The chain `useMatches` reports - the server passed the same one to the SSR render.
26
+ ...(matchChain !== undefined ? { matchChain } : {}),
27
+ };
28
+ }
29
+ //# sourceMappingURL=route-props.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-props.js","sourceRoot":"","sources":["../src/route-props.ts"],"names":[],"mappings":"AACA,mGAAmG;AACnG,gGAAgG;AAChG,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAEnD,iGAAiG;AACjG,MAAM,UAAU,UAAU,CACxB,KAAkB,EAClB,aAAkD,EAClD,WAA+C;IAE/C,MAAM,UAAU,GAAG,WAAW,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;IAC/C,uGAAuG;IACvG,uFAAuF;IACvF,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;IACjC,MAAM,SAAS,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IACrD,OAAO;QACL,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,kGAAkG;QAClG,8EAA8E;QAC9E,GAAG,CAAC,KAAK,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3E,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,+FAA+F;QAC/F,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,MAAM,EAAE,aAAa,CAAC,aAAa,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,SAAS,CAAC;QACtE,2EAA2E;QAC3E,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7D,GAAG,CAAC,KAAK,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3E,qFAAqF;QACrF,GAAG,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACpD,CAAA;AACH,CAAC"}
package/dist/router.d.ts CHANGED
@@ -1,7 +1,19 @@
1
1
  import type { InferOutput, StandardSchemaV1 } from "@nifrajs/core/server";
2
- import type { Blocker, BlockerFunction, BlockerState, NavigateFunction } from "@nifrajs/web";
3
- export type { Blocker, BlockerFunction, BlockerState, NavigateFunction };
2
+ import type { Blocker, BlockerFunction, BlockerState, NavigateFunction, RenderProps, UIMatch } from "@nifrajs/web";
3
+ export type { Blocker, BlockerFunction, BlockerState, NavigateFunction, UIMatch };
4
4
  export declare const SearchContext: import("preact").Context<Record<string, unknown>>;
5
+ export declare const RenderPropsContext: import("preact").Context<RenderProps | undefined>;
6
+ /**
7
+ * The rendered chain - each layout, then the page - with the URL prefix, params and loader data each
8
+ * one owns, plus its `handle` export. The same list on the server render and the client mount, so a
9
+ * layout can render breadcrumbs from its children's `handle`s without a hydration mismatch.
10
+ *
11
+ * ```tsx
12
+ * export const handle = { crumb: "Settings" }
13
+ * const crumbs = useMatches().flatMap((m) => (m.handle as { crumb?: string } | undefined)?.crumb ?? [])
14
+ * ```
15
+ */
16
+ export declare function useMatches(): readonly UIMatch[];
5
17
  /**
6
18
  * The route's typed, validated search params - the SAME value the loader received as `ctx.search`.
7
19
  * SSR-correct: `compose` provides it from the URL server-side and from the identical derivation on the
@@ -1 +1 @@
1
- {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAIzE,OAAO,KAAK,EACV,OAAO,EACP,eAAe,EACf,YAAY,EACZ,gBAAgB,EAGjB,MAAM,cAAc,CAAA;AAmBrB,YAAY,EAAE,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,gBAAgB,EAAE,CAAA;AAgBxE,eAAO,MAAM,aAAa,mDACsE,CAAA;AAGhG;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CACvB,MAAM,SAAS,gBAAgB,GAAG,SAAS,GAAG,SAAS,KACpD,MAAM,SAAS,gBAAgB,GAAG,WAAW,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAInF;AAED;;sEAEsE;AACtE,wBAAgB,WAAW,IAAI,gBAAgB,CAO9C;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,OAAO,GAAG,eAAe,GAAG,OAAO,CAe1E"}
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAIzE,OAAO,KAAK,EACV,OAAO,EACP,eAAe,EACf,YAAY,EACZ,gBAAgB,EAGhB,WAAW,EACX,OAAO,EACR,MAAM,cAAc,CAAA;AAqBrB,YAAY,EAAE,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,gBAAgB,EAAE,OAAO,EAAE,CAAA;AAgBjF,eAAO,MAAM,aAAa,mDACsE,CAAA;AAShG,eAAO,MAAM,kBAAkB,mDAC4D,CAAA;AAK3F;;;;;;;;;GASG;AACH,wBAAgB,UAAU,IAAI,SAAS,OAAO,EAAE,CAG/C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CACvB,MAAM,SAAS,gBAAgB,GAAG,SAAS,GAAG,SAAS,KACpD,MAAM,SAAS,gBAAgB,GAAG,WAAW,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAInF;AAED;;sEAEsE;AACtE,wBAAgB,WAAW,IAAI,gBAAgB,CAO9C;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,OAAO,GAAG,eAAe,GAAG,OAAO,CAe1E"}
package/dist/router.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // `/client`, not the root - these are DOM values, and the root's graph carries the
2
2
  // server, which Vite's dev server evaluates rather than tree-shakes.
3
3
  import { getBrowserNavigate, IDLE_BLOCKER, registerBlocker, resolveNavigate, } from "@nifrajs/web/client";
4
+ // Its own subpath, so an app that never calls `useMatches` never bundles it.
5
+ import { chainMatches } from "@nifrajs/web/internal/matches-runtime";
4
6
  /**
5
7
  * `@nifrajs/web-preact/router` - Preact routing bindings over the agnostic `@nifrajs/web` history layer:
6
8
  * `useNavigate` (programmatic navigation), `useBlocker` (the unsaved-changes guard), and `useSearch`
@@ -9,7 +11,7 @@ import { getBrowserNavigate, IDLE_BLOCKER, registerBlocker, resolveNavigate, } f
9
11
  * `compose` provides on SSR + client mount alike. Client-safe (no `preact-render-to-string`). No JSX.
10
12
  */
11
13
  import { createContext } from "preact";
12
- import { useCallback, useContext, useEffect, useRef, useState } from "preact/compat";
14
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/compat";
13
15
  // Frozen empty search so the default context value has a stable reference.
14
16
  const EMPTY_SEARCH = Object.freeze({});
15
17
  /** The current route's validated search, provided by `compose` on SSR + client mount alike (derived from
@@ -23,6 +25,27 @@ const SEARCH_CONTEXT_SLOT = Symbol.for("nifra.web-preact.search-context");
23
25
  const searchContextSlot = globalThis;
24
26
  export const SearchContext = searchContextSlot[SEARCH_CONTEXT_SLOT] ?? createContext(EMPTY_SEARCH);
25
27
  searchContextSlot[SEARCH_CONTEXT_SLOT] = SearchContext;
28
+ /** The props `compose` rendered the chain with - what {@link useMatches} reads. A `globalThis`
29
+ * singleton for the same reason as {@link SearchContext}. */
30
+ const PROPS_CONTEXT_SLOT = Symbol.for("nifra.web-preact.props-context");
31
+ const propsContextSlot = globalThis;
32
+ export const RenderPropsContext = propsContextSlot[PROPS_CONTEXT_SLOT] ?? createContext(undefined);
33
+ propsContextSlot[PROPS_CONTEXT_SLOT] = RenderPropsContext;
34
+ const NO_MATCHES = Object.freeze([]);
35
+ /**
36
+ * The rendered chain - each layout, then the page - with the URL prefix, params and loader data each
37
+ * one owns, plus its `handle` export. The same list on the server render and the client mount, so a
38
+ * layout can render breadcrumbs from its children's `handle`s without a hydration mismatch.
39
+ *
40
+ * ```tsx
41
+ * export const handle = { crumb: "Settings" }
42
+ * const crumbs = useMatches().flatMap((m) => (m.handle as { crumb?: string } | undefined)?.crumb ?? [])
43
+ * ```
44
+ */
45
+ export function useMatches() {
46
+ const props = useContext(RenderPropsContext);
47
+ return useMemo(() => (props === undefined ? NO_MATCHES : chainMatches(props)), [props]);
48
+ }
26
49
  /**
27
50
  * The route's typed, validated search params - the SAME value the loader received as `ctx.search`.
28
51
  * SSR-correct: `compose` provides it from the URL server-side and from the identical derivation on the
@@ -1 +1 @@
1
- {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAYA,mFAAmF;AACnF,qEAAqE;AACrE,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,eAAe,EACf,eAAe,GAChB,MAAM,qBAAqB,CAAA;AAC5B;;;;;;GAMG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAA;AACtC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAIpF,2EAA2E;AAC3E,MAAM,YAAY,GAAsC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAEzE;;;;;;2BAM2B;AAC3B,MAAM,mBAAmB,GAAG,MAAM,CAAC,GAAG,CAAC,iCAAiC,CAAC,CAAA;AACzE,MAAM,iBAAiB,GAAG,UAEzB,CAAA;AACD,MAAM,CAAC,MAAM,aAAa,GACxB,iBAAiB,CAAC,mBAAmB,CAAC,IAAI,aAAa,CAA0B,YAAY,CAAC,CAAA;AAChG,iBAAiB,CAAC,mBAAmB,CAAC,GAAG,aAAa,CAAA;AAEtD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,SAAS;IAGvB,OAAO,UAAU,CAAC,aAAa,CAEJ,CAAA;AAC7B,CAAC;AAED;;sEAEsE;AACtE,MAAM,UAAU,WAAW;IACzB,OAAO,WAAW,CAAC,CAAC,EAAyC,EAAE,OAAyB,EAAE,EAAE;QAC1F,MAAM,QAAQ,GAAG,kBAAkB,EAAE,CAAA;QACrC,IAAI,QAAQ,KAAK,SAAS;YAAE,OAAM;QAClC,MAAM,QAAQ,GAAG,eAAe,CAAC,EAAE,EAAE,OAAO,CAAC,CAAA;QAC7C,QAAQ,CAAC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;IACzC,CAAC,EAAE,EAAE,CAAqB,CAAA;AAC5B,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CAAC,WAAsC;IAC/D,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAU,YAAY,CAAC,CAAA;IAC7D,oGAAoG;IACpG,oFAAoF;IACpF,MAAM,cAAc,GAAG,MAAM,CAAC,WAAW,CAAC,CAAA;IAC1C,cAAc,CAAC,OAAO,GAAG,WAAW,CAAA;IACpC,SAAS,CACP,GAAG,EAAE,CACH,eAAe,CAAC,CAAC,IAAI,EAAE,EAAE;QACvB,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAA;QACtC,OAAO,OAAO,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;IAChE,CAAC,EAAE,UAAU,CAAC,EAChB,EAAE,CACH,CAAA;IACD,OAAO,OAAO,CAAA;AAChB,CAAC"}
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAcA,mFAAmF;AACnF,qEAAqE;AACrE,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,eAAe,EACf,eAAe,GAChB,MAAM,qBAAqB,CAAA;AAC5B,6EAA6E;AAC7E,OAAO,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAA;AACpE;;;;;;GAMG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAA;AACtC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAI7F,2EAA2E;AAC3E,MAAM,YAAY,GAAsC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAEzE;;;;;;2BAM2B;AAC3B,MAAM,mBAAmB,GAAG,MAAM,CAAC,GAAG,CAAC,iCAAiC,CAAC,CAAA;AACzE,MAAM,iBAAiB,GAAG,UAEzB,CAAA;AACD,MAAM,CAAC,MAAM,aAAa,GACxB,iBAAiB,CAAC,mBAAmB,CAAC,IAAI,aAAa,CAA0B,YAAY,CAAC,CAAA;AAChG,iBAAiB,CAAC,mBAAmB,CAAC,GAAG,aAAa,CAAA;AAEtD;6DAC6D;AAC7D,MAAM,kBAAkB,GAAG,MAAM,CAAC,GAAG,CAAC,gCAAgC,CAAC,CAAA;AACvE,MAAM,gBAAgB,GAAG,UAExB,CAAA;AACD,MAAM,CAAC,MAAM,kBAAkB,GAC7B,gBAAgB,CAAC,kBAAkB,CAAC,IAAI,aAAa,CAA0B,SAAS,CAAC,CAAA;AAC3F,gBAAgB,CAAC,kBAAkB,CAAC,GAAG,kBAAkB,CAAA;AAEzD,MAAM,UAAU,GAAuB,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAExD;;;;;;;;;GASG;AACH,MAAM,UAAU,UAAU;IACxB,MAAM,KAAK,GAAG,UAAU,CAAC,kBAAkB,CAAC,CAAA;IAC5C,OAAO,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;AACzF,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,SAAS;IAGvB,OAAO,UAAU,CAAC,aAAa,CAEJ,CAAA;AAC7B,CAAC;AAED;;sEAEsE;AACtE,MAAM,UAAU,WAAW;IACzB,OAAO,WAAW,CAAC,CAAC,EAAyC,EAAE,OAAyB,EAAE,EAAE;QAC1F,MAAM,QAAQ,GAAG,kBAAkB,EAAE,CAAA;QACrC,IAAI,QAAQ,KAAK,SAAS;YAAE,OAAM;QAClC,MAAM,QAAQ,GAAG,eAAe,CAAC,EAAE,EAAE,OAAO,CAAC,CAAA;QAC7C,QAAQ,CAAC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;IACzC,CAAC,EAAE,EAAE,CAAqB,CAAA;AAC5B,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CAAC,WAAsC;IAC/D,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAU,YAAY,CAAC,CAAA;IAC7D,oGAAoG;IACpG,oFAAoF;IACpF,MAAM,cAAc,GAAG,MAAM,CAAC,WAAW,CAAC,CAAA;IAC1C,cAAc,CAAC,OAAO,GAAG,WAAW,CAAA;IACpC,SAAS,CACP,GAAG,EAAE,CACH,eAAe,CAAC,CAAC,IAAI,EAAE,EAAE;QACvB,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAA;QACtC,OAAO,OAAO,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;IAChE,CAAC,EAAE,UAAU,CAAC,EAChB,EAAE,CACH,CAAA;IACD,OAAO,OAAO,CAAA;AAChB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/web-preact",
3
- "version": "3.3.0",
3
+ "version": "4.0.0",
4
4
  "description": "Preact render adapter for @nifrajs/web - streaming SSR + hydration (no build plugin).",
5
5
  "keywords": [
6
6
  "bun",
@@ -14,6 +14,9 @@
14
14
  "full-stack"
15
15
  ],
16
16
  "type": "module",
17
+ "nifra": {
18
+ "environment": "library"
19
+ },
17
20
  "main": "./dist/index.js",
18
21
  "module": "./dist/index.js",
19
22
  "types": "./dist/index.d.ts",
@@ -82,10 +85,10 @@
82
85
  "access": "public"
83
86
  },
84
87
  "dependencies": {
85
- "@nifrajs/core": "3.3.0",
86
- "@nifrajs/i18n": "3.3.0",
87
- "@nifrajs/image": "3.3.0",
88
- "@nifrajs/web": "3.3.0",
88
+ "@nifrajs/core": "4.0.0",
89
+ "@nifrajs/i18n": "4.0.0",
90
+ "@nifrajs/image": "4.0.0",
91
+ "@nifrajs/web": "4.0.0",
89
92
  "preact-render-to-string": "^6.5.0"
90
93
  },
91
94
  "peerDependencies": {
package/src/client.ts CHANGED
@@ -1,7 +1,4 @@
1
1
  import type { MountRouterOptions, RenderProps } from "@nifrajs/web"
2
- // `/client`, not the root: the root's graph carries the server, and Vite's dev server evaluates it
3
- // instead of tree-shaking it - which broke hydration before the browser ran a line of app code.
4
- import { searchOfChain } from "@nifrajs/web/client"
5
2
  /**
6
3
  * @nifrajs/web-preact/client - Preact client runtime. `hydrate` hydrates a single SSR'd route;
7
4
  * `mountRouter` hydrates a stateful Router that subscribes to the agnostic store (via
@@ -13,6 +10,7 @@ import { type FunctionComponent, h, hydrate as preactHydrate } from "preact"
13
10
  import { useSyncExternalStore } from "preact/compat"
14
11
  import { compose } from "./compose.ts"
15
12
  import { setMountedRouter } from "./fetcher.ts"
13
+ import { routeProps } from "./route-props.ts"
16
14
 
17
15
  // The `_error` boundary chain element - defined in its own (DOM-free) module, re-exported here so
18
16
  // nifra's client codegen resolves it from `@nifrajs/web-preact/client` alongside `mountRouter`.
@@ -44,23 +42,11 @@ export function hydrate(chain: readonly unknown[], props: RenderProps, container
44
42
  * (no `getServerSnapshot`); `router.snapshot` is deterministic, so hydration matches the SSR markup.
45
43
  */
46
44
  export function mountRouter(options: MountRouterOptions): void {
47
- const { router, routes, searchSchemas, container } = options
45
+ const { router, routes, searchSchemas, matchChains, container } = options
48
46
  setMountedRouter(router) // expose it to useFetcher/useFetchers (same page, client-only)
49
47
  const Router: FunctionComponent = () => {
50
48
  const state = useSyncExternalStore(router.subscribe, router.snapshot)
51
- // Derive this route's typed `search` from the URL + the route's schema chain (the SAME `searchOfChain`
52
- // the server ran), so `useSearch` reads an identical value and hydrates with no drift.
53
- const q = state.path.indexOf("?")
54
- const rawSearch = q === -1 ? "" : state.path.slice(q)
55
- return compose(routes[state.routeId] ?? [], {
56
- data: state.data,
57
- actionData: state.actionData,
58
- pending: state.pending,
59
- search: searchOfChain(searchSchemas?.[state.routeId] ?? [], rawSearch),
60
- // The in-flight submission (for optimistic UI) - spread only when present.
61
- ...(state.submission ? { submission: state.submission } : {}),
62
- ...(state.boundaries !== undefined ? { boundaries: state.boundaries } : {}),
63
- })
49
+ return compose(routes[state.routeId] ?? [], routeProps(state, searchSchemas, matchChains))
64
50
  }
65
51
  preactHydrate(h(Router, null), container as Element)
66
52
  }
package/src/compose.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { RenderProps } from "@nifrajs/web"
2
2
  import { type ComponentChildren, type ComponentType, createElement, h, type VNode } from "preact"
3
- import { SearchContext } from "./router.ts"
3
+ import { RenderPropsContext, SearchContext } from "./router.ts"
4
4
 
5
5
  // Frozen empty search so a render with no search context has a stable provider value.
6
6
  const EMPTY_SEARCH: Readonly<Record<string, unknown>> = Object.freeze({})
@@ -13,6 +13,11 @@ const createSearchProvider = createElement as (
13
13
  props: { value: Record<string, unknown> },
14
14
  children?: ComponentChildren,
15
15
  ) => VNode
16
+ const createPropsProvider = createElement as (
17
+ type: typeof RenderPropsContext.Provider,
18
+ props: { value: RenderProps },
19
+ children?: ComponentChildren,
20
+ ) => VNode
16
21
 
17
22
  /**
18
23
  * Fold a layout chain (outermost layout → page) into a single Preact tree: the page (innermost)
@@ -44,5 +49,9 @@ export function compose(chain: readonly unknown[], props: RenderProps): VNode {
44
49
  node,
45
50
  ) as VNode
46
51
  }
47
- return createSearchProvider(SearchContext.Provider, { value: props.search ?? EMPTY_SEARCH }, node)
52
+ return createSearchProvider(
53
+ SearchContext.Provider,
54
+ { value: props.search ?? EMPTY_SEARCH },
55
+ createPropsProvider(RenderPropsContext.Provider, { value: props }, node),
56
+ )
48
57
  }
package/src/content.ts CHANGED
@@ -6,12 +6,14 @@
6
6
  * Security: injects raw HTML, so `html` MUST be trusted (your own Markdown/content, rendered at
7
7
  * build/server time) - never pass user-supplied HTML without sanitizing it first.
8
8
  */
9
+
10
+ import type { TrustedHtml } from "@nifrajs/web"
9
11
  import { createElement, type JSX, type VNode } from "preact"
10
12
 
11
13
  export interface ContentProps
12
14
  extends Omit<JSX.HTMLAttributes<HTMLElement>, "dangerouslySetInnerHTML"> {
13
15
  /** Trusted, pre-rendered HTML (e.g. `entry.html`). */
14
- readonly html: string
16
+ readonly html: TrustedHtml
15
17
  /** Wrapper element (default `"div"`). */
16
18
  readonly as?: string
17
19
  }
package/src/i18n.ts CHANGED
@@ -5,8 +5,16 @@
5
5
  * and the client rebuilds the same formatter from the same props (no mismatch). Uses `preact` +
6
6
  * `preact/hooks` - no JSX.
7
7
  */
8
- import { createFormatter, type Formatter, type Messages } from "@nifrajs/i18n"
9
- import { type ComponentChildren, createContext, createElement, type VNode } from "preact"
8
+ import {
9
+ createFormatter,
10
+ type Formatter,
11
+ type FormatterOptions,
12
+ type MessageKey,
13
+ type RegisteredMessages,
14
+ type Translation,
15
+ } from "@nifrajs/i18n"
16
+ import { type RichChunks, type RichRenderer, renderRich } from "@nifrajs/i18n/rich"
17
+ import { type ComponentChildren, createContext, createElement, Fragment, type VNode } from "preact"
10
18
  import { useContext, useMemo } from "preact/hooks"
11
19
 
12
20
  const I18nContext = createContext<Formatter | null>(null)
@@ -21,23 +29,27 @@ const createProvider = createElement as (
21
29
  children?: ComponentChildren,
22
30
  ) => VNode
23
31
 
24
- export interface I18nProviderProps {
32
+ export interface I18nProviderProps extends FormatterOptions {
25
33
  readonly locale: string
26
- readonly messages: Messages
34
+ /** The catalog for `locale`, checked against the registered catalog type when one is declared. */
35
+ readonly messages: Translation
27
36
  readonly children?: ComponentChildren
28
37
  }
29
38
 
30
- /** Provide a {@link Formatter} (built from `locale` + `messages`) to the subtree. Memoized on
31
- * `locale`/`messages`, so switching locale rebuilds it and re-renders consumers. */
39
+ /** Provide a {@link Formatter} (built from `locale` + `messages`, with the optional `fallback`,
40
+ * `onMissing`, `timeZone` and `numberingSystem` of `createFormatter`) to the subtree. Memoized
41
+ * on those props, so switching locale rebuilds it and re-renders consumers; formatters are cached by
42
+ * catalog identity, so an inline `fallback={[en]}` still yields the same instance. */
32
43
  export function I18nProvider(props: I18nProviderProps): VNode {
44
+ const { locale, messages, fallback, onMissing, timeZone, numberingSystem } = props
33
45
  const formatter = useMemo(
34
- () => createFormatter(props.locale, props.messages),
35
- [props.locale, props.messages],
46
+ () => createFormatter(locale, messages, { fallback, onMissing, timeZone, numberingSystem }),
47
+ [locale, messages, fallback, onMissing, timeZone, numberingSystem],
36
48
  )
37
49
  return createProvider(I18nContext.Provider, { value: formatter }, props.children)
38
50
  }
39
51
 
40
- /** Read the current {@link Formatter} (`{ locale, t, n, d }`). Throws if no `<I18nProvider>` is above. */
52
+ /** Read the current {@link Formatter} (`{ locale, t, get, n, d }`). Throws if no `<I18nProvider>` is above. */
41
53
  export function useT(): Formatter {
42
54
  const formatter = useContext(I18nContext)
43
55
  if (formatter === null) {
@@ -45,3 +57,33 @@ export function useT(): Formatter {
45
57
  }
46
58
  return formatter
47
59
  }
60
+
61
+ /** Tag handlers for {@link rich}, by tag name: each receives its tag's content as one node. */
62
+ export type RichTags = Readonly<Record<string, (content: ComponentChildren) => ComponentChildren>>
63
+
64
+ const join = (chunks: RichChunks<ComponentChildren>): ComponentChildren =>
65
+ chunks.length === 0
66
+ ? null
67
+ : chunks.length === 1
68
+ ? chunks[0]
69
+ : createElement(Fragment, null, ...chunks)
70
+ const PREACT_RICH: RichRenderer<ComponentChildren> = {
71
+ join,
72
+ lineBreak: () => createElement("br", null),
73
+ }
74
+
75
+ /**
76
+ * The message at `key` with its tags rendered by `tags`, as Preact nodes - no HTML, no
77
+ * `dangerouslySetInnerHTML`. `"Read the <link>terms</link>"` with
78
+ * `{ link: (content) => <a href="/terms">{content}</a> }` renders the link around "terms"; a tag with
79
+ * no handler renders its content as text, `<br/>` is a `<br>`, and interpolated values are always
80
+ * text. See `@nifrajs/i18n/rich`.
81
+ */
82
+ export function rich<M extends object = RegisteredMessages>(
83
+ formatter: Formatter<M>,
84
+ key: MessageKey<M>,
85
+ tags?: RichTags,
86
+ vars?: Readonly<Record<string, unknown>>,
87
+ ): ComponentChildren {
88
+ return renderRich(PREACT_RICH, formatter, key, tags, vars)
89
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,44 @@ import type { RenderAdapter } from "@nifrajs/web"
9
9
  import { compose } from "./compose.ts"
10
10
  import { preactRenderToStream, preactRenderToString } from "./preact-render.ts"
11
11
 
12
+ const ISLAND_RUNTIME_OPEN = "<script>(function(){"
13
+ const ISLAND_RUNTIME_OPEN_BYTES = new TextEncoder().encode(ISLAND_RUNTIME_OPEN)
14
+
15
+ const startsWithBytes = (chunk: Uint8Array, prefix: Uint8Array): boolean => {
16
+ if (chunk.length < prefix.length) return false
17
+ for (let i = 0; i < prefix.length; i++) if (chunk[i] !== prefix[i]) return false
18
+ return true
19
+ }
20
+
21
+ /**
22
+ * `preact-render-to-string` streams the runtime that moves resolved `<Suspense>` content into place as
23
+ * one attribute-less `<script>` chunk, and takes no nonce option. Stamp the document nonce on exactly
24
+ * that chunk (it defines the `preact-island` element); every other chunk passes through untouched, so
25
+ * an app-rendered `<script>` never inherits the nonce.
26
+ */
27
+ function withIslandRuntimeNonce(
28
+ stream: ReadableStream<Uint8Array>,
29
+ nonce: string,
30
+ ): ReadableStream<Uint8Array> {
31
+ const decoder = new TextDecoder()
32
+ const encoder = new TextEncoder()
33
+ const nonced = `<script nonce="${nonce.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;")}">(function(){`
34
+ return stream.pipeThrough(
35
+ new TransformStream<Uint8Array, Uint8Array>({
36
+ transform(chunk, controller) {
37
+ if (startsWithBytes(chunk, ISLAND_RUNTIME_OPEN_BYTES)) {
38
+ const text = decoder.decode(chunk)
39
+ if (text.includes('"preact-island"')) {
40
+ controller.enqueue(encoder.encode(nonced + text.slice(ISLAND_RUNTIME_OPEN.length)))
41
+ return
42
+ }
43
+ }
44
+ controller.enqueue(chunk)
45
+ },
46
+ }),
47
+ )
48
+ }
49
+
12
50
  /** The Preact server render adapter - pass to @nifrajs/web's `renderPage`. */
13
51
  export const preactAdapter: RenderAdapter = {
14
52
  // Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). The sync
@@ -24,11 +62,12 @@ export const preactAdapter: RenderAdapter = {
24
62
  }
25
63
  return loaded.renderToString(compose(chain, props))
26
64
  },
27
- async renderToStream(chain, props) {
65
+ async renderToStream(chain, props, options) {
28
66
  // `renderToReadableStream` yields a Web ReadableStream<Uint8Array>; <Suspense> boundaries
29
67
  // (preact/compat) stream as they resolve, mirroring the React adapter.
30
68
  const { renderToReadableStream } = await preactRenderToStream()
31
- return renderToReadableStream(compose(chain, props))
69
+ const stream = renderToReadableStream(compose(chain, props))
70
+ return options?.nonce === undefined ? stream : withIslandRuntimeNonce(stream, options.nonce)
32
71
  },
33
72
  // Preact reconciles against the existing DOM on hydrate (like React), so there's no per-document
34
73
  // bootstrap script - the seam allows the empty string (contrast Solid's generateHydrationScript).
@@ -0,0 +1,34 @@
1
+ import type { MountRouterOptions, RenderProps, RouterState } from "@nifrajs/web"
2
+ // `/client`, not the root: the root's graph carries the server, and Vite's dev server evaluates it
3
+ // instead of tree-shaking it - which broke hydration before the browser ran a line of app code.
4
+ import { searchOfChain } from "@nifrajs/web/client"
5
+
6
+ /** The client half of the server's `RenderProps`, so the first render matches the SSR markup. */
7
+ export function routeProps(
8
+ state: RouterState,
9
+ searchSchemas: MountRouterOptions["searchSchemas"],
10
+ matchChains?: MountRouterOptions["matchChains"],
11
+ ): RenderProps {
12
+ const matchChain = matchChains?.[state.routeId]
13
+ // Derive this route's typed `search` from the URL + the route's schema chain (the SAME `searchOfChain`
14
+ // the server ran), so `useSearch` reads an identical value and hydrates with no drift.
15
+ const q = state.path.indexOf("?")
16
+ const rawSearch = q === -1 ? "" : state.path.slice(q)
17
+ return {
18
+ data: state.data,
19
+ // Each layout's own loader data. Without it a layout renders `data: null` on the client while the
20
+ // server rendered it with its data - a hydration mismatch on the first paint.
21
+ ...(state.layoutData !== undefined ? { layoutData: state.layoutData } : {}),
22
+ actionData: state.actionData,
23
+ pending: state.pending,
24
+ // The same `params`/`path` the server render received, so `useMatches` reports the same chain.
25
+ params: state.params,
26
+ path: state.path,
27
+ search: searchOfChain(searchSchemas?.[state.routeId] ?? [], rawSearch),
28
+ // The in-flight submission (for optimistic UI) - spread only when present.
29
+ ...(state.submission ? { submission: state.submission } : {}),
30
+ ...(state.boundaries !== undefined ? { boundaries: state.boundaries } : {}),
31
+ // The chain `useMatches` reports - the server passed the same one to the SSR render.
32
+ ...(matchChain !== undefined ? { matchChain } : {}),
33
+ }
34
+ }
package/src/router.ts CHANGED
@@ -9,6 +9,8 @@ import type {
9
9
  NavigateFunction,
10
10
  NavigateOptions,
11
11
  NavigateTargetInput,
12
+ RenderProps,
13
+ UIMatch,
12
14
  } from "@nifrajs/web"
13
15
  // `/client`, not the root - these are DOM values, and the root's graph carries the
14
16
  // server, which Vite's dev server evaluates rather than tree-shakes.
@@ -18,6 +20,8 @@ import {
18
20
  registerBlocker,
19
21
  resolveNavigate,
20
22
  } from "@nifrajs/web/client"
23
+ // Its own subpath, so an app that never calls `useMatches` never bundles it.
24
+ import { chainMatches } from "@nifrajs/web/internal/matches-runtime"
21
25
  /**
22
26
  * `@nifrajs/web-preact/router` - Preact routing bindings over the agnostic `@nifrajs/web` history layer:
23
27
  * `useNavigate` (programmatic navigation), `useBlocker` (the unsaved-changes guard), and `useSearch`
@@ -26,9 +30,9 @@ import {
26
30
  * `compose` provides on SSR + client mount alike. Client-safe (no `preact-render-to-string`). No JSX.
27
31
  */
28
32
  import { createContext } from "preact"
29
- import { useCallback, useContext, useEffect, useRef, useState } from "preact/compat"
33
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/compat"
30
34
 
31
- export type { Blocker, BlockerFunction, BlockerState, NavigateFunction }
35
+ export type { Blocker, BlockerFunction, BlockerState, NavigateFunction, UIMatch }
32
36
 
33
37
  // Frozen empty search so the default context value has a stable reference.
34
38
  const EMPTY_SEARCH: Readonly<Record<string, unknown>> = Object.freeze({})
@@ -48,6 +52,33 @@ export const SearchContext =
48
52
  searchContextSlot[SEARCH_CONTEXT_SLOT] ?? createContext<Record<string, unknown>>(EMPTY_SEARCH)
49
53
  searchContextSlot[SEARCH_CONTEXT_SLOT] = SearchContext
50
54
 
55
+ /** The props `compose` rendered the chain with - what {@link useMatches} reads. A `globalThis`
56
+ * singleton for the same reason as {@link SearchContext}. */
57
+ const PROPS_CONTEXT_SLOT = Symbol.for("nifra.web-preact.props-context")
58
+ const propsContextSlot = globalThis as {
59
+ [PROPS_CONTEXT_SLOT]?: ReturnType<typeof createContext<RenderProps | undefined>>
60
+ }
61
+ export const RenderPropsContext =
62
+ propsContextSlot[PROPS_CONTEXT_SLOT] ?? createContext<RenderProps | undefined>(undefined)
63
+ propsContextSlot[PROPS_CONTEXT_SLOT] = RenderPropsContext
64
+
65
+ const NO_MATCHES: readonly UIMatch[] = Object.freeze([])
66
+
67
+ /**
68
+ * The rendered chain - each layout, then the page - with the URL prefix, params and loader data each
69
+ * one owns, plus its `handle` export. The same list on the server render and the client mount, so a
70
+ * layout can render breadcrumbs from its children's `handle`s without a hydration mismatch.
71
+ *
72
+ * ```tsx
73
+ * export const handle = { crumb: "Settings" }
74
+ * const crumbs = useMatches().flatMap((m) => (m.handle as { crumb?: string } | undefined)?.crumb ?? [])
75
+ * ```
76
+ */
77
+ export function useMatches(): readonly UIMatch[] {
78
+ const props = useContext(RenderPropsContext)
79
+ return useMemo(() => (props === undefined ? NO_MATCHES : chainMatches(props)), [props])
80
+ }
81
+
51
82
  /**
52
83
  * The route's typed, validated search params - the SAME value the loader received as `ctx.search`.
53
84
  * SSR-correct: `compose` provides it from the URL server-side and from the identical derivation on the