@reckona/mreact-router 0.0.50 → 0.0.52

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 (47) hide show
  1. package/README.md +8 -6
  2. package/dist/actions.d.ts +1 -2
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +6 -91
  5. package/dist/actions.js.map +1 -1
  6. package/dist/adapters/cloudflare.d.ts +29 -16
  7. package/dist/adapters/cloudflare.d.ts.map +1 -1
  8. package/dist/adapters/cloudflare.js +232 -10
  9. package/dist/adapters/cloudflare.js.map +1 -1
  10. package/dist/build.d.ts +2 -0
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +266 -13
  13. package/dist/build.js.map +1 -1
  14. package/dist/bundle-pipeline.d.ts +2 -0
  15. package/dist/bundle-pipeline.d.ts.map +1 -1
  16. package/dist/bundle-pipeline.js +1 -0
  17. package/dist/bundle-pipeline.js.map +1 -1
  18. package/dist/cli.js +6 -5
  19. package/dist/cli.js.map +1 -1
  20. package/dist/csrf.d.ts +7 -0
  21. package/dist/csrf.d.ts.map +1 -0
  22. package/dist/csrf.js +104 -0
  23. package/dist/csrf.js.map +1 -0
  24. package/dist/file-conventions.d.ts.map +1 -1
  25. package/dist/file-conventions.js +16 -0
  26. package/dist/file-conventions.js.map +1 -1
  27. package/dist/import-policy.d.ts.map +1 -1
  28. package/dist/import-policy.js +2 -0
  29. package/dist/import-policy.js.map +1 -1
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/native-route-matcher.d.ts.map +1 -1
  35. package/dist/native-route-matcher.js +14 -1
  36. package/dist/native-route-matcher.js.map +1 -1
  37. package/dist/render.d.ts +1 -0
  38. package/dist/render.d.ts.map +1 -1
  39. package/dist/render.js +87 -9
  40. package/dist/render.js.map +1 -1
  41. package/dist/routes.d.ts +1 -1
  42. package/dist/routes.d.ts.map +1 -1
  43. package/dist/routes.js +27 -4
  44. package/dist/routes.js.map +1 -1
  45. package/dist/types.d.ts +10 -3
  46. package/dist/types.d.ts.map +1 -1
  47. package/package.json +11 -11
package/dist/build.js CHANGED
@@ -28,6 +28,7 @@ export async function buildApp(options) {
28
28
  const shouldBuildCloudflare = buildTargets.includes("cloudflare");
29
29
  const shouldBuildAwsLambda = buildTargets.includes("aws-lambda");
30
30
  const routes = await scanAppRoutes({ appDir: project.routesDir });
31
+ const vitePlugins = options.viteConfig?.plugins;
31
32
  const files = await collectBuildFiles(project.projectRoot, project.allowedSourceDirs, project.routesDir);
32
33
  const serverDir = join(options.outDir, "server");
33
34
  const clientDir = join(options.outDir, "client");
@@ -42,7 +43,7 @@ export async function buildApp(options) {
42
43
  await mkdir(join(clientDir, ".vite"), { recursive: true });
43
44
  await mkdir(join(clientDir, "assets", "routes"), { recursive: true });
44
45
  await copyPublicAssets(project.publicDir, join(clientDir, "public"));
45
- const appConventionPublicAssets = await copyAppFileConventionAssets(project.routesDir, join(clientDir, "public"));
46
+ const appConventionPublicAssets = await copyAppFileConventionAssets(project.routesDir, clientDir);
46
47
  await copyPublicAssets(project.publicDir, clientDir);
47
48
  const publicAssets = [
48
49
  ...new Set([...(await collectPublicAssetPaths(project.publicDir)), ...appConventionPublicAssets]),
@@ -60,6 +61,7 @@ export async function buildApp(options) {
60
61
  project,
61
62
  projectRoot: project.projectRoot,
62
63
  routes,
64
+ vitePlugins,
63
65
  });
64
66
  const generatedImportPolicy = buildGeneratedImportPolicy({
65
67
  files,
@@ -80,6 +82,7 @@ export async function buildApp(options) {
80
82
  route,
81
83
  sourceMapDir: join(options.outDir, "source-maps", "client"),
82
84
  sourceMaps: project.clientSourceMaps,
85
+ vitePlugins,
83
86
  })));
84
87
  const navigationRuntimeScript = clientRoutes.some((route) => route.navigation === true && !route.client)
85
88
  ? await writeNavigationRuntimeBundle(clientDir)
@@ -93,7 +96,9 @@ export async function buildApp(options) {
93
96
  appDir: project.routesDir,
94
97
  assetBaseUrl: project.assetBaseUrl,
95
98
  clientRoutes: clientManifestRoutes,
99
+ project,
96
100
  routes,
101
+ serverModules,
97
102
  });
98
103
  let cloudflareRouteModules;
99
104
  if (shouldBuildCloudflare) {
@@ -105,6 +110,7 @@ export async function buildApp(options) {
105
110
  routesDir: project.routesDir,
106
111
  routes,
107
112
  serverModules,
113
+ vitePlugins,
108
114
  });
109
115
  }
110
116
  const serverManifest = {
@@ -438,6 +444,15 @@ function isNodeError(error) {
438
444
  async function prerenderStaticRoutes(options) {
439
445
  const clientScripts = new Map(options.clientRoutes.flatMap((route) => route.client && route.script !== undefined ? [[route.path, route.script]] : []));
440
446
  const prerendered = {};
447
+ const importPolicy = {
448
+ allowedPackages: await readDeclaredProjectPackages(options.project.projectRoot),
449
+ allowedSourceDirs: options.project.allowedSourceDirs,
450
+ projectRoot: options.project.projectRoot,
451
+ };
452
+ const serverModuleMap = new Map(Object.entries(options.serverModules).map(([file, artifact]) => [
453
+ join(options.project.projectRoot, file),
454
+ artifact,
455
+ ]));
441
456
  for (const route of options.routes) {
442
457
  if (route.kind !== "page") {
443
458
  continue;
@@ -451,7 +466,9 @@ async function prerenderStaticRoutes(options) {
451
466
  appDir: options.appDir,
452
467
  assetBaseUrl: options.assetBaseUrl,
453
468
  clientScripts,
469
+ importPolicy,
454
470
  request: new Request(`http://mreact.local${pathname}`),
471
+ serverModules: serverModuleMap,
455
472
  });
456
473
  const headers = {};
457
474
  response.headers.forEach((value, key) => {
@@ -542,6 +559,7 @@ async function buildServerModuleArtifacts(options) {
542
559
  code: stripRouteLoaderOnlyExports(source),
543
560
  filename: absoluteFile,
544
561
  importPolicy: requestModuleImportPolicy,
562
+ vitePlugins: options.vitePlugins,
545
563
  });
546
564
  artifact.loader = {
547
565
  code,
@@ -554,6 +572,7 @@ async function buildServerModuleArtifacts(options) {
554
572
  code: stripRouteMetadataOnlyExports(source),
555
573
  filename: absoluteFile,
556
574
  importPolicy: requestModuleImportPolicy,
575
+ vitePlugins: options.vitePlugins,
557
576
  });
558
577
  artifact.routeMetadata = {
559
578
  code,
@@ -568,6 +587,7 @@ async function buildServerModuleArtifacts(options) {
568
587
  importPolicy: requestModuleImportPolicy,
569
588
  routeKind: route?.kind,
570
589
  source,
590
+ vitePlugins: options.vitePlugins,
571
591
  }),
572
592
  sourceHash: hashText(source),
573
593
  };
@@ -728,6 +748,7 @@ async function buildRequestModuleArtifactCode(options) {
728
748
  code: stripRouteRequestOnlyExports(options.source),
729
749
  filename: options.filename,
730
750
  importPolicy: options.importPolicy,
751
+ vitePlugins: options.vitePlugins,
731
752
  });
732
753
  }
733
754
  async function bundleRouteLoaderModuleCode(options) {
@@ -747,6 +768,7 @@ async function bundleRouteRequestModuleCode(options) {
747
768
  code: options.code,
748
769
  filename: options.filename,
749
770
  platform: "node",
771
+ vitePlugins: options.vitePlugins,
750
772
  plugins: [
751
773
  createAppRouterImportPolicyPlugin({
752
774
  appDir: options.appDir,
@@ -794,18 +816,24 @@ async function writeCloudflareRouteModules(options) {
794
816
  const routeId = routeIdForPath(route.path);
795
817
  const routeModuleFile = `routes/${routeId}.mjs`;
796
818
  let routeModuleExports;
797
- if (route.kind === "server") {
819
+ if (route.kind === "server" || route.kind === "metadata") {
798
820
  try {
799
821
  const routeOutput = await buildCloudflareServerRouteModule({
800
822
  filename: route.file,
823
+ vitePlugins: options.vitePlugins,
801
824
  });
802
825
  const serverRouteFile = `routes/${routeId}.${hashText(routeOutput).slice(0, 8)}.server.mjs`;
803
826
  await writeFile(join(options.cloudflareDir, serverRouteFile), routeOutput);
804
827
  const serverRouteImport = `./${serverRouteFile.split("/").pop() ?? serverRouteFile}`;
805
- routeModuleExports = [`export * from ${JSON.stringify(serverRouteImport)};`];
828
+ routeModuleExports = [
829
+ `export * from ${JSON.stringify(serverRouteImport)};`,
830
+ ...(route.kind === "metadata"
831
+ ? [`export { default } from ${JSON.stringify(serverRouteImport)};`]
832
+ : []),
833
+ ];
806
834
  }
807
835
  catch (error) {
808
- throw new Error(`Failed to build Cloudflare server route module for ${routeFile}: ${errorMessage(error)}`);
836
+ throw new Error(`Failed to build Cloudflare ${route.kind} route module for ${routeFile}: ${errorMessage(error)}`);
809
837
  }
810
838
  await writeFile(join(options.cloudflareDir, routeModuleFile), `${routeModuleExports.join("\n")}\n`);
811
839
  registryEntries.push(`${JSON.stringify(routeFile)}: () => import(${JSON.stringify(`./${routeModuleFile}`)})`);
@@ -827,12 +855,14 @@ async function writeCloudflareRouteModules(options) {
827
855
  projectRoot: options.projectRoot,
828
856
  routesDir: options.routesDir,
829
857
  serverModules: options.serverModules,
858
+ vitePlugins: options.vitePlugins,
830
859
  })
831
860
  : await buildCloudflareStringRouteComponentModule({
832
861
  filename: route.file,
833
862
  projectRoot: options.projectRoot,
834
863
  routesDir: options.routesDir,
835
864
  serverModules: options.serverModules,
865
+ vitePlugins: options.vitePlugins,
836
866
  });
837
867
  const componentFile = `routes/${routeId}.${hashText(componentOutput).slice(0, 8)}.component.mjs`;
838
868
  await writeFile(join(options.cloudflareDir, componentFile), componentOutput);
@@ -849,6 +879,7 @@ async function writeCloudflareRouteModules(options) {
849
879
  const loaderOutput = await buildCloudflareRouteLoaderModule({
850
880
  filename: route.file,
851
881
  projectRoot: options.projectRoot,
882
+ vitePlugins: options.vitePlugins,
852
883
  });
853
884
  const loaderFile = `routes/${routeId}.${hashText(loaderOutput).slice(0, 8)}.loader.mjs`;
854
885
  await writeFile(join(options.cloudflareDir, loaderFile), loaderOutput);
@@ -873,15 +904,25 @@ async function writeCloudflareRouteModules(options) {
873
904
  return { registryFile: "route-modules.mjs" };
874
905
  }
875
906
  async function buildCloudflareServerComponentModule(options) {
907
+ const metadataModule = await buildCloudflareRouteMetadataExportModule({
908
+ filename: options.filename,
909
+ vitePlugins: options.vitePlugins,
910
+ });
876
911
  const entry = `import * as routeModule from ${JSON.stringify(options.filename)};
912
+ ${metadataModule === undefined ? "const metadataModule = {};" : 'import * as metadataModule from "mreact:metadata";'}
877
913
 
878
914
  const component = routeModule.default ?? routeModule.App ?? Object.values(routeModule).find((value) => typeof value === "function");
879
915
  export const App = component;
880
916
  export default component;
917
+ export const generateMetadata = metadataModule.generateMetadata;
918
+ export const metadata = metadataModule.metadata;
881
919
  export const slots = routeModule.slots;`;
882
- return bundleCloudflareModule({
920
+ return bundleCloudflareVirtualModule({
883
921
  entry,
884
922
  filename: `${options.filename}.mreact-cloudflare-component.js`,
923
+ modules: metadataModule === undefined
924
+ ? new Map()
925
+ : new Map([["mreact:metadata", metadataModule]]),
885
926
  plugins: [
886
927
  cloudflareServerSourceTransformPlugin({
887
928
  projectRoot: options.projectRoot,
@@ -891,6 +932,7 @@ export const slots = routeModule.slots;`;
891
932
  cloudflareWorkspaceRuntimePlugin(),
892
933
  ],
893
934
  resolveDir: dirname(options.filename),
935
+ vitePlugins: options.vitePlugins,
894
936
  });
895
937
  }
896
938
  async function buildCloudflareStringRouteComponentModule(options) {
@@ -901,6 +943,7 @@ async function buildCloudflareStringRouteComponentModule(options) {
901
943
  projectRoot: options.projectRoot,
902
944
  serverModules: options.serverModules,
903
945
  serverOutput: "string",
946
+ vitePlugins: options.vitePlugins,
904
947
  });
905
948
  }
906
949
  const pageModule = await buildCloudflareComponentExportModule({
@@ -908,15 +951,17 @@ async function buildCloudflareStringRouteComponentModule(options) {
908
951
  projectRoot: options.projectRoot,
909
952
  serverModules: options.serverModules,
910
953
  serverOutput: "string",
954
+ vitePlugins: options.vitePlugins,
911
955
  });
912
956
  const shellModules = await Promise.all(shellFiles.map((shell) => buildCloudflareComponentExportModule({
913
957
  filename: shell.file,
914
958
  projectRoot: options.projectRoot,
915
959
  serverModules: options.serverModules,
916
960
  serverOutput: "string",
961
+ vitePlugins: options.vitePlugins,
917
962
  })));
918
963
  const shellImports = shellFiles.map((_, index) => `import * as shell${index} from "mreact:shell-${index}";`);
919
- const shellDefinitions = shellFiles.map((shell, index) => `{ component: selectComponent(shell${index}, ${JSON.stringify(shell.file)}), id: ${JSON.stringify(shell.id)}, kind: ${JSON.stringify(shell.kind)} }`);
964
+ const shellDefinitions = shellFiles.map((shell, index) => `{ component: selectComponent(shell${index}, ${JSON.stringify(shell.file)}), id: ${JSON.stringify(shell.id)}, kind: ${JSON.stringify(shell.kind)}, module: shell${index} }`);
920
965
  const entry = `import * as pageModule from "mreact:page";
921
966
  ${shellImports.join("\n")}
922
967
 
@@ -929,7 +974,8 @@ export default renderCloudflareStringRoute;
929
974
  async function renderCloudflareStringRoute(props) {
930
975
  const slotHtml = await renderRouteSlots(pageModule.slots, props);
931
976
  const layoutShells = await renderLayoutShells(shells, props, slotHtml);
932
- let html = "<!DOCTYPE html>" + cloudflareRouteHeadTags(props.clientManifest, props.route.path);
977
+ const metadata = await resolveRouteMetadata([...shells.map((shell) => shell.module), pageModule], props);
978
+ let html = "<!DOCTYPE html>";
933
979
  for (const shell of layoutShells) {
934
980
  html += shell.prefix;
935
981
  }
@@ -937,6 +983,7 @@ async function renderCloudflareStringRoute(props) {
937
983
  for (const shell of [...layoutShells].reverse()) {
938
984
  html += shell.suffix;
939
985
  }
986
+ html = injectCloudflareHead(html, metadata, cloudflareRouteHeadTags(props.clientManifest, props.route.path));
940
987
  return new Response(html, {
941
988
  headers: {
942
989
  "content-type": "text/html; charset=utf-8"
@@ -973,6 +1020,7 @@ ${cloudflareShellRuntimeSource()}`;
973
1020
  ]),
974
1021
  plugins: [cloudflareWorkspaceRuntimePlugin()],
975
1022
  resolveDir: dirname(options.filename),
1023
+ vitePlugins: options.vitePlugins,
976
1024
  });
977
1025
  }
978
1026
  async function buildCloudflareStreamRouteComponentModule(options) {
@@ -981,6 +1029,7 @@ async function buildCloudflareStreamRouteComponentModule(options) {
981
1029
  projectRoot: options.projectRoot,
982
1030
  serverModules: options.serverModules,
983
1031
  serverOutput: "stream",
1032
+ vitePlugins: options.vitePlugins,
984
1033
  });
985
1034
  const shellFiles = await cloudflareShellFilesForPage(options.routesDir, options.filename);
986
1035
  const shellModules = await Promise.all(shellFiles.map((shell) => buildCloudflareComponentExportModule({
@@ -988,9 +1037,10 @@ async function buildCloudflareStreamRouteComponentModule(options) {
988
1037
  projectRoot: options.projectRoot,
989
1038
  serverModules: options.serverModules,
990
1039
  serverOutput: "string",
1040
+ vitePlugins: options.vitePlugins,
991
1041
  })));
992
1042
  const shellImports = shellFiles.map((_, index) => `import * as shell${index} from "mreact:shell-${index}";`);
993
- const shellDefinitions = shellFiles.map((shell, index) => `{ component: selectComponent(shell${index}, ${JSON.stringify(shell.file)}), id: ${JSON.stringify(shell.id)}, kind: ${JSON.stringify(shell.kind)} }`);
1043
+ const shellDefinitions = shellFiles.map((shell, index) => `{ component: selectComponent(shell${index}, ${JSON.stringify(shell.file)}), id: ${JSON.stringify(shell.id)}, kind: ${JSON.stringify(shell.kind)}, module: shell${index} }`);
994
1044
  const entry = `import { createStringSink, renderOutOfOrderReorderScript, renderToReadableStream } from "@reckona/mreact-server";
995
1045
  import * as pageModule from "mreact:page";
996
1046
  ${shellImports.join("\n")}
@@ -1005,10 +1055,15 @@ function renderCloudflareStreamRoute(props) {
1005
1055
  const body = renderToReadableStream(async ($sink) => {
1006
1056
  const slotHtml = await renderRouteSlots(pageModule.slots, props);
1007
1057
  const layoutShells = await renderLayoutShells(shells, props, slotHtml);
1058
+ const metadata = await resolveRouteMetadata([...shells.map((shell) => shell.module), pageModule], props);
1059
+ const routeHeadTags = cloudflareRouteHeadTags(props.clientManifest, props.route.path);
1008
1060
  $sink.append("<!DOCTYPE html>");
1009
- $sink.append(cloudflareRouteHeadTags(props.clientManifest, props.route.path));
1010
- for (const shell of layoutShells) {
1011
- $sink.append(shell.prefix);
1061
+ if (layoutShells.length === 0) {
1062
+ $sink.append(injectCloudflareHead("", metadata, routeHeadTags));
1063
+ }
1064
+ for (let index = 0; index < layoutShells.length; index += 1) {
1065
+ const shell = layoutShells[index];
1066
+ $sink.append(index === 0 ? injectCloudflareHead(shell.prefix, metadata, routeHeadTags) : shell.prefix);
1012
1067
  }
1013
1068
  await pageComponent($sink, props);
1014
1069
  renderOutOfOrderReorderScript($sink);
@@ -1060,6 +1115,7 @@ ${cloudflareShellRuntimeSource()}`;
1060
1115
  ]),
1061
1116
  plugins: [cloudflareWorkspaceRuntimePlugin()],
1062
1117
  resolveDir: dirname(options.filename),
1118
+ vitePlugins: options.vitePlugins,
1063
1119
  });
1064
1120
  }
1065
1121
  function cloudflareShellRuntimeSource() {
@@ -1128,6 +1184,160 @@ function readSlotName(attributes) {
1128
1184
  return match?.[1] ?? match?.[2];
1129
1185
  }
1130
1186
 
1187
+ async function resolveRouteMetadata(modules, props) {
1188
+ const metadata = [];
1189
+ const context = {
1190
+ data: props.data,
1191
+ params: props.params,
1192
+ request: props.request
1193
+ };
1194
+ for (const module of modules) {
1195
+ let next = module.metadata;
1196
+ if (typeof module.generateMetadata === "function") {
1197
+ const generated = await module.generateMetadata(context);
1198
+ next = mergeRouteMetadata([next, generated].filter(Boolean));
1199
+ }
1200
+ if (next !== undefined) {
1201
+ metadata.push(next);
1202
+ }
1203
+ }
1204
+ return mergeRouteMetadata(metadata);
1205
+ }
1206
+
1207
+ function mergeRouteMetadata(metadata) {
1208
+ if (metadata.length === 0) {
1209
+ return undefined;
1210
+ }
1211
+ return metadata.reduce((merged, next) => ({
1212
+ ...merged,
1213
+ ...next,
1214
+ alternates: mergeObject(merged.alternates, next.alternates),
1215
+ csp: mergeObject(merged.csp, next.csp),
1216
+ head: mergeArrays(merged.head, next.head),
1217
+ icons: mergeObject(merged.icons, next.icons),
1218
+ openGraph: {
1219
+ ...mergeObject(merged.openGraph, next.openGraph),
1220
+ images: mergeArrays(merged.openGraph?.images, next.openGraph?.images),
1221
+ },
1222
+ }), {});
1223
+ }
1224
+
1225
+ function mergeObject(left, right) {
1226
+ if (left === undefined) {
1227
+ return right;
1228
+ }
1229
+ if (right === undefined) {
1230
+ return left;
1231
+ }
1232
+ return { ...left, ...right };
1233
+ }
1234
+
1235
+ function mergeArrays(left, right) {
1236
+ if (left === undefined || left.length === 0) {
1237
+ return right;
1238
+ }
1239
+ if (right === undefined || right.length === 0) {
1240
+ return left;
1241
+ }
1242
+ return [...left, ...right];
1243
+ }
1244
+
1245
+ function injectCloudflareHead(html, metadata, routeHeadTags) {
1246
+ const metadataTags = routeMetadataHeadTags(metadata);
1247
+ const tags = routeHeadTags + metadataTags;
1248
+ let nextHtml = metadata?.lang === undefined ? html : injectHtmlLangAttribute(html, metadataString(metadata.lang));
1249
+
1250
+ if (metadata?.title !== undefined) {
1251
+ nextHtml = nextHtml.replace(/<title\\b[^>]*>[\\s\\S]*?<\\/title\\s*>/i, "");
1252
+ }
1253
+
1254
+ if (tags === "") {
1255
+ return nextHtml;
1256
+ }
1257
+ if (/<head(?:\\s[^>]*)?>/i.test(nextHtml)) {
1258
+ return nextHtml.replace(/<head(\\s[^>]*)?>/i, (match) => \`\${match}\${tags}\`);
1259
+ }
1260
+ if (/<html(?:\\s[^>]*)?>/i.test(nextHtml)) {
1261
+ return nextHtml.replace(/<html(\\s[^>]*)?>/i, (match) => \`\${match}<head>\${tags}</head>\`);
1262
+ }
1263
+ return \`<head>\${tags}</head>\${nextHtml}\`;
1264
+ }
1265
+
1266
+ function routeMetadataHeadTags(metadata) {
1267
+ if (metadata === undefined) {
1268
+ return "";
1269
+ }
1270
+ const tags = [
1271
+ metadata.title === undefined ? undefined : \`<title>\${escapeHtml(metadataString(metadata.title))}</title>\`,
1272
+ metadata.description === undefined ? undefined : \`<meta name="description" content="\${escapeHtmlAttribute(metadataString(metadata.description))}">\`,
1273
+ metadata.alternates?.canonical === undefined ? undefined : \`<link rel="canonical" href="\${escapeHtmlAttribute(metadataString(metadata.alternates.canonical))}">\`,
1274
+ metadata.openGraph?.title === undefined ? undefined : \`<meta property="og:title" content="\${escapeHtmlAttribute(metadataString(metadata.openGraph.title))}">\`,
1275
+ metadata.openGraph?.description === undefined ? undefined : \`<meta property="og:description" content="\${escapeHtmlAttribute(metadataString(metadata.openGraph.description))}">\`,
1276
+ ...openGraphImages(metadata.openGraph).map((image) => \`<meta property="og:image" content="\${escapeHtmlAttribute(image)}">\`),
1277
+ metadata.icons?.icon === undefined ? undefined : \`<link rel="icon" href="\${escapeHtmlAttribute(metadataString(metadata.icons.icon))}">\`,
1278
+ metadata.icons?.apple === undefined ? undefined : \`<link rel="apple-touch-icon" href="\${escapeHtmlAttribute(metadataString(metadata.icons.apple))}">\`,
1279
+ ...headDescriptorTags(metadata.head, metadata.csp?.nonce),
1280
+ ];
1281
+ return tags.filter((tag) => tag !== undefined).join("");
1282
+ }
1283
+
1284
+ function openGraphImages(openGraph) {
1285
+ if (openGraph?.images !== undefined && openGraph.images.length > 0) {
1286
+ return openGraph.images.map(metadataImageUrl);
1287
+ }
1288
+ return openGraph?.image === undefined ? [] : [metadataImageUrl(openGraph.image)];
1289
+ }
1290
+
1291
+ function headDescriptorTags(descriptors, nonce) {
1292
+ return (descriptors ?? []).flatMap((descriptor) => {
1293
+ const descriptorNonce = descriptor.nonce === true ? nonce : descriptor.nonce || undefined;
1294
+ const attrs = {
1295
+ ...descriptor.attrs,
1296
+ ...(descriptorNonce === undefined ? {} : { nonce: descriptorNonce }),
1297
+ };
1298
+ const attrText = Object.entries(attrs)
1299
+ .flatMap(([name, value]) => {
1300
+ if (value === undefined || value === false) {
1301
+ return [];
1302
+ }
1303
+ return value === true
1304
+ ? [escapeHtmlAttribute(name)]
1305
+ : [\`\${escapeHtmlAttribute(name)}="\${escapeHtmlAttribute(String(value))}"\`];
1306
+ })
1307
+ .join(" ");
1308
+ const open = attrText === "" ? \`<\${descriptor.tag}>\` : \`<\${descriptor.tag} \${attrText}>\`;
1309
+ if (descriptor.tag === "meta" || descriptor.tag === "link" || descriptor.tag === "base") {
1310
+ return [open.slice(0, -1) + ">"];
1311
+ }
1312
+ return [\`\${open}\${String(descriptor.content ?? "").replaceAll("<", "\\\\u003c")}</\${descriptor.tag}>\`];
1313
+ });
1314
+ }
1315
+
1316
+ function injectHtmlLangAttribute(html, lang) {
1317
+ const escapedLang = escapeHtmlAttribute(lang);
1318
+ if (!/<html(?:\\s[^>]*)?>/i.test(html)) {
1319
+ return html;
1320
+ }
1321
+ return html.replace(/<html(\\s[^>]*)?>/i, (_match, attrs = "") => {
1322
+ const strippedAttrs = String(attrs).replace(/\\s+lang=(?:"[^"]*"|'[^']*'|[^\\s>]+)/i, "");
1323
+ return \`<html lang="\${escapedLang}"\${strippedAttrs}>\`;
1324
+ });
1325
+ }
1326
+
1327
+ function metadataString(value) {
1328
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1329
+ return String(value);
1330
+ }
1331
+ throw new Error("Invalid metadata field: expected string, number, or boolean.");
1332
+ }
1333
+
1334
+ function metadataImageUrl(value) {
1335
+ if (typeof value === "object" && value !== null && "url" in value) {
1336
+ return metadataString(value.url);
1337
+ }
1338
+ return metadataString(value);
1339
+ }
1340
+
1131
1341
  function cloudflareRouteHeadTags(manifest, routePath) {
1132
1342
  const route = manifest.routes.find((route) => route.path === routePath);
1133
1343
  const css = route?.css ?? [];
@@ -1146,18 +1356,35 @@ function escapeHtmlAttribute(value) {
1146
1356
  .replaceAll("&", "&amp;")
1147
1357
  .replaceAll('"', "&quot;")
1148
1358
  .replaceAll("<", "&lt;");
1359
+ }
1360
+
1361
+ function escapeHtml(value) {
1362
+ return String(value ?? "")
1363
+ .replaceAll("&", "&amp;")
1364
+ .replaceAll("<", "&lt;")
1365
+ .replaceAll(">", "&gt;");
1149
1366
  }`;
1150
1367
  }
1151
1368
  async function buildCloudflareComponentExportModule(options) {
1369
+ const metadataModule = await buildCloudflareRouteMetadataExportModule({
1370
+ filename: options.filename,
1371
+ vitePlugins: options.vitePlugins,
1372
+ });
1152
1373
  const entry = `import * as routeModule from ${JSON.stringify(options.filename)};
1374
+ ${metadataModule === undefined ? "const metadataModule = {};" : 'import * as metadataModule from "mreact:metadata";'}
1153
1375
 
1154
1376
  const component = routeModule.default ?? routeModule.App ?? Object.values(routeModule).find((value) => typeof value === "function");
1155
1377
  export const App = component;
1156
1378
  export default component;
1379
+ export const generateMetadata = metadataModule.generateMetadata;
1380
+ export const metadata = metadataModule.metadata;
1157
1381
  export const slots = routeModule.slots;`;
1158
- return bundleCloudflareModule({
1382
+ return bundleCloudflareVirtualModule({
1159
1383
  entry,
1160
1384
  filename: `${options.filename}.mreact-cloudflare-${options.serverOutput}-component.js`,
1385
+ modules: metadataModule === undefined
1386
+ ? new Map()
1387
+ : new Map([["mreact:metadata", metadataModule]]),
1161
1388
  plugins: [
1162
1389
  cloudflareServerSourceTransformPlugin({
1163
1390
  projectRoot: options.projectRoot,
@@ -1167,6 +1394,7 @@ export const slots = routeModule.slots;`;
1167
1394
  cloudflareWorkspaceRuntimePlugin(),
1168
1395
  ],
1169
1396
  resolveDir: dirname(options.filename),
1397
+ vitePlugins: options.vitePlugins,
1170
1398
  });
1171
1399
  }
1172
1400
  async function buildCloudflareRouteLoaderModule(options) {
@@ -1176,6 +1404,23 @@ async function buildCloudflareRouteLoaderModule(options) {
1176
1404
  filename: `${options.filename}.mreact-cloudflare-loader.js`,
1177
1405
  plugins: [cloudflareWorkspaceRuntimePlugin()],
1178
1406
  resolveDir: dirname(options.filename),
1407
+ vitePlugins: options.vitePlugins,
1408
+ });
1409
+ }
1410
+ async function buildCloudflareRouteMetadataExportModule(options) {
1411
+ const source = await readFile(options.filename, "utf8");
1412
+ if (!hasMetadataExport(source)) {
1413
+ return undefined;
1414
+ }
1415
+ const entry = `import * as routeMetadataModule from ${JSON.stringify(options.filename)};
1416
+ export const generateMetadata = routeMetadataModule.generateMetadata;
1417
+ export const metadata = routeMetadataModule.metadata;`;
1418
+ return bundleCloudflareModule({
1419
+ entry,
1420
+ filename: `${options.filename}.mreact-cloudflare-metadata.js`,
1421
+ plugins: [cloudflareWorkspaceRuntimePlugin()],
1422
+ resolveDir: dirname(options.filename),
1423
+ vitePlugins: options.vitePlugins,
1179
1424
  });
1180
1425
  }
1181
1426
  async function buildCloudflareServerRouteModule(options) {
@@ -1196,6 +1441,7 @@ export default defaultHandler;`;
1196
1441
  filename: `${options.filename}.mreact-cloudflare-server-route.js`,
1197
1442
  plugins: [cloudflareWorkspaceRuntimePlugin()],
1198
1443
  resolveDir: dirname(options.filename),
1444
+ vitePlugins: options.vitePlugins,
1199
1445
  });
1200
1446
  }
1201
1447
  async function bundleCloudflareModule(options) {
@@ -1207,6 +1453,7 @@ async function bundleCloudflareModule(options) {
1207
1453
  preserveExports: true,
1208
1454
  plugins: options.plugins,
1209
1455
  target: "es2022",
1456
+ vitePlugins: options.vitePlugins,
1210
1457
  });
1211
1458
  const code = output.code;
1212
1459
  if (code === undefined) {
@@ -1242,6 +1489,7 @@ async function bundleCloudflareVirtualModule(options) {
1242
1489
  ...options.plugins,
1243
1490
  ],
1244
1491
  resolveDir: options.resolveDir,
1492
+ vitePlugins: options.vitePlugins,
1245
1493
  });
1246
1494
  }
1247
1495
  async function cloudflareShellFilesForPage(routesDir, pageFile) {
@@ -1337,6 +1585,7 @@ function cloudflareWorkspaceRuntimePlugin() {
1337
1585
  });
1338
1586
  const routerCachePath = packageFile("router", "@reckona/mreact-router", "cache");
1339
1587
  const routerCookiesPath = packageFile("router", "@reckona/mreact-router", "cookies");
1588
+ const routerCsrfPath = packageFile("router", "@reckona/mreact-router", "csrf");
1340
1589
  const routerDeferredPath = packageFile("router", "@reckona/mreact-router", "deferred");
1341
1590
  const routerI18nPath = packageFile("router", "@reckona/mreact-router", "i18n");
1342
1591
  const routerLinkPath = packageFile("router", "@reckona/mreact-router", "link");
@@ -1403,6 +1652,7 @@ export function escapeHtmlBatch(values) {
1403
1652
  buildApi.onLoad({ filter: /^index$/, namespace: "mreact-cloudflare-router-index" }, () => ({
1404
1653
  contents: `export { cacheControl, revalidatePath } from ${JSON.stringify(routerCachePath)};
1405
1654
  export { deleteCookie, parseCookieHeader, serializeCookie, setCookie } from ${JSON.stringify(routerCookiesPath)};
1655
+ export { createFormCsrfToken, formCsrfCookie, formCsrfFieldName, serverActionCookie, validateFormCsrf } from ${JSON.stringify(routerCsrfPath)};
1406
1656
  export { defer, isDeferredLoaderData } from ${JSON.stringify(routerDeferredPath)};
1407
1657
  export { defineMessages, detectLocale } from ${JSON.stringify(routerI18nPath)};
1408
1658
  export { Link, linkProps } from ${JSON.stringify(routerLinkPath)};
@@ -1420,7 +1670,8 @@ export { cookies, headers, html, json, next, notFound, redirect, redirectExterna
1420
1670
  };
1421
1671
  }
1422
1672
  function cloudflareRouteRequiresGeneratedModule(route, prerenderedRoutes) {
1423
- return (route.kind === "server" ||
1673
+ return (route.kind === "metadata" ||
1674
+ route.kind === "server" ||
1424
1675
  (route.kind === "page" &&
1425
1676
  (route.segments.some((segment) => segment.kind !== "static") ||
1426
1677
  prerenderedRoutes[route.path] === undefined)));
@@ -1455,6 +1706,7 @@ async function writeClientRouteBundle(options) {
1455
1706
  pageFile: route.file,
1456
1707
  projectRoot: options.projectRoot,
1457
1708
  routeId: routeIdForPath(route.path),
1709
+ vitePlugins: options.vitePlugins,
1458
1710
  });
1459
1711
  const source = await readFile(route.file, "utf8");
1460
1712
  const clientSource = stripRouteClientOnlyExports(source);
@@ -1545,6 +1797,7 @@ async function writeRouteCssAssets(options) {
1545
1797
  filename: options.pageFile,
1546
1798
  minify: true,
1547
1799
  platform: "browser",
1800
+ vitePlugins: options.vitePlugins,
1548
1801
  });
1549
1802
  const cssAssets = (output.assets ?? []).filter((asset) => asset.fileName.endsWith(".css"));
1550
1803
  const written = [];