@pajecawav/yamf 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -7
- package/dist/client/index.js +39 -1
- package/dist/client/index.js.map +1 -0
- package/dist/components/Head.d.ts +2 -2
- package/dist/components/Head.js.map +1 -1
- package/dist/hooks/isPrerendering.d.ts +12 -0
- package/dist/hooks/isPrerendering.d.ts.map +1 -0
- package/dist/hooks/isPrerendering.js +17 -0
- package/dist/hooks/isPrerendering.js.map +1 -0
- package/dist/hooks/useHead.d.ts +9 -3
- package/dist/hooks/useHead.d.ts.map +1 -1
- package/dist/hooks/useHead.js +46 -4
- package/dist/hooks/useHead.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/island/client.js +45 -36
- package/dist/island/client.js.map +1 -1
- package/dist/page.d.ts +31 -4
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +60 -6
- package/dist/page.js.map +1 -1
- package/dist/safeAsync.d.ts +19 -0
- package/dist/safeAsync.d.ts.map +1 -0
- package/dist/safeAsync.js +34 -0
- package/dist/safeAsync.js.map +1 -0
- package/dist/server/entry.d.mts +9 -0
- package/dist/server/entry.d.mts.map +1 -1
- package/dist/server/entry.mjs +7 -2
- package/dist/server/entry.mjs.map +1 -1
- package/dist/server/island/server.d.mts.map +1 -1
- package/dist/server/island/server.mjs +11 -1
- package/dist/server/island/server.mjs.map +1 -1
- package/dist/vite/index.d.mts.map +1 -1
- package/dist/vite/index.mjs +12 -4
- package/dist/vite/index.mjs.map +1 -1
- package/dist/vite/islands.mjs +34 -5
- package/dist/vite/islands.mjs.map +1 -1
- package/dist/vite/prerender.mjs +16 -0
- package/dist/vite/prerender.mjs.map +1 -0
- package/dist/vite/virtual-root.mjs +11 -4
- package/dist/vite/virtual-root.mjs.map +1 -1
- package/dist/vite/virtual-template.mjs +8 -3
- package/dist/vite/virtual-template.mjs.map +1 -1
- package/package.json +23 -16
- package/src/client/index.ts +51 -1
- package/src/components/Head.tsx +2 -2
- package/src/hooks/isPrerendering.ts +13 -0
- package/src/hooks/useHead.tsx +98 -9
- package/src/index.ts +10 -1
- package/src/island/client.tsx +77 -65
- package/src/island/server.tsx +24 -1
- package/src/page.tsx +176 -11
- package/src/safeAsync.tsx +46 -0
- package/src/server/entry.tsx +25 -1
- package/src/vite/index.ts +30 -3
- package/src/vite/islands.ts +59 -5
- package/src/vite/prerender.ts +27 -0
- package/src/vite/virtual-root.ts +14 -4
- 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,
|
|
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.
|
|
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 `
|
|
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
|
|
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
|
|
package/dist/client/index.js
CHANGED
|
@@ -1 +1,39 @@
|
|
|
1
|
-
|
|
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 {
|
|
1
|
+
import { YamfHead } from "../shared/head.js";
|
|
2
2
|
//#region src/components/Head.d.ts
|
|
3
|
-
export type HeadProps =
|
|
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
|
|
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"}
|
package/dist/hooks/useHead.d.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import { YamfHead } from "../shared/head.js";
|
|
1
2
|
import { ClientUnhead } from "unhead/client";
|
|
2
|
-
import {
|
|
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
|
-
|
|
11
|
+
__unhead__?: StreamQueue;
|
|
12
|
+
__yamfHead__?: ClientUnhead;
|
|
7
13
|
}
|
|
8
14
|
}
|
|
9
|
-
export declare const useHead: (input?:
|
|
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":"
|
|
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"}
|
package/dist/hooks/useHead.js
CHANGED
|
@@ -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)
|
|
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)
|
|
10
|
-
|
|
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
|
-
|
|
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\
|
|
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
|
-
|
|
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 };
|
package/dist/island/client.js
CHANGED
|
@@ -18,44 +18,53 @@ const unobserve = (target) => {
|
|
|
18
18
|
observer.unobserve(target);
|
|
19
19
|
listeners.delete(target);
|
|
20
20
|
};
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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 rawProps = island.getAttribute("island-props");
|
|
35
|
+
const islandProps = rawProps === null ? [] : parse(rawProps);
|
|
36
|
+
const islandSrc = island.getAttribute("island-src");
|
|
37
|
+
const islandEntry = island.getAttribute("island-entry");
|
|
38
|
+
const islandClient = island.getAttribute("island-client") ?? "load";
|
|
39
|
+
if (!islandSrc) throw new Error("Missing island-src attribute");
|
|
40
|
+
if (!islandEntry) throw new Error("Missing island-entry attribute");
|
|
41
|
+
const hydrate = (mod) => {
|
|
42
|
+
const Comp = mod[islandEntry];
|
|
43
|
+
if (!Comp) throw new Error(`Missing island entry ${islandEntry} in ${islandSrc}`);
|
|
44
|
+
hydrateRoot(island, /* @__PURE__ */ jsx(Comp, { ...islandProps }));
|
|
45
|
+
};
|
|
46
|
+
const initIsland = () => {
|
|
47
|
+
const src = withLeadingSlash(islandSrc);
|
|
48
|
+
__yamf_raw_import__(src).then(hydrate);
|
|
49
|
+
};
|
|
50
|
+
switch (islandClient) {
|
|
51
|
+
case "true":
|
|
52
|
+
case "load":
|
|
53
|
+
initIsland();
|
|
54
|
+
break;
|
|
55
|
+
case "idle":
|
|
56
|
+
requestIdle(initIsland);
|
|
57
|
+
break;
|
|
58
|
+
case "visible":
|
|
59
|
+
if (island.firstElementChild) observe(island.firstElementChild, initIsland);
|
|
60
|
+
else initIsland();
|
|
61
|
+
break;
|
|
62
|
+
case "false":
|
|
63
|
+
case "skip": break;
|
|
64
|
+
default: throw new Error(`Invalid island-client value: ${islandClient}`);
|
|
57
65
|
}
|
|
58
|
-
}
|
|
66
|
+
};
|
|
59
67
|
//#endregion
|
|
68
|
+
export { hydrateIsland };
|
|
60
69
|
|
|
61
70
|
//# 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
|
|
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\t// skip islands omit the attribute entirely — parse after (and only if) it\n\t// exists, otherwise parse(\"{}\") would throw \"Invalid input\" and abort the\n\t// whole pending-hydration loop\n\tconst rawProps = island.getAttribute(\"island-props\");\n\tconst islandProps = rawProps === null ? [] : parse(rawProps);\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;CAI3D,MAAM,WAAW,OAAO,aAAa,cAAc;CACnD,MAAM,cAAc,aAAa,OAAO,CAAC,IAAI,MAAM,QAAQ;CAC3D,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
|
|
17
|
-
|
|
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
|
package/dist/page.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page.d.ts","names":[],"sources":["../src/page.tsx"],"mappings":"
|
|
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, """)}"` : "";
|
|
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,
|
|
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, {})),
|
|
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
|
};
|