@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/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 DefinePageOptions {
35
- render: PageRenderer;
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
- export const definePage = (options: DefinePageOptions): PageHandler => {
40
- return async (event, { assets, head: headInit }) => {
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, "&quot;")}"` : "";
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
- template.replace("<!--ssr-outlet-->", html ?? ""),
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(head, renderToReadableStream(<App />), template);
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
+ };
@@ -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
- if (!options?.disableEarlyHints) {
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",
@@ -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
- if (declaration?.type === "VariableDeclaration") {
68
- const declarator = declaration.declarations[0];
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?.type === "FunctionDeclaration") {
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
- if (s.original.includes("__island_raw_import__")) {
123
- s.replaceAll("__island_raw_import__", "import");
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
+ };
@@ -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 root = resolveModulePath(ROOT_PATH, {
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 (!root) {
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 "${ROOT_PATH}";
42
- import rootAssets from "${ROOT_PATH}?assets=ssr";
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 (relative(file, TEMPLATE_PATH) === "") {
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) {