@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.
- 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 +44 -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 +18 -11
- 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 +73 -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/src/page.tsx
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
1
2
|
import { Fragment, type Child } from "hono/jsx";
|
|
2
3
|
import { renderToReadableStream } from "hono/jsx/streaming";
|
|
3
4
|
import { Hono } from "hono/tiny";
|
|
4
5
|
import type { EventHandlerResponse, H3Event } from "nitro/h3";
|
|
5
|
-
import { HTTPResponse, withServerTiming } from "nitro/h3";
|
|
6
|
+
import { HTTPError, HTTPResponse, withServerTiming } from "nitro/h3";
|
|
6
7
|
import { useSeoMeta } from "unhead";
|
|
7
8
|
import type { Unhead } from "unhead/server";
|
|
8
9
|
import { transformHtmlTemplate } from "unhead/server";
|
|
9
|
-
import { createStreamableHead, wrapStream } from "unhead/stream/server";
|
|
10
|
-
import type { ResolvableLink, UseSeoMetaInput } from "unhead/types";
|
|
10
|
+
import { createStreamableHead, renderSSRHeadSuspenseChunk, wrapStream } from "unhead/stream/server";
|
|
11
|
+
import type { ResolvableHead, ResolvableLink, UseSeoMetaInput } from "unhead/types";
|
|
11
12
|
import { Root as RootComponent } from "virtual:yamf:root";
|
|
12
13
|
import { template } from "virtual:yamf:template";
|
|
13
14
|
import { Router } from "wouter";
|
|
@@ -20,24 +21,160 @@ export type PageHandler = (
|
|
|
20
21
|
params: {
|
|
21
22
|
assets: ImportAssetsResult;
|
|
22
23
|
head?: YamfHead;
|
|
24
|
+
nonce?: string;
|
|
23
25
|
},
|
|
24
26
|
) => EventHandlerResponse;
|
|
25
27
|
|
|
26
|
-
export type PageRenderer = (
|
|
28
|
+
export type PageRenderer<P = unknown, Q = unknown> = (
|
|
27
29
|
event: H3Event,
|
|
28
30
|
params: {
|
|
29
31
|
head: Unhead;
|
|
30
32
|
seoHead: (input: UseSeoMetaInput) => void;
|
|
33
|
+
params: P;
|
|
34
|
+
query: Q;
|
|
31
35
|
},
|
|
32
36
|
) => HTTPResponse | Child | Promise<Child | HTTPResponse>;
|
|
33
37
|
|
|
34
|
-
interface
|
|
35
|
-
|
|
38
|
+
export interface PageCacheOptions {
|
|
39
|
+
maxAge: number;
|
|
40
|
+
swr?: number;
|
|
41
|
+
private?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface DefinePageOptions<P = unknown, Q = unknown> {
|
|
45
|
+
render: PageRenderer<P, Q>;
|
|
36
46
|
stream?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Cache-Control for the page response. `60` is shorthand for
|
|
49
|
+
* `{ maxAge: 60 }` → `Cache-Control: public, max-age=60`; the object form
|
|
50
|
+
* adds `stale-while-revalidate` and `private`.
|
|
51
|
+
*/
|
|
52
|
+
cache?: number | PageCacheOptions;
|
|
53
|
+
/**
|
|
54
|
+
* Standard-schema validator (zod, valibot, arktype, …) for the route
|
|
55
|
+
* params. On failure the page throws HTTPError 404 before render; the
|
|
56
|
+
* validated, typed output is passed to render as `params`.
|
|
57
|
+
*/
|
|
58
|
+
params?: StandardSchemaV1<unknown, P>;
|
|
59
|
+
/**
|
|
60
|
+
* Standard-schema validator for the search params. On failure the page
|
|
61
|
+
* throws HTTPError 400 before render; the validated, typed output is
|
|
62
|
+
* passed to render as `query`. Repeated keys collapse to the last value.
|
|
63
|
+
*/
|
|
64
|
+
query?: StandardSchemaV1<unknown, Q>;
|
|
37
65
|
}
|
|
38
66
|
|
|
39
|
-
|
|
40
|
-
|
|
67
|
+
const cacheControlValue = (cache: number | PageCacheOptions): string => {
|
|
68
|
+
const options = typeof cache === "number" ? { maxAge: cache } : cache;
|
|
69
|
+
|
|
70
|
+
const parts = [options.private ? "private" : "public", `max-age=${options.maxAge}`];
|
|
71
|
+
|
|
72
|
+
if (options.swr !== undefined) {
|
|
73
|
+
parts.push(`stale-while-revalidate=${options.swr}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return parts.join(", ");
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// resolvable head inputs may be functions — resolve them for serialization
|
|
80
|
+
const unwrapEntryInput = (input: unknown): ResolvableHead => {
|
|
81
|
+
return (
|
|
82
|
+
typeof input === "function" ? (input as () => ResolvableHead)() : input
|
|
83
|
+
) as ResolvableHead;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const collectHeadInputs = (head: Unhead): ResolvableHead[] => {
|
|
87
|
+
const inputs: ResolvableHead[] = [];
|
|
88
|
+
|
|
89
|
+
for (const entry of head.entries.values()) {
|
|
90
|
+
inputs.push(unwrapEntryInput(entry.input));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return inputs;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const serializeHeadPayload = (inputs: ResolvableHead[]): string => {
|
|
97
|
+
return JSON.stringify(inputs)
|
|
98
|
+
.replace(/</g, "\\u003c")
|
|
99
|
+
.replace(/>/g, "\\u003e")
|
|
100
|
+
.replace(/&/g, "\\u0026");
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const scriptNonceAttr = (nonce?: string): string => {
|
|
104
|
+
return nonce ? ` nonce="${nonce.replace(/"/g, """)}"` : "";
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// client handshake: re-register the server head entries (titleTemplate,
|
|
108
|
+
// htmlAttrs, page-level head) into the client head via the unhead stream
|
|
109
|
+
// queue, so post-hydration updates keep templates and adopt SSR tags
|
|
110
|
+
// instead of duplicating them
|
|
111
|
+
const headPayloadScript = (inputs: ResolvableHead[], nonce?: string): string => {
|
|
112
|
+
if (!inputs.length) {
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return `<script${scriptNonceAttr(nonce)}>window.__unhead__&&window.__unhead__.push(${serializeHeadPayload(inputs)})</script>`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// injected at the start of <body>: unhead rebuilds the html/head tags and
|
|
120
|
+
// appends its head tags (including the stream bootstrap shim) inside <head>,
|
|
121
|
+
// so the payload must come after them for window.__unhead__ to exist
|
|
122
|
+
const injectHeadPayload = (template: string, script: string): string => {
|
|
123
|
+
if (!script) {
|
|
124
|
+
return template;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (/<body[^>]*>/.test(template)) {
|
|
128
|
+
return template.replace(/<body[^>]*>/, match => `${match}${script}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return template.replace("</head>", `</head>${script}`);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const validateWithSchema = async <T,>(
|
|
135
|
+
schema: StandardSchemaV1<unknown, T> | undefined,
|
|
136
|
+
value: unknown,
|
|
137
|
+
status: 400 | 404,
|
|
138
|
+
what: string,
|
|
139
|
+
): Promise<T> => {
|
|
140
|
+
if (!schema) {
|
|
141
|
+
return value as T;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const result = await schema["~standard"].validate(value);
|
|
145
|
+
|
|
146
|
+
if (result.issues) {
|
|
147
|
+
throw new HTTPError(`Invalid ${what}`, {
|
|
148
|
+
status,
|
|
149
|
+
cause: new Error(result.issues.map(issue => issue.message).join("; ")),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return result.value;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const definePage = <P = unknown, Q = unknown>(
|
|
157
|
+
options: DefinePageOptions<P, Q>,
|
|
158
|
+
): PageHandler => {
|
|
159
|
+
return async (event, { assets, head: headInit, nonce }) => {
|
|
160
|
+
if (options.cache !== undefined) {
|
|
161
|
+
event.res.headers.set("cache-control", cacheControlValue(options.cache));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const params = await validateWithSchema(
|
|
165
|
+
options.params,
|
|
166
|
+
event.context.params,
|
|
167
|
+
404,
|
|
168
|
+
"path params",
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const query = await validateWithSchema(
|
|
172
|
+
options.query,
|
|
173
|
+
Object.fromEntries(event.url.searchParams),
|
|
174
|
+
400,
|
|
175
|
+
"query params",
|
|
176
|
+
);
|
|
177
|
+
|
|
41
178
|
const { head } = createStreamableHead({ init: [headInit] });
|
|
42
179
|
const seoHead = (input?: UseSeoMetaInput) => useSeoMeta(head, input);
|
|
43
180
|
seoHead(headInit?.seo);
|
|
@@ -50,12 +187,17 @@ export const definePage = (options: DefinePageOptions): PageHandler => {
|
|
|
50
187
|
script: [{ type: "module", src: assets.entry }],
|
|
51
188
|
});
|
|
52
189
|
|
|
53
|
-
const content = await options.render(event, { head, seoHead });
|
|
190
|
+
const content = await options.render(event, { head, seoHead, params, query });
|
|
54
191
|
|
|
55
192
|
if (content instanceof HTTPResponse) {
|
|
56
193
|
return content;
|
|
57
194
|
}
|
|
58
195
|
|
|
196
|
+
// must be collected before wrapStream — it clears entries after the shell
|
|
197
|
+
const payloadScript = headPayloadScript(collectHeadInputs(head), nonce);
|
|
198
|
+
|
|
199
|
+
const templateWithPayload = injectHeadPayload(template, payloadScript);
|
|
200
|
+
|
|
59
201
|
const Root = RootComponent ?? Fragment;
|
|
60
202
|
|
|
61
203
|
const App = async () => (
|
|
@@ -83,7 +225,7 @@ export const definePage = (options: DefinePageOptions): PageHandler => {
|
|
|
83
225
|
|
|
84
226
|
html = transformHtmlTemplate(
|
|
85
227
|
head,
|
|
86
|
-
|
|
228
|
+
templateWithPayload.replace("<!--ssr-outlet-->", html ?? ""),
|
|
87
229
|
);
|
|
88
230
|
|
|
89
231
|
return new Response(html, responseInit);
|
|
@@ -92,7 +234,30 @@ export const definePage = (options: DefinePageOptions): PageHandler => {
|
|
|
92
234
|
.request("/");
|
|
93
235
|
}
|
|
94
236
|
|
|
95
|
-
const stream = wrapStream(
|
|
237
|
+
const stream = wrapStream(
|
|
238
|
+
head,
|
|
239
|
+
renderToReadableStream(<App />),
|
|
240
|
+
templateWithPayload,
|
|
241
|
+
undefined,
|
|
242
|
+
{
|
|
243
|
+
// same as unhead's default flushChunk, but with CSP nonce support
|
|
244
|
+
flushChunk: () => {
|
|
245
|
+
let chunk: string;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
chunk = renderSSRHeadSuspenseChunk(head);
|
|
249
|
+
} catch {
|
|
250
|
+
return "";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!chunk) {
|
|
254
|
+
return "";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return `<script${scriptNonceAttr(nonce)}>window.__unhead__&&(${chunk});document.currentScript.remove()</script>`;
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
);
|
|
96
261
|
|
|
97
262
|
return new Response(stream, responseInit);
|
|
98
263
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Child, FC } from "hono/jsx";
|
|
2
|
+
|
|
3
|
+
export interface SafeAsyncFallbackProps {
|
|
4
|
+
error: unknown;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const DefaultFallback: FC<SafeAsyncFallbackProps> = () => (
|
|
8
|
+
<div
|
|
9
|
+
role="alert"
|
|
10
|
+
data-testid="yamf-error"
|
|
11
|
+
style="padding: 1rem; border: 1px solid currentColor"
|
|
12
|
+
>
|
|
13
|
+
Something went wrong.
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wraps an async server component with error handling for streaming SSR.
|
|
19
|
+
*
|
|
20
|
+
* When an async component inside a Suspense boundary rejects after the shell
|
|
21
|
+
* has been flushed, the streaming renderer swallows the error: the browser
|
|
22
|
+
* keeps the loading fallback forever with a 200 status, and nothing reaches
|
|
23
|
+
* the error handler. `safeAsync` awaits the component directly, so a
|
|
24
|
+
* rejection is caught here and rendered as `fallback` instead.
|
|
25
|
+
*
|
|
26
|
+
* Catches errors of the wrapped component itself, not of its subtree.
|
|
27
|
+
*/
|
|
28
|
+
export const safeAsync = <P extends object>(
|
|
29
|
+
Component: (props: P) => Child | Promise<Child>,
|
|
30
|
+
fallback: FC<SafeAsyncFallbackProps> = DefaultFallback,
|
|
31
|
+
): FC<P> => {
|
|
32
|
+
const Safe = async (props: P): Promise<Child> => {
|
|
33
|
+
try {
|
|
34
|
+
return await Component(props);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error("[yamf] safeAsync: async component rejected", error);
|
|
37
|
+
|
|
38
|
+
return fallback({ error });
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// hono's FC type does not include Promise<Child> in its return type,
|
|
43
|
+
// but the JSX renderer awaits component results
|
|
44
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
|
45
|
+
return Safe as FC<P>;
|
|
46
|
+
};
|
package/src/server/entry.tsx
CHANGED
|
@@ -24,6 +24,8 @@ interface Route {
|
|
|
24
24
|
|
|
25
25
|
const router = createRouter<Route>();
|
|
26
26
|
|
|
27
|
+
const registeredRoutes = new Map<string, string>();
|
|
28
|
+
|
|
27
29
|
for (const [relativePath, handler] of Object.entries(pages).toSorted((a, b) =>
|
|
28
30
|
a[0].localeCompare(b[0]),
|
|
29
31
|
)) {
|
|
@@ -46,6 +48,16 @@ for (const [relativePath, handler] of Object.entries(pages).toSorted((a, b) =>
|
|
|
46
48
|
|
|
47
49
|
route = withLeadingSlash(withoutTrailingSlash(route));
|
|
48
50
|
|
|
51
|
+
const conflictingFile = registeredRoutes.get(route);
|
|
52
|
+
|
|
53
|
+
if (conflictingFile && conflictingFile !== relativePath) {
|
|
54
|
+
console.warn(
|
|
55
|
+
`[yamf] duplicate page route "${route}": ${conflictingFile} and ${relativePath} both map to it — the routes conflict`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
registeredRoutes.set(route, relativePath);
|
|
60
|
+
|
|
49
61
|
addRoute(router, "GET", route, {
|
|
50
62
|
handler,
|
|
51
63
|
serverAssets: pagesServerAssets[relativePath],
|
|
@@ -57,6 +69,15 @@ export type ServerEntry = EventHandlerWithFetch<EventHandlerRequest, Promise<unk
|
|
|
57
69
|
export interface DefineServerEntryOptions {
|
|
58
70
|
head?: YamfHead | ((event: H3Event) => YamfHead);
|
|
59
71
|
disableEarlyHints?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* CSP nonce for the inline scripts yamf injects (head handshake payload
|
|
74
|
+
* and streamed head patches).
|
|
75
|
+
*
|
|
76
|
+
* Note: unhead's stream bootstrap script (`window.__unhead__||(…)`) does
|
|
77
|
+
* not support a nonce in unhead 3.x — with a strict `script-src` CSP it
|
|
78
|
+
* must be allowed separately (e.g. via a hash).
|
|
79
|
+
*/
|
|
80
|
+
nonce?: string | ((event: H3Event) => string | undefined);
|
|
60
81
|
}
|
|
61
82
|
|
|
62
83
|
export const defineServerEntry = (options?: DefineServerEntryOptions): ServerEntry => {
|
|
@@ -72,7 +93,9 @@ export const defineServerEntry = (options?: DefineServerEntryOptions): ServerEnt
|
|
|
72
93
|
|
|
73
94
|
const assets = clientAssets.merge(...[rootAssets, serverAssets].filter(x => !!x));
|
|
74
95
|
|
|
75
|
-
|
|
96
|
+
// early hints are pointless inside the prerender worker — nitro's
|
|
97
|
+
// prerenderer buffers the response to a file and discards them
|
|
98
|
+
if (!options?.disableEarlyHints && !event.req.headers.has("x-nitro-prerender")) {
|
|
76
99
|
const link = [
|
|
77
100
|
...assets.js.map(script => `<${script.href}>; rel=modulepreload`),
|
|
78
101
|
...assets.css.map(style => `<${style.href}>; rel=preload; as=style`),
|
|
@@ -86,6 +109,7 @@ export const defineServerEntry = (options?: DefineServerEntryOptions): ServerEnt
|
|
|
86
109
|
return (await handler())(event, {
|
|
87
110
|
assets,
|
|
88
111
|
head: typeof options?.head === "function" ? options.head(event) : options?.head,
|
|
112
|
+
nonce: typeof options?.nonce === "function" ? options.nonce(event) : options?.nonce,
|
|
89
113
|
});
|
|
90
114
|
};
|
|
91
115
|
|
package/src/vite/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
2
3
|
import type { NitroPluginConfig } from "nitro/vite";
|
|
3
4
|
import { nitro } from "nitro/vite";
|
|
4
5
|
import type { EnvironmentOptions, PluginOption } from "vite";
|
|
5
6
|
import { islands } from "./islands";
|
|
7
|
+
import { prerenderModule } from "./prerender";
|
|
6
8
|
import { virtualAssets } from "./virtual-assets";
|
|
7
9
|
import { virtualErrorHandler } from "./virtual-error-handler";
|
|
8
10
|
import { virtualPages } from "./virtual-pages";
|
|
@@ -19,7 +21,11 @@ const yamf = (options?: YamfOptions): PluginOption[] => {
|
|
|
19
21
|
// TODO: extendable config
|
|
20
22
|
plugins.push({
|
|
21
23
|
name: "yamf:config",
|
|
22
|
-
config() {
|
|
24
|
+
config(config) {
|
|
25
|
+
// resolved against the vite root (not cwd) so running vite from
|
|
26
|
+
// another working directory does not silently drop the ssr entry
|
|
27
|
+
const root = config.root ?? process.cwd();
|
|
28
|
+
|
|
23
29
|
const ssrEnv: EnvironmentOptions = {
|
|
24
30
|
build: {
|
|
25
31
|
// TODO: figure out if this should be true
|
|
@@ -61,7 +67,7 @@ const yamf = (options?: YamfOptions): PluginOption[] => {
|
|
|
61
67
|
],
|
|
62
68
|
},
|
|
63
69
|
environments: {
|
|
64
|
-
...(existsSync("./src/server.tsx") ? { ssr: ssrEnv } : {}),
|
|
70
|
+
...(existsSync(resolve(root, "./src/server.tsx")) ? { ssr: ssrEnv } : {}),
|
|
65
71
|
client: {
|
|
66
72
|
build: {
|
|
67
73
|
rolldownOptions: {
|
|
@@ -82,16 +88,37 @@ const yamf = (options?: YamfOptions): PluginOption[] => {
|
|
|
82
88
|
plugins.push(virtualTemplate());
|
|
83
89
|
plugins.push(virtualRoot());
|
|
84
90
|
|
|
91
|
+
// registers the nitro module that makes prerender work (see prerenderModule)
|
|
92
|
+
plugins.push({
|
|
93
|
+
name: "yamf:prerender",
|
|
94
|
+
nitro: prerenderModule,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// reserved nitro keys are yamf-controlled and intentionally win over user
|
|
98
|
+
// values; everything else the user passes through wins. user publicAssets
|
|
99
|
+
// are merged with yamf's instead of being dropped
|
|
100
|
+
const {
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
102
|
+
serverDir: _serverDir,
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
104
|
+
renderer: _renderer,
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
106
|
+
compressPublicAssets: _compressPublicAssets,
|
|
107
|
+
publicAssets: userPublicAssets,
|
|
108
|
+
...userNitro
|
|
109
|
+
} = options?.nitro ?? {};
|
|
110
|
+
|
|
85
111
|
plugins.push(
|
|
86
112
|
nitro({
|
|
113
|
+
...userNitro,
|
|
87
114
|
serverDir: "./src",
|
|
88
115
|
renderer: false,
|
|
89
|
-
...options?.nitro,
|
|
90
116
|
compressPublicAssets: {
|
|
91
117
|
gzip: true,
|
|
92
118
|
brotli: true,
|
|
93
119
|
},
|
|
94
120
|
publicAssets: [
|
|
121
|
+
...(userPublicAssets ?? []),
|
|
95
122
|
{
|
|
96
123
|
baseURL: "assets",
|
|
97
124
|
dir: "./public/assets",
|
package/src/vite/islands.ts
CHANGED
|
@@ -5,6 +5,24 @@ import { Visitor } from "vite";
|
|
|
5
5
|
|
|
6
6
|
const ISLAND_REGEX = /\.island\.(j|t)sx?$/;
|
|
7
7
|
|
|
8
|
+
// inits that can plausibly evaluate to a component; anything else (object,
|
|
9
|
+
// array, literal, class…) is left as a plain export with a warning
|
|
10
|
+
const WRAPPABLE_INIT_TYPES = new Set([
|
|
11
|
+
"ArrowFunctionExpression",
|
|
12
|
+
"FunctionExpression",
|
|
13
|
+
"CallExpression",
|
|
14
|
+
"OptionalCallExpression",
|
|
15
|
+
"Identifier",
|
|
16
|
+
"ConditionalExpression",
|
|
17
|
+
"SequenceExpression",
|
|
18
|
+
"TSAsExpression",
|
|
19
|
+
"NonNullExpression",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const isWrappableInit = (init: { type: string }): boolean => {
|
|
23
|
+
return WRAPPABLE_INIT_TYPES.has(init.type);
|
|
24
|
+
};
|
|
25
|
+
|
|
8
26
|
type Lang = NonNullable<ParserOptions["lang"]>;
|
|
9
27
|
|
|
10
28
|
function langFromId(id: string): Lang {
|
|
@@ -42,6 +60,7 @@ export const islands = (): Plugin[] => {
|
|
|
42
60
|
s.prepend(`import __assets from "${id}?assets=client";\n`);
|
|
43
61
|
|
|
44
62
|
const seenExports = new Set<string>();
|
|
63
|
+
const warnings: string[] = [];
|
|
45
64
|
|
|
46
65
|
new Visitor({
|
|
47
66
|
ExportDefaultDeclaration(node) {
|
|
@@ -51,6 +70,9 @@ export const islands = (): Plugin[] => {
|
|
|
51
70
|
declaration.type !== "FunctionExpression" &&
|
|
52
71
|
declaration.type !== "ArrowFunctionExpression"
|
|
53
72
|
) {
|
|
73
|
+
warnings.push(
|
|
74
|
+
`${id}: export default <${declaration.type === "Identifier" ? "identifier" : declaration.type}> is not wrapped as an island — only function components are supported`,
|
|
75
|
+
);
|
|
54
76
|
return;
|
|
55
77
|
}
|
|
56
78
|
|
|
@@ -64,8 +86,20 @@ export const islands = (): Plugin[] => {
|
|
|
64
86
|
},
|
|
65
87
|
ExportNamedDeclaration(node) {
|
|
66
88
|
const declaration = node.declaration;
|
|
67
|
-
|
|
68
|
-
|
|
89
|
+
|
|
90
|
+
if (!declaration) {
|
|
91
|
+
if (node.exportKind !== "type") {
|
|
92
|
+
warnings.push(
|
|
93
|
+
`${id}: export { … } statements are not wrapped as islands — export the component with \`export const\` or \`export function\``,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (declaration.type === "VariableDeclaration") {
|
|
100
|
+
const declarators = declaration.declarations;
|
|
101
|
+
const declarator = declarators[0];
|
|
102
|
+
|
|
69
103
|
if (
|
|
70
104
|
!declarator ||
|
|
71
105
|
declarator.id.type !== "Identifier" ||
|
|
@@ -74,7 +108,22 @@ export const islands = (): Plugin[] => {
|
|
|
74
108
|
) {
|
|
75
109
|
return;
|
|
76
110
|
}
|
|
111
|
+
|
|
77
112
|
const exportName = declarator.id.name;
|
|
113
|
+
|
|
114
|
+
if (!isWrappableInit(declarator.init)) {
|
|
115
|
+
warnings.push(
|
|
116
|
+
`${id}: export "${exportName}" does not look like a component — left as a plain export, not an island`,
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (declarators.length > 1) {
|
|
122
|
+
warnings.push(
|
|
123
|
+
`${id}: only the first declarator of a multi-declarator export is wrapped as an island ("${exportName}") — split the declaration`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
78
127
|
seenExports.add(exportName);
|
|
79
128
|
|
|
80
129
|
// export const <name> = <init> ->
|
|
@@ -88,7 +137,7 @@ export const islands = (): Plugin[] => {
|
|
|
88
137
|
declarator.init.end,
|
|
89
138
|
`; export const ${exportName} = __runtime.createIsland(__wrap_${exportName}, "${exportName}", __assets)`,
|
|
90
139
|
);
|
|
91
|
-
} else if (declaration
|
|
140
|
+
} else if (declaration.type === "FunctionDeclaration") {
|
|
92
141
|
const fnId = declaration.id;
|
|
93
142
|
if (
|
|
94
143
|
!fnId ||
|
|
@@ -111,6 +160,10 @@ export const islands = (): Plugin[] => {
|
|
|
111
160
|
}
|
|
112
161
|
},
|
|
113
162
|
}).visit(program);
|
|
163
|
+
|
|
164
|
+
for (const warning of warnings) {
|
|
165
|
+
this.warn(warning);
|
|
166
|
+
}
|
|
114
167
|
}),
|
|
115
168
|
},
|
|
116
169
|
},
|
|
@@ -119,8 +172,9 @@ export const islands = (): Plugin[] => {
|
|
|
119
172
|
transform: {
|
|
120
173
|
order: "post",
|
|
121
174
|
handler: withMagicString(function (s) {
|
|
122
|
-
|
|
123
|
-
|
|
175
|
+
// the token is namespaced to avoid colliding with user code
|
|
176
|
+
if (s.original.includes("__yamf_raw_import__")) {
|
|
177
|
+
s.replaceAll("__yamf_raw_import__", "import");
|
|
124
178
|
}
|
|
125
179
|
}),
|
|
126
180
|
},
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { NitroModule } from "nitro/types";
|
|
2
|
+
|
|
3
|
+
// Nitro's prerenderer is a standalone rolldown build (`nitro-prerender` preset)
|
|
4
|
+
// that cannot run yamf's vite plugins, so `virtual:yamf:*` modules, the islands
|
|
5
|
+
// transform, and the vite asset manifests do not exist there. Its auto-detected
|
|
6
|
+
// serverEntry (src/server.tsx) would be bundled by rolldown and crash the
|
|
7
|
+
// worker at init (`Received protocol 'virtual:'`). Disabling it lets prerender
|
|
8
|
+
// requests fall through to the inherited renderer — nitro's vite ssr-renderer —
|
|
9
|
+
// which dispatches them into the vite-built ssr service bundle: the exact code
|
|
10
|
+
// path the production server uses.
|
|
11
|
+
export const prerenderModule: NitroModule = {
|
|
12
|
+
name: "yamf:prerender",
|
|
13
|
+
setup(nitro) {
|
|
14
|
+
nitro.hooks.hook("prerender:config", config => {
|
|
15
|
+
config.serverEntry = false;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
nitro.hooks.hook("prerender:generate", route => {
|
|
19
|
+
// static hosts (github-pages & friends) expect the not-found document
|
|
20
|
+
// at exactly /404.html; nitro would write /404 as 404/index.html
|
|
21
|
+
// (autoSubfolderIndex)
|
|
22
|
+
if (route.route === "/404" && !route.error && !route.skip) {
|
|
23
|
+
route.fileName = "404.html";
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
};
|
package/src/vite/virtual-root.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { resolveModulePath } from "exsolve";
|
|
2
3
|
import type { Plugin } from "vite";
|
|
3
4
|
import { js } from "../shared/utils";
|
|
@@ -8,8 +9,15 @@ export const virtualRoot = (): Plugin => {
|
|
|
8
9
|
const virtualModuleId = "virtual:yamf:root";
|
|
9
10
|
const resolvedVirtualModuleId = "\0" + virtualModuleId;
|
|
10
11
|
|
|
12
|
+
// resolved against the vite root (not cwd) so running vite from another
|
|
13
|
+
// working directory does not silently skip the root layout
|
|
14
|
+
let root = process.cwd();
|
|
15
|
+
|
|
11
16
|
return {
|
|
12
17
|
name: "yamf:virtual-root",
|
|
18
|
+
configResolved(config) {
|
|
19
|
+
root = config.root;
|
|
20
|
+
},
|
|
13
21
|
resolveId(id) {
|
|
14
22
|
if (id === virtualModuleId) {
|
|
15
23
|
return resolvedVirtualModuleId;
|
|
@@ -22,13 +30,15 @@ export const virtualRoot = (): Plugin => {
|
|
|
22
30
|
return;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
const
|
|
33
|
+
const rootPath = resolve(root, ROOT_PATH);
|
|
34
|
+
|
|
35
|
+
const rootModule = resolveModulePath(rootPath, {
|
|
26
36
|
try: true,
|
|
27
37
|
suffixes: ["", "/index"],
|
|
28
38
|
extensions: [".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs", ".mts", ".cts"],
|
|
29
39
|
});
|
|
30
40
|
|
|
31
|
-
if (!
|
|
41
|
+
if (!rootModule) {
|
|
32
42
|
return js`
|
|
33
43
|
const Root = null;
|
|
34
44
|
const rootAssets = null;
|
|
@@ -38,8 +48,8 @@ export { Root, rootAssets };
|
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
return js`
|
|
41
|
-
import Root from "${
|
|
42
|
-
import rootAssets from "${
|
|
51
|
+
import Root from "${rootModule}";
|
|
52
|
+
import rootAssets from "${rootModule}?assets=ssr";
|
|
43
53
|
|
|
44
54
|
export { Root, rootAssets };
|
|
45
55
|
`.trim();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { relative } from "node:path";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
3
|
import type { Plugin } from "vite";
|
|
4
4
|
import { js } from "../shared/utils";
|
|
5
5
|
|
|
@@ -19,8 +19,16 @@ export const virtualTemplate = (): Plugin => {
|
|
|
19
19
|
const virtualModuleId = "virtual:yamf:template";
|
|
20
20
|
const resolvedVirtualModuleId = "\0" + virtualModuleId;
|
|
21
21
|
|
|
22
|
+
// resolved against the vite root (not cwd) so running vite from another
|
|
23
|
+
// working directory — monorepo tooling, turbo, etc — does not silently
|
|
24
|
+
// fall back to the default template
|
|
25
|
+
let root = process.cwd();
|
|
26
|
+
|
|
22
27
|
return {
|
|
23
28
|
name: "yamf:virtual-template",
|
|
29
|
+
configResolved(config) {
|
|
30
|
+
root = config.root;
|
|
31
|
+
},
|
|
24
32
|
resolveId(id) {
|
|
25
33
|
if (id === virtualModuleId) {
|
|
26
34
|
return resolvedVirtualModuleId;
|
|
@@ -34,7 +42,7 @@ export const virtualTemplate = (): Plugin => {
|
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
try {
|
|
37
|
-
const template = await readFile(TEMPLATE_PATH, "utf8");
|
|
45
|
+
const template = await readFile(resolve(root, TEMPLATE_PATH), "utf8");
|
|
38
46
|
|
|
39
47
|
return js`export const template = ${JSON.stringify(template)};`;
|
|
40
48
|
} catch (error) {
|
|
@@ -46,7 +54,11 @@ export const virtualTemplate = (): Plugin => {
|
|
|
46
54
|
}
|
|
47
55
|
},
|
|
48
56
|
handleHotUpdate({ file, server }) {
|
|
49
|
-
if (
|
|
57
|
+
if (!isAbsolute(file)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (relative(resolve(root, TEMPLATE_PATH), file) === "") {
|
|
50
62
|
const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId);
|
|
51
63
|
|
|
52
64
|
if (mod) {
|