@pajecawav/yamf 0.0.8 → 0.0.9

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.
Files changed (59) hide show
  1. package/README.md +63 -7
  2. package/dist/client/index.js +39 -1
  3. package/dist/client/index.js.map +1 -0
  4. package/dist/components/Head.d.ts +2 -2
  5. package/dist/components/Head.js.map +1 -1
  6. package/dist/hooks/isPrerendering.d.ts +12 -0
  7. package/dist/hooks/isPrerendering.d.ts.map +1 -0
  8. package/dist/hooks/isPrerendering.js +17 -0
  9. package/dist/hooks/isPrerendering.js.map +1 -0
  10. package/dist/hooks/useHead.d.ts +9 -3
  11. package/dist/hooks/useHead.d.ts.map +1 -1
  12. package/dist/hooks/useHead.js +46 -4
  13. package/dist/hooks/useHead.js.map +1 -1
  14. package/dist/index.d.ts +6 -2
  15. package/dist/index.js +3 -1
  16. package/dist/island/client.js +44 -36
  17. package/dist/island/client.js.map +1 -1
  18. package/dist/page.d.ts +31 -4
  19. package/dist/page.d.ts.map +1 -1
  20. package/dist/page.js +60 -6
  21. package/dist/page.js.map +1 -1
  22. package/dist/safeAsync.d.ts +19 -0
  23. package/dist/safeAsync.d.ts.map +1 -0
  24. package/dist/safeAsync.js +34 -0
  25. package/dist/safeAsync.js.map +1 -0
  26. package/dist/server/entry.d.mts +9 -0
  27. package/dist/server/entry.d.mts.map +1 -1
  28. package/dist/server/entry.mjs +7 -2
  29. package/dist/server/entry.mjs.map +1 -1
  30. package/dist/server/island/server.d.mts.map +1 -1
  31. package/dist/server/island/server.mjs +11 -1
  32. package/dist/server/island/server.mjs.map +1 -1
  33. package/dist/vite/index.d.mts.map +1 -1
  34. package/dist/vite/index.mjs +12 -4
  35. package/dist/vite/index.mjs.map +1 -1
  36. package/dist/vite/islands.mjs +34 -5
  37. package/dist/vite/islands.mjs.map +1 -1
  38. package/dist/vite/prerender.mjs +16 -0
  39. package/dist/vite/prerender.mjs.map +1 -0
  40. package/dist/vite/virtual-root.mjs +11 -4
  41. package/dist/vite/virtual-root.mjs.map +1 -1
  42. package/dist/vite/virtual-template.mjs +8 -3
  43. package/dist/vite/virtual-template.mjs.map +1 -1
  44. package/package.json +18 -11
  45. package/src/client/index.ts +51 -1
  46. package/src/components/Head.tsx +2 -2
  47. package/src/hooks/isPrerendering.ts +13 -0
  48. package/src/hooks/useHead.tsx +98 -9
  49. package/src/index.ts +10 -1
  50. package/src/island/client.tsx +73 -65
  51. package/src/island/server.tsx +24 -1
  52. package/src/page.tsx +176 -11
  53. package/src/safeAsync.tsx +46 -0
  54. package/src/server/entry.tsx +25 -1
  55. package/src/vite/index.ts +30 -3
  56. package/src/vite/islands.ts +59 -5
  57. package/src/vite/prerender.ts +27 -0
  58. package/src/vite/virtual-root.ts +14 -4
  59. package/src/vite/virtual-template.ts +15 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # yamf
2
2
 
3
- SSR meta-framework on top of [Vite](https://vite.dev), [Nitro](https://nitro.build/), and [Hono JSX](https://hono.dev/docs/guides/jsx). File-based routing for HTML pages, islands architecture for client interactivity, client-side routing via [wouter](https://github.com/molefrog/wouter), React-ecosystem compatibility through [@hono/react-compat](https://github.com/honojs/react-compat), head/SEO via [unhead](https://unhead.unjs.io/), and full Nitro feature set (presets, caching, middleware, API routes).
3
+ SSR meta-framework on top of [Vite](https://vite.dev), [Nitro](https://nitro.build/), and [Hono JSX](https://hono.dev/docs/guides/jsx). File-based routing for HTML pages, islands architecture for client interactivity, routing hooks in islands via [wouter](https://github.com/molefrog/wouter), React-ecosystem compatibility through [@hono/react-compat](https://github.com/honojs/react-compat), head/SEO via [unhead](https://unhead.unjs.io/), and full Nitro feature set (presets, caching, middleware, API routes).
4
4
 
5
5
  ## Install
6
6
 
@@ -98,6 +98,56 @@ export default definePage({
98
98
  });
99
99
  ```
100
100
 
101
+ ### Streaming and mid-stream errors
102
+
103
+ Set `stream: true` to flush the shell early and stream Suspense boundaries as they resolve (better TTFB for slow data). If an async component rejects after the shell has been sent, the streaming renderer swallows the error and the fallback would stay forever — wrap risky async components with `safeAsync` to render an error fallback instead:
104
+
105
+ ```tsx
106
+ import { definePage, safeAsync } from "@pajecawav/yamf";
107
+ import { Suspense } from "hono/jsx";
108
+
109
+ const SlowSection = safeAsync(Slow, () => <p>Failed to load.</p>);
110
+
111
+ export default definePage({
112
+ stream: true,
113
+ render: () => (
114
+ <Suspense fallback={<p>Loading…</p>}>
115
+ <SlowSection />
116
+ </Suspense>
117
+ ),
118
+ });
119
+ ```
120
+
121
+ ### Caching
122
+
123
+ ```tsx
124
+ export default definePage({
125
+ cache: 60, // Cache-Control: public, max-age=60
126
+ // or
127
+ cache: { maxAge: 60, swr: 10, private: true },
128
+ render: () => ...,
129
+ });
130
+ ```
131
+
132
+ ### Params and query validation
133
+
134
+ `params` and `query` accept any [standard-schema](https://standardschema.dev/) validator (zod, valibot, arktype…). Validation runs before render: invalid path params produce 404, invalid query produces 400. Validated, typed values are passed to `render`:
135
+
136
+ ```tsx
137
+ import { z } from "zod";
138
+ import { definePage } from "@pajecawav/yamf";
139
+
140
+ export default definePage({
141
+ params: z.object({ id: z.coerce.number().int() }),
142
+ query: z.object({ page: z.coerce.number().default(1) }),
143
+ render: (_event, { params, query }) => (
144
+ <p>
145
+ {params.id} — page {query.page}
146
+ </p>
147
+ ),
148
+ });
149
+ ```
150
+
101
151
  ## Islands
102
152
 
103
153
  Any file matching `*.island.{tsx,ts,jsx,js}` is automatically wrapped. Each exported function becomes an island that server-renders inside `<yamf-island>` and hydrates on the client.
@@ -147,7 +197,7 @@ export default definePage({
147
197
  | `visible` | Hydrate when scrolled into view. |
148
198
  | `skip` | Server-rendered only, no hydration. |
149
199
 
150
- Props are serialized with `devalue` (supports `Date`, `Map`, `Set`, `URL`, `RegExp`, `Error`, `BigInt`, cycles).
200
+ Props are serialized with `devalue` (supports `Date`, `Map`, `Set`, `URL`, `RegExp`, `Error`, `BigInt`, cycles). Islands with `yamf-client="skip"` (or `false`) do not serialize their props at all — the client never reads them. In dev, yamf warns when serialized props exceed 16KB (configurable via the `YAMF_ISLAND_PROPS_LIMIT` define/env).
151
201
 
152
202
  ### React ecosystem compatibility
153
203
 
@@ -155,7 +205,7 @@ The Vite plugin aliases `react` and `react-dom` to [`@hono/react-compat`](https:
155
205
 
156
206
  ## Routing
157
207
 
158
- Every page renders inside a [wouter](https://github.com/molefrog/wouter) `<Router>`, seeded with the current request's `pathname` and `search` for SSR. After hydration, navigation is client-side — wouter hooks and components work inside islands and the root layout:
208
+ Every page renders inside a [wouter](https://github.com/molefrog/wouter) `<Router>`, seeded with the current request's `pathname` and `search` for SSR. wouter hooks and components work inside islands and the root layout:
159
209
 
160
210
  ```tsx
161
211
  // src/components/Search.island.tsx
@@ -178,7 +228,11 @@ export const Search = () => {
178
228
  };
179
229
  ```
180
230
 
181
- wouter's `Link`, `Route`, `useLocation`, `useRoute`, and `useSearchParams` are all available. Note that yamf's file-based routing (see [File routing](#file-routing)) handles full-page SSR routes, while wouter handles client-side navigation and query-param state within a page.
231
+ wouter's `Route`, `useLocation`, `useRoute`, and `useSearchParams` are all available for in-page routing state. wouter is an optional peer dependency of yamf — declare it in your own `package.json` when you use it in islands.
232
+
233
+ **Navigation between pages is always a full page load** — pages are server-rendered HTML documents and are not shipped to the client. Use plain `<a href>` links for page navigation (optionally with [Speculation Rules](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API) prefetching/prerendering).
234
+
235
+ > **Footgun:** wouter's `<Link>` inside an island intercepts the click and changes the URL with `history.pushState` — but the page content does not change, leaving the URL and the document out of sync. Do not use `<Link>` for cross-page navigation in yamf.
182
236
 
183
237
  ## Client entry
184
238
 
@@ -187,7 +241,9 @@ wouter's `Link`, `Route`, `useLocation`, `useRoute`, and `useSearchParams` are a
187
241
  import "@pajecawav/yamf/client";
188
242
  ```
189
243
 
190
- Side-effect import. Registers the `yamf-island` custom element and initializes `window.__UNHEAD__`. Required for island hydration and client-side `useHead`.
244
+ Side-effect import. Registers the `yamf-island` custom element via a tiny (~1KB) bootstrap and initializes the client head. The full hydration runtime (hono/jsx DOM renderer, devalue, unhead client) is dynamically imported only when the document actually contains an island — island-free pages don't pay for it.
245
+
246
+ The client head is hydrated from the server: the entry-head config (including `titleTemplate`) and the server's head entries are re-registered on the client, and head patches streamed after the shell are applied on hydration.
191
247
 
192
248
  ## Head and SEO
193
249
 
@@ -263,8 +319,8 @@ Optional. Wraps every page's content. CSS imported here is included in the asset
263
319
 
264
320
  - `useEvent()` — current `H3Event`. Works in components inside the render tree and root layout, not in `render` itself.
265
321
  - `useSSRContext()` — returns `{ head, event } | null`.
266
- - `useHead(input)` — push head tags.
267
- - `useSeoMeta(input)` — shorthand for SEO meta.
322
+ - `useHead(input)` — push head tags. On the client this is a real hook: the head entry is created once per component, patched on re-render and disposed on unmount — call it unconditionally at the top level of the component.
323
+ - `useSeoMeta(input)` — shorthand for SEO meta. Same lifecycle on the client.
268
324
 
269
325
  ## API routes and error handling
270
326
 
@@ -1 +1,39 @@
1
- import "../island/client.js";
1
+ //#region src/client/index.ts
2
+ /**
3
+ * Tiny bootstrap for the island runtime.
4
+ *
5
+ * Defines the <yamf-island> custom element and dynamically imports the real
6
+ * hydration runtime (hono/jsx dom renderer, devalue, unhead client) only when
7
+ * the document actually contains an island. Pages — or whole apps — without
8
+ * islands never pay for it.
9
+ */
10
+ const pending = /* @__PURE__ */ new Set();
11
+ let hydrate = null;
12
+ let loading = false;
13
+ const loadRuntime = () => {
14
+ if (loading) return;
15
+ loading = true;
16
+ import("../island/client.js").then(({ hydrateIsland }) => {
17
+ hydrate = hydrateIsland;
18
+ for (const island of pending) {
19
+ pending.delete(island);
20
+ hydrate(island);
21
+ }
22
+ });
23
+ };
24
+ customElements.define("yamf-island", class extends HTMLElement {
25
+ connectedCallback() {
26
+ if (hydrate) {
27
+ hydrate(this);
28
+ return;
29
+ }
30
+ pending.add(this);
31
+ loadRuntime();
32
+ }
33
+ disconnectedCallback() {
34
+ pending.delete(this);
35
+ }
36
+ });
37
+ //#endregion
38
+
39
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/client/index.ts"],"sourcesContent":["/**\n * Tiny bootstrap for the island runtime.\n *\n * Defines the <yamf-island> custom element and dynamically imports the real\n * hydration runtime (hono/jsx dom renderer, devalue, unhead client) only when\n * the document actually contains an island. Pages — or whole apps — without\n * islands never pay for it.\n */\n\nconst pending: Set<HTMLElement> = new Set();\n\ntype Hydrate = (island: HTMLElement) => void;\n\nlet hydrate: Hydrate | null = null;\nlet loading: boolean = false;\n\nconst loadRuntime = (): void => {\n\tif (loading) {\n\t\treturn;\n\t}\n\n\tloading = true;\n\n\tvoid import(\"../island/client\").then(({ hydrateIsland }) => {\n\t\thydrate = hydrateIsland;\n\n\t\tfor (const island of pending) {\n\t\t\tpending.delete(island);\n\t\t\thydrate(island);\n\t\t}\n\t});\n};\n\ncustomElements.define(\n\t\"yamf-island\",\n\tclass extends HTMLElement {\n\t\tpublic connectedCallback() {\n\t\t\tif (hydrate) {\n\t\t\t\thydrate(this);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tpending.add(this);\n\t\t\tloadRuntime();\n\t\t}\n\n\t\tpublic disconnectedCallback() {\n\t\t\tpending.delete(this);\n\t\t}\n\t},\n);\n"],"mappings":";;;;;;;;;AASA,MAAM,0BAA4B,IAAI,IAAI;AAI1C,IAAI,UAA0B;AAC9B,IAAI,UAAmB;AAEvB,MAAM,oBAA0B;CAC/B,IAAI,SACH;CAGD,UAAU;CAEV,OAAY,sBAAmB,CAAC,MAAM,EAAE,oBAAoB;EAC3D,UAAU;EAEV,KAAK,MAAM,UAAU,SAAS;GAC7B,QAAQ,OAAO,MAAM;GACrB,QAAQ,MAAM;EACf;CACD,CAAC;AACF;AAEA,eAAe,OACd,eACA,cAAc,YAAY;CACzB,oBAA2B;EAC1B,IAAI,SAAS;GACZ,QAAQ,IAAI;GACZ;EACD;EAEA,QAAQ,IAAI,IAAI;EAChB,YAAY;CACb;CAEA,uBAA8B;EAC7B,QAAQ,OAAO,IAAI;CACpB;AACD,CACD"}
@@ -1,6 +1,6 @@
1
- import { ResolvableHead } from "unhead/types";
1
+ import { YamfHead } from "../shared/head.js";
2
2
  //#region src/components/Head.d.ts
3
- export type HeadProps = ResolvableHead;
3
+ export type HeadProps = YamfHead;
4
4
  export declare const Head: (props: HeadProps) => void;
5
5
  //#endregion
6
6
  //# sourceMappingURL=Head.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Head.js","names":[],"sources":["../../src/components/Head.tsx"],"sourcesContent":["import type { ResolvableHead } from \"unhead/types\";\nimport { useHead } from \"#/hooks/useHead\";\n\nexport type HeadProps = ResolvableHead;\n\nexport const Head = (props: HeadProps): void => {\n\tuseHead(props);\n};\n"],"mappings":";;AAKA,MAAa,QAAQ,UAA2B;CAC/C,QAAQ,KAAK;AACd"}
1
+ {"version":3,"file":"Head.js","names":[],"sources":["../../src/components/Head.tsx"],"sourcesContent":["import { useHead } from \"#/hooks/useHead\";\nimport type { YamfHead } from \"#/shared/head\";\n\nexport type HeadProps = YamfHead;\n\nexport const Head = (props: HeadProps): void => {\n\tuseHead(props);\n};\n"],"mappings":";;AAKA,MAAa,QAAQ,UAA2B;CAC/C,QAAQ,KAAK;AACd"}
@@ -0,0 +1,12 @@
1
+ //#region src/hooks/isPrerendering.d.ts
2
+ /**
3
+ * Whether the current render runs inside Nitro's build-time prerender pass —
4
+ * the prerenderer fetching the route to write it to disk as a static file.
5
+ *
6
+ * Always `false` in dev and at request time. Prefer this over
7
+ * `import.meta.prerender`: pages are bundled by the vite ssr service, where
8
+ * that flag is never replaced and would always read as `undefined`.
9
+ */
10
+ export declare const isPrerendering: () => boolean;
11
+ //#endregion
12
+ //# sourceMappingURL=isPrerendering.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isPrerendering.d.ts","names":[],"sources":["../../src/hooks/isPrerendering.ts"],"mappings":";;;;;;;;;qBAUa"}
@@ -0,0 +1,17 @@
1
+ import { useSSRContext } from "../context/ssr.js";
2
+ //#region src/hooks/isPrerendering.ts
3
+ /**
4
+ * Whether the current render runs inside Nitro's build-time prerender pass —
5
+ * the prerenderer fetching the route to write it to disk as a static file.
6
+ *
7
+ * Always `false` in dev and at request time. Prefer this over
8
+ * `import.meta.prerender`: pages are bundled by the vite ssr service, where
9
+ * that flag is never replaced and would always read as `undefined`.
10
+ */
11
+ const isPrerendering = () => {
12
+ return useSSRContext()?.event.req.headers.has("x-nitro-prerender") ?? false;
13
+ };
14
+ //#endregion
15
+ export { isPrerendering };
16
+
17
+ //# sourceMappingURL=isPrerendering.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isPrerendering.js","names":[],"sources":["../../src/hooks/isPrerendering.ts"],"sourcesContent":["import { useSSRContext } from \"#/context/ssr\";\n\n/**\n * Whether the current render runs inside Nitro's build-time prerender pass —\n * the prerenderer fetching the route to write it to disk as a static file.\n *\n * Always `false` in dev and at request time. Prefer this over\n * `import.meta.prerender`: pages are bundled by the vite ssr service, where\n * that flag is never replaced and would always read as `undefined`.\n */\nexport const isPrerendering = (): boolean => {\n\treturn useSSRContext()?.event.req.headers.has(\"x-nitro-prerender\") ?? false;\n};\n"],"mappings":";;;;;;;;;;AAUA,MAAa,uBAAgC;CAC5C,OAAO,cAAc,CAAC,EAAE,MAAM,IAAI,QAAQ,IAAI,mBAAmB,KAAK;AACvE"}
@@ -1,12 +1,18 @@
1
+ import { YamfHead } from "../shared/head.js";
1
2
  import { ClientUnhead } from "unhead/client";
2
- import { ResolvableHead, UseSeoMetaInput } from "unhead/types";
3
+ import { UseSeoMetaInput } from "unhead/types";
3
4
  //#region src/hooks/useHead.d.ts
5
+ interface StreamQueue {
6
+ _q?: unknown[][];
7
+ push?: (batch: unknown[]) => void;
8
+ }
4
9
  declare global {
5
10
  interface Window {
6
- __UNHEAD__?: ClientUnhead;
11
+ __unhead__?: StreamQueue;
12
+ __yamfHead__?: ClientUnhead;
7
13
  }
8
14
  }
9
- export declare const useHead: (input?: ResolvableHead) => void;
15
+ export declare const useHead: (input?: YamfHead) => void;
10
16
  export declare const useSeoMeta: (input?: UseSeoMetaInput) => void;
11
17
  //#endregion
12
18
  //# sourceMappingURL=useHead.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useHead.d.ts","names":[],"sources":["../../src/hooks/useHead.tsx"],"mappings":";;;QAMe;YACJ;IACT,aAAa;;;qBAQF,UAAW,QAAQ;qBAYnB,aAAc,QAAQ"}
1
+ {"version":3,"file":"useHead.d.ts","names":[],"sources":["../../src/hooks/useHead.tsx"],"mappings":";;;;UAQU;EACT;EACA,QAAQ;;QAGM;YACJ;IACT,aAAa;IACb,eAAe;;;qBAqEJ,UAAW,QAAQ;qBA0BnB,aAAc,QAAQ"}
@@ -1,19 +1,61 @@
1
1
  import { useSSRContext } from "../context/ssr.js";
2
+ import { useEffect, useRef } from "hono/jsx";
2
3
  import { useHead, useSeoMeta } from "unhead";
3
4
  import { createHead } from "unhead/client";
4
5
  //#region src/hooks/useHead.tsx
5
- if (!import.meta.env.SSR) window.__UNHEAD__ = createHead();
6
+ if (!import.meta.env.SSR) {
7
+ const head = createHead();
8
+ const drain = (batch) => {
9
+ for (const input of batch) head.push(input);
10
+ };
11
+ const queue = window.__unhead__?._q;
12
+ if (queue) for (const batch of queue) drain(batch);
13
+ window.__unhead__ = { push: drain };
14
+ window.__yamfHead__ = head;
15
+ }
16
+ /**
17
+ * Binds an unhead entry to the calling component's lifecycle: the entry is
18
+ * created once on mount, patched on every re-render with the latest input,
19
+ * and disposed on unmount. Without this, every re-render pushes a new entry
20
+ * that is never removed — a leak and growing DOM render cost.
21
+ */
22
+ const useHeadEntryLifecycle = (input, createEntry) => {
23
+ const entryRef = useRef(null);
24
+ useEffect(() => {
25
+ const head = window.__yamfHead__;
26
+ if (!head || input === void 0) return;
27
+ const entry = createEntry(head, input);
28
+ entryRef.current = entry;
29
+ return () => {
30
+ entry.dispose();
31
+ entryRef.current = null;
32
+ };
33
+ }, []);
34
+ useEffect(() => {
35
+ if (input !== void 0) entryRef.current?.patch(input);
36
+ }, [input]);
37
+ };
6
38
  const useHead$1 = (input) => {
7
39
  if (import.meta.env.SSR) {
40
+ if (input === void 0) return;
8
41
  const ctx = useSSRContext();
9
- if (ctx?.head) useHead(ctx.head, input);
10
- } else if (window.__UNHEAD__) useHead(window.__UNHEAD__, input);
42
+ if (ctx?.head) {
43
+ const { seo, ...rest } = input;
44
+ if (seo !== void 0) useSeoMeta(ctx.head, seo);
45
+ useHead(ctx.head, rest);
46
+ }
47
+ return;
48
+ }
49
+ useHeadEntryLifecycle(input, (head, resolved) => useHead(head, resolved));
11
50
  };
12
51
  const useSeoMeta$1 = (input) => {
13
52
  if (import.meta.env.SSR) {
53
+ if (input === void 0) return;
14
54
  const ctx = useSSRContext();
15
55
  if (ctx?.head) useSeoMeta(ctx.head, input);
16
- } else if (window.__UNHEAD__) useSeoMeta(window.__UNHEAD__, input);
56
+ return;
57
+ }
58
+ useHeadEntryLifecycle(input, (head, resolved) => useSeoMeta(head, resolved));
17
59
  };
18
60
  //#endregion
19
61
  export { useHead$1 as useHead, useSeoMeta$1 as useSeoMeta };
@@ -1 +1 @@
1
- {"version":3,"file":"useHead.js","names":["useHead","useSeoMeta"],"sources":["../../src/hooks/useHead.tsx"],"sourcesContent":["import { useHead as _useHead, useSeoMeta as _useSeoMeta } from \"unhead\";\nimport type { ClientUnhead } from \"unhead/client\";\nimport { createHead } from \"unhead/client\";\nimport type { ResolvableHead, UseSeoMetaInput } from \"unhead/types\";\nimport { useSSRContext } from \"#/context/ssr\";\n\ndeclare global {\n\tinterface Window {\n\t\t__UNHEAD__?: ClientUnhead;\n\t}\n}\n\nif (!import.meta.env.SSR) {\n\twindow.__UNHEAD__ = createHead();\n}\n\nexport const useHead = (input?: ResolvableHead): void => {\n\tif (import.meta.env.SSR) {\n\t\tconst ctx = useSSRContext();\n\n\t\tif (ctx?.head) {\n\t\t\t_useHead(ctx.head, input);\n\t\t}\n\t} else if (window.__UNHEAD__) {\n\t\t_useHead(window.__UNHEAD__, input);\n\t}\n};\n\nexport const useSeoMeta = (input?: UseSeoMetaInput): void => {\n\tif (import.meta.env.SSR) {\n\t\tconst ctx = useSSRContext();\n\n\t\tif (ctx?.head) {\n\t\t\t_useSeoMeta(ctx.head, input);\n\t\t}\n\t} else if (window.__UNHEAD__) {\n\t\t_useSeoMeta(window.__UNHEAD__, input);\n\t}\n};\n"],"mappings":";;;;AAYA,IAAI,CAAC,YAAY,IAAI,KACpB,OAAO,aAAa,WAAW;AAGhC,MAAaA,aAAW,UAAiC;CACxD,IAAI,YAAY,IAAI,KAAK;EACxB,MAAM,MAAM,cAAc;EAE1B,IAAI,KAAK,MACR,QAAS,IAAI,MAAM,KAAK;CAE1B,OAAO,IAAI,OAAO,YACjB,QAAS,OAAO,YAAY,KAAK;AAEnC;AAEA,MAAaC,gBAAc,UAAkC;CAC5D,IAAI,YAAY,IAAI,KAAK;EACxB,MAAM,MAAM,cAAc;EAE1B,IAAI,KAAK,MACR,WAAY,IAAI,MAAM,KAAK;CAE7B,OAAO,IAAI,OAAO,YACjB,WAAY,OAAO,YAAY,KAAK;AAEtC"}
1
+ {"version":3,"file":"useHead.js","names":["useHead","_useHead","useSeoMeta","_useSeoMeta"],"sources":["../../src/hooks/useHead.tsx"],"sourcesContent":["import { useEffect, useRef } from \"hono/jsx\";\nimport { useHead as _useHead, useSeoMeta as _useSeoMeta } from \"unhead\";\nimport type { ClientUnhead } from \"unhead/client\";\nimport { createHead } from \"unhead/client\";\nimport type { ActiveHeadEntry, ResolvableHead, UseSeoMetaInput } from \"unhead/types\";\nimport { useSSRContext } from \"#/context/ssr\";\nimport type { YamfHead } from \"#/shared/head\";\n\ninterface StreamQueue {\n\t_q?: unknown[][];\n\tpush?: (batch: unknown[]) => void;\n}\n\ndeclare global {\n\tinterface Window {\n\t\t__unhead__?: StreamQueue;\n\t\t__yamfHead__?: ClientUnhead;\n\t}\n}\n\nif (!import.meta.env.SSR) {\n\tconst head = createHead();\n\n\tconst drain = (batch: unknown[]): void => {\n\t\tfor (const input of batch) {\n\t\t\thead.push(input as ResolvableHead);\n\t\t}\n\t};\n\n\t// inline scripts (the handshake payload and streamed suspense patches)\n\t// queue into window.__unhead__ while the document parses; this module is\n\t// part of the deferred client entry and runs after parsing, so by now the\n\t// queue holds everything the server sent — drain it into the client head\n\tconst queue = window.__unhead__?._q;\n\n\tif (queue) {\n\t\tfor (const batch of queue) {\n\t\t\tdrain(batch);\n\t\t}\n\t}\n\n\t// keep late patches (a stream that is still open) working\n\twindow.__unhead__ = { push: drain };\n\n\twindow.__yamfHead__ = head;\n}\n\n/**\n * Binds an unhead entry to the calling component's lifecycle: the entry is\n * created once on mount, patched on every re-render with the latest input,\n * and disposed on unmount. Without this, every re-render pushes a new entry\n * that is never removed — a leak and growing DOM render cost.\n */\nconst useHeadEntryLifecycle = <T,>(\n\tinput: T | undefined,\n\tcreateEntry: (head: ClientUnhead, input: T) => ActiveHeadEntry<T>,\n): void => {\n\tconst entryRef = useRef<ActiveHeadEntry<T> | null>(null);\n\n\tuseEffect(() => {\n\t\tconst head = window.__yamfHead__;\n\n\t\tif (!head || input === undefined) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst entry = createEntry(head, input);\n\n\t\tentryRef.current = entry;\n\n\t\treturn () => {\n\t\t\tentry.dispose();\n\t\t\tentryRef.current = null;\n\t\t};\n\t\t// mount-only: the entry is created once per component instance\n\t\t// oxlint-disable-next-line eslint(exhaustive-deps)\n\t}, []);\n\n\tuseEffect(() => {\n\t\tif (input !== undefined) {\n\t\t\tentryRef.current?.patch(input);\n\t\t}\n\t}, [input]);\n};\n\nexport const useHead = (input?: YamfHead): void => {\n\tif (import.meta.env.SSR) {\n\t\tif (input === undefined) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst ctx = useSSRContext();\n\n\t\tif (ctx?.head) {\n\t\t\t// the custom `seo` key is yamf sugar for useSeoMeta — process it\n\t\t\t// instead of passing an unknown key down to unhead\n\t\t\tconst { seo, ...rest } = input;\n\n\t\t\tif (seo !== undefined) {\n\t\t\t\t_useSeoMeta(ctx.head, seo);\n\t\t\t}\n\n\t\t\t_useHead(ctx.head, rest);\n\t\t}\n\n\t\treturn;\n\t}\n\n\tuseHeadEntryLifecycle(input, (head, resolved) => _useHead(head, resolved));\n};\n\nexport const useSeoMeta = (input?: UseSeoMetaInput): void => {\n\tif (import.meta.env.SSR) {\n\t\tif (input === undefined) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst ctx = useSSRContext();\n\n\t\tif (ctx?.head) {\n\t\t\t_useSeoMeta(ctx.head, input);\n\t\t}\n\n\t\treturn;\n\t}\n\n\tuseHeadEntryLifecycle(input, (head, resolved) => _useSeoMeta(head, resolved));\n};\n"],"mappings":";;;;;AAoBA,IAAI,CAAC,YAAY,IAAI,KAAK;CACzB,MAAM,OAAO,WAAW;CAExB,MAAM,SAAS,UAA2B;EACzC,KAAK,MAAM,SAAS,OACnB,KAAK,KAAK,KAAuB;CAEnC;CAMA,MAAM,QAAQ,OAAO,YAAY;CAEjC,IAAI,OACH,KAAK,MAAM,SAAS,OACnB,MAAM,KAAK;CAKb,OAAO,aAAa,EAAE,MAAM,MAAM;CAElC,OAAO,eAAe;AACvB;;;;;;;AAQA,MAAM,yBACL,OACA,gBACU;CACV,MAAM,WAAW,OAAkC,IAAI;CAEvD,gBAAgB;EACf,MAAM,OAAO,OAAO;EAEpB,IAAI,CAAC,QAAQ,UAAU,KAAA,GACtB;EAGD,MAAM,QAAQ,YAAY,MAAM,KAAK;EAErC,SAAS,UAAU;EAEnB,aAAa;GACZ,MAAM,QAAQ;GACd,SAAS,UAAU;EACpB;CAGD,GAAG,CAAC,CAAC;CAEL,gBAAgB;EACf,IAAI,UAAU,KAAA,GACb,SAAS,SAAS,MAAM,KAAK;CAE/B,GAAG,CAAC,KAAK,CAAC;AACX;AAEA,MAAaA,aAAW,UAA2B;CAClD,IAAI,YAAY,IAAI,KAAK;EACxB,IAAI,UAAU,KAAA,GACb;EAGD,MAAM,MAAM,cAAc;EAE1B,IAAI,KAAK,MAAM;GAGd,MAAM,EAAE,KAAK,GAAG,SAAS;GAEzB,IAAI,QAAQ,KAAA,GACX,WAAY,IAAI,MAAM,GAAG;GAG1B,QAAS,IAAI,MAAM,IAAI;EACxB;EAEA;CACD;CAEA,sBAAsB,QAAQ,MAAM,aAAaC,QAAS,MAAM,QAAQ,CAAC;AAC1E;AAEA,MAAaC,gBAAc,UAAkC;CAC5D,IAAI,YAAY,IAAI,KAAK;EACxB,IAAI,UAAU,KAAA,GACb;EAGD,MAAM,MAAM,cAAc;EAE1B,IAAI,KAAK,MACR,WAAY,IAAI,MAAM,KAAK;EAG5B;CACD;CAEA,sBAAsB,QAAQ,MAAM,aAAaC,WAAY,MAAM,QAAQ,CAAC;AAC7E"}
package/dist/index.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import { ImportAssetsResult, ImportAssetsResultRaw } from "./shared/assets.js";
2
2
  import { YamfHead } from "./shared/head.js";
3
- import { PageHandler, definePage } from "./page.js";
3
+ import { PageCacheOptions, PageHandler, PageRenderer, definePage } from "./page.js";
4
+ import { SafeAsyncFallbackProps, safeAsync } from "./safeAsync.js";
4
5
  import { useSSRContext } from "./context/ssr.js";
5
6
  import { useEvent } from "./hooks/useEvent.js";
7
+ import { isPrerendering } from "./hooks/isPrerendering.js";
6
8
  import { useHead, useSeoMeta } from "./hooks/useHead.js";
7
9
  import { IslandClientDirective, IslandProps } from "./island/types.js";
8
10
  import { Head, HeadProps } from "./components/Head.js";
9
- export { Head, type HeadProps, type ImportAssetsResult, type ImportAssetsResultRaw, type IslandClientDirective, type IslandProps, type PageHandler, type YamfHead, definePage, useEvent, useHead, useSSRContext, useSeoMeta };
11
+ import { Unhead } from "unhead/server";
12
+ import { StandardSchemaV1 } from "@standard-schema/spec";
13
+ export { Head, type HeadProps, type ImportAssetsResult, type ImportAssetsResultRaw, type IslandClientDirective, type IslandProps, type PageCacheOptions, type PageHandler, type PageRenderer, type SafeAsyncFallbackProps, type StandardSchemaV1, type Unhead, type YamfHead, definePage, isPrerendering, safeAsync, useEvent, useHead, useSSRContext, useSeoMeta };
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { useSSRContext } from "./context/ssr.js";
2
2
  import { definePage } from "./page.js";
3
+ import { safeAsync } from "./safeAsync.js";
3
4
  import { useEvent } from "./hooks/useEvent.js";
5
+ import { isPrerendering } from "./hooks/isPrerendering.js";
4
6
  import { useHead, useSeoMeta } from "./hooks/useHead.js";
5
7
  import { Head } from "./components/Head.js";
6
- export { Head, definePage, useEvent, useHead, useSSRContext, useSeoMeta };
8
+ export { Head, definePage, isPrerendering, safeAsync, useEvent, useHead, useSSRContext, useSeoMeta };
@@ -18,44 +18,52 @@ const unobserve = (target) => {
18
18
  observer.unobserve(target);
19
19
  listeners.delete(target);
20
20
  };
21
- customElements.define("yamf-island", class extends HTMLElement {
22
- connectedCallback() {
23
- const islandProps = parse(this.getAttribute("island-props") ?? "{}");
24
- const islandSrc = this.getAttribute("island-src");
25
- const islandEntry = this.getAttribute("island-entry");
26
- const islandClient = this.getAttribute("island-client") ?? "load";
27
- if (!islandSrc) throw new Error("Missing island-src attribute");
28
- if (!islandEntry) throw new Error("Missing island-entry attribute");
29
- const hydrateIsland = (mod) => {
30
- const Comp = mod[islandEntry];
31
- if (!Comp) throw new Error(`Missing island entry ${islandEntry} in ${islandSrc}`);
32
- hydrateRoot(this, /* @__PURE__ */ jsx(Comp, { ...islandProps }));
33
- };
34
- const initIsland = () => {
35
- const src = withLeadingSlash(islandSrc);
36
- __island_raw_import__(src).then(hydrateIsland);
37
- };
38
- switch (islandClient) {
39
- case "true":
40
- case "load":
41
- initIsland();
42
- break;
43
- case "idle":
44
- requestIdleCallback(initIsland);
45
- break;
46
- case "visible":
47
- if (this.firstElementChild) observe(this.firstElementChild, initIsland);
48
- else initIsland();
49
- break;
50
- case "false":
51
- case "skip": break;
52
- default: throw new Error(`Invalid island-client value: ${islandClient}`);
53
- }
21
+ const requestIdle = (callback) => {
22
+ const ric = window.requestIdleCallback;
23
+ if (typeof ric === "function") {
24
+ ric(callback);
25
+ return;
54
26
  }
55
- disconnectedCallback() {
56
- unobserve(this);
27
+ setTimeout(callback, 1);
28
+ };
29
+ /**
30
+ * Hydrates a single <yamf-island> island. Called by the bootstrap custom
31
+ * element (src/client/index.ts) once this runtime module has been loaded.
32
+ */
33
+ const hydrateIsland = (island) => {
34
+ const islandProps = parse(island.getAttribute("island-props") ?? "{}");
35
+ const islandSrc = island.getAttribute("island-src");
36
+ const islandEntry = island.getAttribute("island-entry");
37
+ const islandClient = island.getAttribute("island-client") ?? "load";
38
+ if (!islandSrc) throw new Error("Missing island-src attribute");
39
+ if (!islandEntry) throw new Error("Missing island-entry attribute");
40
+ const hydrate = (mod) => {
41
+ const Comp = mod[islandEntry];
42
+ if (!Comp) throw new Error(`Missing island entry ${islandEntry} in ${islandSrc}`);
43
+ hydrateRoot(island, /* @__PURE__ */ jsx(Comp, { ...islandProps }));
44
+ };
45
+ const initIsland = () => {
46
+ const src = withLeadingSlash(islandSrc);
47
+ __yamf_raw_import__(src).then(hydrate);
48
+ };
49
+ switch (islandClient) {
50
+ case "true":
51
+ case "load":
52
+ initIsland();
53
+ break;
54
+ case "idle":
55
+ requestIdle(initIsland);
56
+ break;
57
+ case "visible":
58
+ if (island.firstElementChild) observe(island.firstElementChild, initIsland);
59
+ else initIsland();
60
+ break;
61
+ case "false":
62
+ case "skip": break;
63
+ default: throw new Error(`Invalid island-client value: ${islandClient}`);
57
64
  }
58
- });
65
+ };
59
66
  //#endregion
67
+ export { hydrateIsland };
60
68
 
61
69
  //# sourceMappingURL=client.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":[],"sources":["../../src/island/client.tsx"],"sourcesContent":["import { parse } from \"devalue\";\nimport type { FC } from \"hono/jsx\";\nimport { hydrateRoot } from \"hono/jsx/dom/client\";\nimport { withLeadingSlash } from \"ufo\";\nimport type { IslandClientDirectiveSerialized } from \"./types\";\n\ndeclare let __island_raw_import__: <T>(file: string) => Promise<T>;\n\nconst listeners = new WeakMap<Element, VoidFunction>();\n\nconst observer = new IntersectionObserver(entries => {\n\tfor (const entry of entries) {\n\t\tif (entry.isIntersecting) {\n\t\t\tlisteners.get(entry.target)?.();\n\t\t\tunobserve(entry.target);\n\t\t}\n\t}\n});\n\nconst observe = (target: Element, cb: VoidFunction) => {\n\tobserver.observe(target);\n\tlisteners.set(target, cb);\n};\n\nconst unobserve = (target: Element) => {\n\tobserver.unobserve(target);\n\tlisteners.delete(target);\n};\n\ncustomElements.define(\n\t\"yamf-island\",\n\tclass extends HTMLElement {\n\t\tpublic connectedCallback() {\n\t\t\tconst islandProps = parse(this.getAttribute(\"island-props\") ?? \"{}\");\n\t\t\tconst islandSrc = this.getAttribute(\"island-src\");\n\t\t\tconst islandEntry = this.getAttribute(\"island-entry\");\n\t\t\t// oxlint-disable-next-line typescript/no-unsafe-type-assertion\n\t\t\tconst islandClient = (this.getAttribute(\"island-client\") ??\n\t\t\t\t\"load\") as IslandClientDirectiveSerialized;\n\n\t\t\tif (!islandSrc) {\n\t\t\t\tthrow new Error(\"Missing island-src attribute\");\n\t\t\t}\n\n\t\t\tif (!islandEntry) {\n\t\t\t\tthrow new Error(\"Missing island-entry attribute\");\n\t\t\t}\n\n\t\t\tconst hydrateIsland = (mod: Record<string, FC>) => {\n\t\t\t\tconst Comp = mod[islandEntry];\n\n\t\t\t\tif (!Comp) {\n\t\t\t\t\tthrow new Error(`Missing island entry ${islandEntry} in ${islandSrc}`);\n\t\t\t\t}\n\n\t\t\t\thydrateRoot(this, <Comp {...islandProps} />);\n\t\t\t};\n\n\t\t\tconst initIsland = () => {\n\t\t\t\tconst src = withLeadingSlash(islandSrc);\n\t\t\t\tvoid __island_raw_import__<Record<string, FC>>(src).then(hydrateIsland);\n\t\t\t};\n\n\t\t\tswitch (islandClient) {\n\t\t\t\tcase \"true\":\n\t\t\t\tcase \"load\":\n\t\t\t\t\tinitIsland();\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"idle\":\n\t\t\t\t\trequestIdleCallback(initIsland);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"visible\":\n\t\t\t\t\t// yamf-island has `display: contents` which breaks IntersectionObserver\n\t\t\t\t\t// so we have to observe the first child instead if it exists\n\t\t\t\t\tif (this.firstElementChild) {\n\t\t\t\t\t\tobserve(this.firstElementChild, initIsland);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tinitIsland();\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"false\":\n\t\t\t\tcase \"skip\":\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tislandClient satisfies never;\n\t\t\t\t\t// oxlint-disable-next-line typescript/restrict-template-expressions\n\t\t\t\t\tthrow new Error(`Invalid island-client value: ${islandClient}`);\n\t\t\t}\n\t\t}\n\n\t\tpublic disconnectedCallback() {\n\t\t\tunobserve(this);\n\t\t}\n\t},\n);\n"],"mappings":";;;;;AAQA,MAAM,4BAAY,IAAI,QAA+B;AAErD,MAAM,WAAW,IAAI,sBAAqB,YAAW;CACpD,KAAK,MAAM,SAAS,SACnB,IAAI,MAAM,gBAAgB;EACzB,UAAU,IAAI,MAAM,MAAM,CAAC,GAAG;EAC9B,UAAU,MAAM,MAAM;CACvB;AAEF,CAAC;AAED,MAAM,WAAW,QAAiB,OAAqB;CACtD,SAAS,QAAQ,MAAM;CACvB,UAAU,IAAI,QAAQ,EAAE;AACzB;AAEA,MAAM,aAAa,WAAoB;CACtC,SAAS,UAAU,MAAM;CACzB,UAAU,OAAO,MAAM;AACxB;AAEA,eAAe,OACd,eACA,cAAc,YAAY;CACzB,oBAA2B;EAC1B,MAAM,cAAc,MAAM,KAAK,aAAa,cAAc,KAAK,IAAI;EACnE,MAAM,YAAY,KAAK,aAAa,YAAY;EAChD,MAAM,cAAc,KAAK,aAAa,cAAc;EAEpD,MAAM,eAAgB,KAAK,aAAa,eAAe,KACtD;EAED,IAAI,CAAC,WACJ,MAAM,IAAI,MAAM,8BAA8B;EAG/C,IAAI,CAAC,aACJ,MAAM,IAAI,MAAM,gCAAgC;EAGjD,MAAM,iBAAiB,QAA4B;GAClD,MAAM,OAAO,IAAI;GAEjB,IAAI,CAAC,MACJ,MAAM,IAAI,MAAM,wBAAwB,YAAY,MAAM,WAAW;GAGtE,YAAY,MAAM,oBAAC,MAAD,EAAM,GAAI,YAAc,CAAA,CAAC;EAC5C;EAEA,MAAM,mBAAmB;GACxB,MAAM,MAAM,iBAAiB,SAAS;GACtC,sBAA+C,GAAG,CAAC,CAAC,KAAK,aAAa;EACvE;EAEA,QAAQ,cAAR;GACC,KAAK;GACL,KAAK;IACJ,WAAW;IACX;GACD,KAAK;IACJ,oBAAoB,UAAU;IAC9B;GACD,KAAK;IAGJ,IAAI,KAAK,mBACR,QAAQ,KAAK,mBAAmB,UAAU;SAE1C,WAAW;IAEZ;GACD,KAAK;GACL,KAAK,QACJ;GACD,SAGC,MAAM,IAAI,MAAM,gCAAgC,cAAc;EAChE;CACD;CAEA,uBAA8B;EAC7B,UAAU,IAAI;CACf;AACD,CACD"}
1
+ {"version":3,"file":"client.js","names":[],"sources":["../../src/island/client.tsx"],"sourcesContent":["import { parse } from \"devalue\";\nimport type { FC } from \"hono/jsx\";\nimport { hydrateRoot } from \"hono/jsx/dom/client\";\nimport { withLeadingSlash } from \"ufo\";\nimport type { IslandClientDirectiveSerialized } from \"./types\";\n\ndeclare let __yamf_raw_import__: <T>(file: string) => Promise<T>;\n\nconst listeners: WeakMap<Element, VoidFunction> = new WeakMap();\n\nconst observer: IntersectionObserver = new IntersectionObserver(entries => {\n\tfor (const entry of entries) {\n\t\tif (entry.isIntersecting) {\n\t\t\tlisteners.get(entry.target)?.();\n\t\t\tunobserve(entry.target);\n\t\t}\n\t}\n});\n\nconst observe = (target: Element, cb: VoidFunction) => {\n\tobserver.observe(target);\n\tlisteners.set(target, cb);\n};\n\nconst unobserve = (target: Element) => {\n\tobserver.unobserve(target);\n\tlisteners.delete(target);\n};\n\n// Safari does not enable requestIdleCallback by default in any stable\n// release — without a fallback, client:idle islands never hydrate there\nconst requestIdle = (callback: () => void): void => {\n\tconst ric = window.requestIdleCallback;\n\n\tif (typeof ric === \"function\") {\n\t\tric(callback);\n\t\treturn;\n\t}\n\n\tsetTimeout(callback, 1);\n};\n\n/**\n * Hydrates a single <yamf-island> island. Called by the bootstrap custom\n * element (src/client/index.ts) once this runtime module has been loaded.\n */\nexport const hydrateIsland = (island: HTMLElement): void => {\n\tconst islandProps = parse(island.getAttribute(\"island-props\") ?? \"{}\");\n\tconst islandSrc = island.getAttribute(\"island-src\");\n\tconst islandEntry = island.getAttribute(\"island-entry\");\n\t// oxlint-disable-next-line typescript/no-unsafe-type-assertion\n\tconst islandClient = (island.getAttribute(\"island-client\") ??\n\t\t\"load\") as IslandClientDirectiveSerialized;\n\n\tif (!islandSrc) {\n\t\tthrow new Error(\"Missing island-src attribute\");\n\t}\n\n\tif (!islandEntry) {\n\t\tthrow new Error(\"Missing island-entry attribute\");\n\t}\n\n\tconst hydrate = (mod: Record<string, FC>) => {\n\t\tconst Comp = mod[islandEntry];\n\n\t\tif (!Comp) {\n\t\t\tthrow new Error(`Missing island entry ${islandEntry} in ${islandSrc}`);\n\t\t}\n\n\t\thydrateRoot(island, <Comp {...islandProps} />);\n\t};\n\n\tconst initIsland = () => {\n\t\tconst src = withLeadingSlash(islandSrc);\n\t\tvoid __yamf_raw_import__<Record<string, FC>>(src).then(hydrate);\n\t};\n\n\tswitch (islandClient) {\n\t\tcase \"true\":\n\t\tcase \"load\":\n\t\t\tinitIsland();\n\t\t\tbreak;\n\t\tcase \"idle\":\n\t\t\trequestIdle(initIsland);\n\t\t\tbreak;\n\t\tcase \"visible\":\n\t\t\t// yamf-island has `display: contents` which breaks IntersectionObserver\n\t\t\t// so we have to observe the first child instead if it exists\n\t\t\tif (island.firstElementChild) {\n\t\t\t\tobserve(island.firstElementChild, initIsland);\n\t\t\t} else {\n\t\t\t\tinitIsland();\n\t\t\t}\n\t\t\tbreak;\n\t\tcase \"false\":\n\t\tcase \"skip\":\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tislandClient satisfies never;\n\t\t\t// oxlint-disable-next-line typescript/restrict-template-expressions\n\t\t\tthrow new Error(`Invalid island-client value: ${islandClient}`);\n\t}\n};\n"],"mappings":";;;;;AAQA,MAAM,4BAA4C,IAAI,QAAQ;AAE9D,MAAM,WAAiC,IAAI,sBAAqB,YAAW;CAC1E,KAAK,MAAM,SAAS,SACnB,IAAI,MAAM,gBAAgB;EACzB,UAAU,IAAI,MAAM,MAAM,CAAC,GAAG;EAC9B,UAAU,MAAM,MAAM;CACvB;AAEF,CAAC;AAED,MAAM,WAAW,QAAiB,OAAqB;CACtD,SAAS,QAAQ,MAAM;CACvB,UAAU,IAAI,QAAQ,EAAE;AACzB;AAEA,MAAM,aAAa,WAAoB;CACtC,SAAS,UAAU,MAAM;CACzB,UAAU,OAAO,MAAM;AACxB;AAIA,MAAM,eAAe,aAA+B;CACnD,MAAM,MAAM,OAAO;CAEnB,IAAI,OAAO,QAAQ,YAAY;EAC9B,IAAI,QAAQ;EACZ;CACD;CAEA,WAAW,UAAU,CAAC;AACvB;;;;;AAMA,MAAa,iBAAiB,WAA8B;CAC3D,MAAM,cAAc,MAAM,OAAO,aAAa,cAAc,KAAK,IAAI;CACrE,MAAM,YAAY,OAAO,aAAa,YAAY;CAClD,MAAM,cAAc,OAAO,aAAa,cAAc;CAEtD,MAAM,eAAgB,OAAO,aAAa,eAAe,KACxD;CAED,IAAI,CAAC,WACJ,MAAM,IAAI,MAAM,8BAA8B;CAG/C,IAAI,CAAC,aACJ,MAAM,IAAI,MAAM,gCAAgC;CAGjD,MAAM,WAAW,QAA4B;EAC5C,MAAM,OAAO,IAAI;EAEjB,IAAI,CAAC,MACJ,MAAM,IAAI,MAAM,wBAAwB,YAAY,MAAM,WAAW;EAGtE,YAAY,QAAQ,oBAAC,MAAD,EAAM,GAAI,YAAc,CAAA,CAAC;CAC9C;CAEA,MAAM,mBAAmB;EACxB,MAAM,MAAM,iBAAiB,SAAS;EACtC,oBAA6C,GAAG,CAAC,CAAC,KAAK,OAAO;CAC/D;CAEA,QAAQ,cAAR;EACC,KAAK;EACL,KAAK;GACJ,WAAW;GACX;EACD,KAAK;GACJ,YAAY,UAAU;GACtB;EACD,KAAK;GAGJ,IAAI,OAAO,mBACV,QAAQ,OAAO,mBAAmB,UAAU;QAE5C,WAAW;GAEZ;EACD,KAAK;EACL,KAAK,QACJ;EACD,SAGC,MAAM,IAAI,MAAM,gCAAgC,cAAc;CAChE;AACD"}
package/dist/page.d.ts CHANGED
@@ -3,20 +3,47 @@ import { YamfHead } from "./shared/head.js";
3
3
  import { Child } from "hono/jsx";
4
4
  import { EventHandlerResponse, H3Event, HTTPResponse } from "nitro/h3";
5
5
  import { Unhead } from "unhead/server";
6
+ import { StandardSchemaV1 } from "@standard-schema/spec";
6
7
  import { UseSeoMetaInput } from "unhead/types";
7
8
  //#region src/page.d.ts
8
9
  export type PageHandler = (event: H3Event, params: {
9
10
  assets: ImportAssetsResult;
10
11
  head?: YamfHead;
12
+ nonce?: string;
11
13
  }) => EventHandlerResponse;
12
- export type PageRenderer = (event: H3Event, params: {
14
+ export type PageRenderer<P = unknown, Q = unknown> = (event: H3Event, params: {
13
15
  head: Unhead;
14
16
  seoHead: (input: UseSeoMetaInput) => void;
17
+ params: P;
18
+ query: Q;
15
19
  }) => HTTPResponse | Child | Promise<Child | HTTPResponse>;
16
- interface DefinePageOptions {
17
- render: PageRenderer;
20
+ export interface PageCacheOptions {
21
+ maxAge: number;
22
+ swr?: number;
23
+ private?: boolean;
24
+ }
25
+ interface DefinePageOptions<P = unknown, Q = unknown> {
26
+ render: PageRenderer<P, Q>;
18
27
  stream?: boolean;
28
+ /**
29
+ * Cache-Control for the page response. `60` is shorthand for
30
+ * `{ maxAge: 60 }` → `Cache-Control: public, max-age=60`; the object form
31
+ * adds `stale-while-revalidate` and `private`.
32
+ */
33
+ cache?: number | PageCacheOptions;
34
+ /**
35
+ * Standard-schema validator (zod, valibot, arktype, …) for the route
36
+ * params. On failure the page throws HTTPError 404 before render; the
37
+ * validated, typed output is passed to render as `params`.
38
+ */
39
+ params?: StandardSchemaV1<unknown, P>;
40
+ /**
41
+ * Standard-schema validator for the search params. On failure the page
42
+ * throws HTTPError 400 before render; the validated, typed output is
43
+ * passed to render as `query`. Repeated keys collapse to the last value.
44
+ */
45
+ query?: StandardSchemaV1<unknown, Q>;
19
46
  }
20
- export declare const definePage: (options: DefinePageOptions) => PageHandler;
47
+ export declare const definePage: <P = unknown, Q = unknown>(options: DefinePageOptions<P, Q>) => PageHandler;
21
48
  //#endregion
22
49
  //# sourceMappingURL=page.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"page.d.ts","names":[],"sources":["../src/page.tsx"],"mappings":";;;;;;;YAiBY,eACX,OAAO,SACP;EACC,QAAQ;EACR,OAAO;MAEJ;YAEO,gBACX,OAAO,SACP;EACC,MAAM;EACN,UAAU,OAAO;MAEd,eAAe,QAAQ,QAAQ,QAAQ;UAElC;EACT,QAAQ;EACR;;qBAGY,aAAc,SAAS,sBAAoB"}
1
+ {"version":3,"file":"page.d.ts","names":[],"sources":["../src/page.tsx"],"mappings":";;;;;;;;YAkBY,eACX,OAAO,SACP;EACC,QAAQ;EACR,OAAO;EACP;MAEG;YAEO,aAAa,aAAa,gBACrC,OAAO,SACP;EACC,MAAM;EACN,UAAU,OAAO;EACjB,QAAQ;EACR,OAAO;MAEJ,eAAe,QAAQ,QAAQ,QAAQ;iBAE3B;EAChB;EACA;EACA;;UAGS,kBAAkB,aAAa;EACxC,QAAQ,aAAa,GAAG;EACxB;;;;;;EAMA,iBAAiB;;;;;;EAMjB,SAAS,0BAA0B;;;;;;EAMnC,QAAQ,0BAA0B;;qBA4FtB,aAAc,aAAa,aACvC,SAAS,kBAAkB,GAAG,OAC5B"}
package/dist/page.js CHANGED
@@ -2,17 +2,58 @@ import { SSRContext } from "./context/ssr.js";
2
2
  import { Fragment } from "hono/jsx";
3
3
  import { renderToReadableStream } from "hono/jsx/streaming";
4
4
  import { Hono } from "hono/tiny";
5
- import { HTTPResponse, withServerTiming } from "nitro/h3";
5
+ import { HTTPError, HTTPResponse, withServerTiming } from "nitro/h3";
6
6
  import { useSeoMeta } from "unhead";
7
7
  import { transformHtmlTemplate } from "unhead/server";
8
- import { createStreamableHead, wrapStream } from "unhead/stream/server";
8
+ import { createStreamableHead, renderSSRHeadSuspenseChunk, wrapStream } from "unhead/stream/server";
9
9
  import { Root } from "virtual:yamf:root";
10
10
  import { template } from "virtual:yamf:template";
11
11
  import { Router } from "wouter";
12
12
  import { jsx } from "hono/jsx/jsx-runtime";
13
13
  //#region src/page.tsx
14
+ const cacheControlValue = (cache) => {
15
+ const options = typeof cache === "number" ? { maxAge: cache } : cache;
16
+ const parts = [options.private ? "private" : "public", `max-age=${options.maxAge}`];
17
+ if (options.swr !== void 0) parts.push(`stale-while-revalidate=${options.swr}`);
18
+ return parts.join(", ");
19
+ };
20
+ const unwrapEntryInput = (input) => {
21
+ return typeof input === "function" ? input() : input;
22
+ };
23
+ const collectHeadInputs = (head) => {
24
+ const inputs = [];
25
+ for (const entry of head.entries.values()) inputs.push(unwrapEntryInput(entry.input));
26
+ return inputs;
27
+ };
28
+ const serializeHeadPayload = (inputs) => {
29
+ return JSON.stringify(inputs).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
30
+ };
31
+ const scriptNonceAttr = (nonce) => {
32
+ return nonce ? ` nonce="${nonce.replace(/"/g, "&quot;")}"` : "";
33
+ };
34
+ const headPayloadScript = (inputs, nonce) => {
35
+ if (!inputs.length) return "";
36
+ return `<script${scriptNonceAttr(nonce)}>window.__unhead__&&window.__unhead__.push(${serializeHeadPayload(inputs)})<\/script>`;
37
+ };
38
+ const injectHeadPayload = (template, script) => {
39
+ if (!script) return template;
40
+ if (/<body[^>]*>/.test(template)) return template.replace(/<body[^>]*>/, (match) => `${match}${script}`);
41
+ return template.replace("</head>", `</head>${script}`);
42
+ };
43
+ const validateWithSchema = async (schema, value, status, what) => {
44
+ if (!schema) return value;
45
+ const result = await schema["~standard"].validate(value);
46
+ if (result.issues) throw new HTTPError(`Invalid ${what}`, {
47
+ status,
48
+ cause: new Error(result.issues.map((issue) => issue.message).join("; "))
49
+ });
50
+ return result.value;
51
+ };
14
52
  const definePage = (options) => {
15
- return async (event, { assets, head: headInit }) => {
53
+ return async (event, { assets, head: headInit, nonce }) => {
54
+ if (options.cache !== void 0) event.res.headers.set("cache-control", cacheControlValue(options.cache));
55
+ const params = await validateWithSchema(options.params, event.context.params, 404, "path params");
56
+ const query = await validateWithSchema(options.query, Object.fromEntries(event.url.searchParams), 400, "query params");
16
57
  const { head } = createStreamableHead({ init: [headInit] });
17
58
  const seoHead = (input) => useSeoMeta(head, input);
18
59
  seoHead(headInit?.seo);
@@ -31,9 +72,13 @@ const definePage = (options) => {
31
72
  });
32
73
  const content = await options.render(event, {
33
74
  head,
34
- seoHead
75
+ seoHead,
76
+ params,
77
+ query
35
78
  });
36
79
  if (content instanceof HTTPResponse) return content;
80
+ const payloadScript = headPayloadScript(collectHeadInputs(head), nonce);
81
+ const templateWithPayload = injectHeadPayload(template, payloadScript);
37
82
  const Root$1 = Root ?? Fragment;
38
83
  const App = async () => /* @__PURE__ */ jsx(SSRContext, {
39
84
  value: {
@@ -49,10 +94,19 @@ const definePage = (options) => {
49
94
  const responseInit = { headers: { "Content-Type": "text/html; charset=utf-8" } };
50
95
  if (!options.stream) return new Hono().get("/", async (c) => withServerTiming(event, "#render", async () => {
51
96
  let html = await (await c.html(/* @__PURE__ */ jsx(App, {}))).text();
52
- html = transformHtmlTemplate(head, template.replace("<!--ssr-outlet-->", html ?? ""));
97
+ html = transformHtmlTemplate(head, templateWithPayload.replace("<!--ssr-outlet-->", html ?? ""));
53
98
  return new Response(html, responseInit);
54
99
  })).request("/");
55
- const stream = wrapStream(head, renderToReadableStream(/* @__PURE__ */ jsx(App, {})), template);
100
+ const stream = wrapStream(head, renderToReadableStream(/* @__PURE__ */ jsx(App, {})), templateWithPayload, void 0, { flushChunk: () => {
101
+ let chunk;
102
+ try {
103
+ chunk = renderSSRHeadSuspenseChunk(head);
104
+ } catch {
105
+ return "";
106
+ }
107
+ if (!chunk) return "";
108
+ return `<script${scriptNonceAttr(nonce)}>window.__unhead__&&(${chunk});document.currentScript.remove()<\/script>`;
109
+ } });
56
110
  return new Response(stream, responseInit);
57
111
  };
58
112
  };