@k8ordo/framework 0.1.0 → 1.0.0

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 CHANGED
@@ -154,7 +154,10 @@ error.tsx, loading.tsx, fallback.tsx, redirect.ts, guard.ts and route.ts;
154
154
  components go in `src/components/` and every other module (Server Actions,
155
155
  schemas, state definitions, data, helpers) in `src/lib/`, since a
156
156
  `_`-prefixed directory there is a URL segment, not a private one. Never
157
- edit `.k8ordo/` — it is generated. Import from `@k8ordo/framework` (never
157
+ edit `.k8ordo/` — it is generated, with the `Register` augmentations: the
158
+ route table's, and `@k8ordo/i18n`'s when `src/i18n.ts` exports the locale
159
+ set by the name `locales` (write that one by hand only when the set lives
160
+ elsewhere). Import from `@k8ordo/framework` (never
158
161
  `@k8ordo/router`), and the request API from `@k8ordo/framework/server`,
159
162
  which `mode: 'static'` refuses. Build links with `href()`; search params
160
163
  are `@k8ordo/state`'s.
@@ -1,6 +1,10 @@
1
+ import { Payload } from "./payload.mjs";
1
2
  import { ReactNode } from "react";
2
3
  //#region src/runtime/app-router.d.ts
3
4
  export declare const setDocumentClient: (client: string) => void;
5
+ /** What a Server Action's answer does to the document, once it is read. */
6
+ export declare const answerAction: (payload: Payload) => Promise<unknown>;
7
+ export declare const setPayloadReader: (read: (stream: ReadableStream<Uint8Array>) => Promise<Payload>) => void;
4
8
  /**
5
9
  * The client half under the framework: the tree comes from the server, so
6
10
  * there is no route table in the browser at all — only navigation. The
@@ -1,21 +1,19 @@
1
1
  "use client";
2
- import { ACTION_ID_HEADER } from "./payload.mjs";
3
2
  import { payloadPathFor } from "./payload-path.mjs";
4
3
  import { ShowNotFound } from "./fallback-boundary.mjs";
5
4
  import { isPayload } from "./is-payload.mjs";
6
5
  import { createPrefetchCache, prefetchTargetOf } from "./prefetch.mjs";
7
6
  import { Recover, markNavigated, reloadInstead } from "./recover.mjs";
8
7
  import { NavigationGeneration, PathnameProvider, useInterceptedNavigation, withoutBase } from "@k8ordo/router";
9
- import { createFromFetch, createFromReadableStream, createTemporaryReferenceSet, encodeReply, setServerCallback } from "@vitejs/plugin-rsc/browser";
10
8
  import { startTransition, useDeferredValue, useEffect, useRef, useState } from "react";
11
9
  import { jsx } from "react/jsx-runtime";
12
10
  //#region src/runtime/app-router.tsx
13
11
  /**
14
12
  * Where a Server Action's answer lands. The callback has to be registered
15
13
  * before any client component can call one, which is earlier than a component
16
- * can offer its own setter — so the wiring is here and the mounted router
17
- * lends it a way to apply what comes back, and to drop the pages it fetched
18
- * ahead, which the action may have changed.
14
+ * can offer its own setter — so `answerAction` is module-level and the
15
+ * mounted router lends it a way to apply what comes back, and to drop the
16
+ * pages it fetched ahead, which the action may have changed.
19
17
  */
20
18
  let mounted = null;
21
19
  /**
@@ -36,13 +34,8 @@ const setDocumentClient = (client) => {
36
34
  * document load brings the script that can.
37
35
  */
38
36
  const canRender = (payload) => payload.client === documentClient;
39
- setServerCallback(async (id, args) => {
40
- const temporaryReferences = createTemporaryReferenceSet();
41
- const payload = await createFromFetch(fetch(location.href, {
42
- method: "POST",
43
- headers: { [ACTION_ID_HEADER]: id },
44
- body: await encodeReply(args, { temporaryReferences })
45
- }), { temporaryReferences });
37
+ /** What a Server Action's answer does to the document, once it is read. */
38
+ const answerAction = async (payload) => {
46
39
  mounted?.forgetPrefetched();
47
40
  if (payload.redirect !== void 0) {
48
41
  await navigation.navigate(payload.redirect).finished;
@@ -51,7 +44,11 @@ setServerCallback(async (id, args) => {
51
44
  if (!canRender(payload)) return reloadInstead();
52
45
  mounted?.apply(payload);
53
46
  return payload.returnValue;
54
- });
47
+ };
48
+ let readPayload = () => Promise.reject(/* @__PURE__ */ new Error("k8ordo: payloads are read in the browser only"));
49
+ const setPayloadReader = (read) => {
50
+ readPayload = read;
51
+ };
55
52
  /**
56
53
  * The request each fetched payload is still streaming in on. A payload
57
54
  * resolves once its top arrives, and a page that awaits its data arrives
@@ -77,7 +74,7 @@ const fetchPage = async (payloadPath, signal) => {
77
74
  signal.addEventListener("abort", cancel, { once: true });
78
75
  const response = await fetch(payloadPath, { signal: request.signal });
79
76
  if (!isPayload(response)) return null;
80
- const payload = await createFromReadableStream(response.body);
77
+ const payload = await readPayload(response.body);
81
78
  streams.set(payload, {
82
79
  cancel: () => {
83
80
  request.abort();
@@ -272,4 +269,4 @@ function AppRouter({ pathname, search, tree, notFound }) {
272
269
  });
273
270
  }
274
271
  //#endregion
275
- export { AppRouter, setDocumentClient };
272
+ export { AppRouter, answerAction, setDocumentClient, setPayloadReader };
@@ -0,0 +1,8 @@
1
+ //#region src/runtime/browser-payloads.d.ts
2
+ /**
3
+ * Hands app-router the browser's Flight client: how a fetched payload is
4
+ * read, and where a Server Action is sent. Called by the browser entry
5
+ * before anything renders.
6
+ */
7
+ export declare const connectAppRouter: () => void;
8
+ //#endregion
@@ -0,0 +1,23 @@
1
+ import { ACTION_ID_HEADER } from "./payload.mjs";
2
+ import { answerAction, setPayloadReader } from "./app-router.mjs";
3
+ import { createFromFetch, createFromReadableStream, createTemporaryReferenceSet, encodeReply, setServerCallback } from "@vitejs/plugin-rsc/browser";
4
+ //#region src/runtime/browser-payloads.ts
5
+ /**
6
+ * Hands app-router the browser's Flight client: how a fetched payload is
7
+ * read, and where a Server Action is sent. Called by the browser entry
8
+ * before anything renders.
9
+ */
10
+ const connectAppRouter = () => {
11
+ setPayloadReader((stream) => createFromReadableStream(stream));
12
+ setServerCallback(async (id, args) => {
13
+ const temporaryReferences = createTemporaryReferenceSet();
14
+ const payload = await createFromFetch(fetch(location.href, {
15
+ method: "POST",
16
+ headers: { [ACTION_ID_HEADER]: id },
17
+ body: await encodeReply(args, { temporaryReferences })
18
+ }), { temporaryReferences });
19
+ return answerAction(payload);
20
+ });
21
+ };
22
+ //#endregion
23
+ export { connectAppRouter };
@@ -1,11 +1,13 @@
1
1
  import { AppRouter, setDocumentClient } from "./app-router.mjs";
2
+ import { connectAppRouter } from "./browser-payloads.mjs";
2
3
  import { mount } from "./mount.mjs";
3
4
  import { reportCaught } from "./page-boundary.mjs";
4
5
  import { whenRevealed } from "./revealed.mjs";
5
- import { createFromReadableStream } from "@vitejs/plugin-rsc/browser";
6
6
  import { jsx } from "react/jsx-runtime";
7
+ import { createFromReadableStream } from "@vitejs/plugin-rsc/browser";
7
8
  import { rscStream } from "rsc-html-stream/client";
8
9
  //#region src/runtime/entry.browser.tsx
10
+ connectAppRouter();
9
11
  const payload = await createFromReadableStream(rscStream);
10
12
  setDocumentClient(payload.client);
11
13
  await whenRevealed();
package/dist/serve.mjs CHANGED
@@ -120,7 +120,7 @@ const sendAnswer = async (incoming, response, result) => {
120
120
  const encoding = compressible ? negotiateEncoding(incoming.headers["accept-encoding"], ENCODINGS) : null;
121
121
  if (compressible) {
122
122
  const vary = result.headers.get("vary");
123
- headers["vary"] = vary === null ? "Accept-Encoding" : `${vary}, Accept-Encoding`;
123
+ headers.vary = vary === null ? "Accept-Encoding" : `${vary}, Accept-Encoding`;
124
124
  }
125
125
  if (encoding !== null) {
126
126
  headers["content-encoding"] = encoding;
package/dist/vite.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { _ as pagePathFor, a as cloudflareCounts, b as NOT_FOUND_SEGMENT, c as rewriteFor, d as ENCODINGS, f as SUFFIX, g as isPayloadPath, i as builtRules, l as rulesOf, n as resolveStatic, o as cloudflareMatches, p as isCompressible, s as formatRedirects, t as readRules, u as shellRules, v as payloadPathFor, x as decodePathname, y as FALLBACK_SEGMENT } from "./static-host-dgtttTuZ.mjs";
2
2
  import { t as sharedDir } from "./shared-dir-CkF4QCWc.mjs";
3
+ import { findPackageJSON } from "node:module";
3
4
  import { normalizePathname, withBase, withoutBase } from "@k8ordo/router";
4
5
  import { fileURLToPath, pathToFileURL } from "node:url";
5
6
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
@@ -412,7 +413,10 @@ const ANSWERED = "answered";
412
413
  */
413
414
  const GENERATED_FROM = `${FRAMEWORK}/generated`;
414
415
  const ROUTE_REQUEST_FROM = `${FRAMEWORK}/server`;
415
- const BANNER = `// Generated by ${FRAMEWORK} from routes/. Do not edit.`;
416
+ const I18N = "@k8ordo/i18n";
417
+ /** Where an application declares its locale set, from its root. */
418
+ const LOCALES_MODULE = "src/i18n.ts";
419
+ const bannerFrom = (...sources) => `// Generated by ${FRAMEWORK} from ${sources.join(" and ")}. Do not edit.`;
416
420
  const sanitize = (path) => {
417
421
  const base = path.replace(/\.[jt]sx?$/u, "").replaceAll(/[^A-Za-z0-9]+/gu, "_").replaceAll(/^_+|_+$/gu, "");
418
422
  return base === "" ? "route" : base;
@@ -610,7 +614,7 @@ const emitRoutesModule = (tree, options) => {
610
614
  "ParsedParams"
611
615
  ];
612
616
  return [
613
- BANNER,
617
+ bannerFrom("routes/"),
614
618
  "",
615
619
  `import { defineRoutes } from '${GENERATED_FROM}';`,
616
620
  ...searchReaders.length > 0 ? [`import { urlReader } from '@k8ordo/state';`] : [],
@@ -777,17 +781,22 @@ const namerFile = (namer, name) => {
777
781
  * Both packages read the app's route table through declaration merging, so
778
782
  * the app gets typed paths everywhere without writing the ceremony itself.
779
783
  * The router's `Register` is augmented through the framework's re-export of
780
- * it, which TypeScript merges into the router's own declaration.
784
+ * it, which TypeScript merges into the router's own declaration. The locale
785
+ * set reaches `@k8ordo/i18n`'s `Register` the same way, so every message is
786
+ * held to it.
781
787
  */
782
788
  const emitRegisterModule = (options) => {
783
789
  const wantsState = options.stateModule !== null && options.stateModule !== void 0;
790
+ const localesModule = options.localesModule ?? null;
784
791
  const withRequest = options.mode === "server";
785
792
  const lines = [
786
- BANNER,
793
+ localesModule === null ? bannerFrom("routes/") : bannerFrom("routes/", LOCALES_MODULE),
787
794
  "",
788
795
  `import type { ParsedParamsMap } from '${GENERATED_FROM}';`,
789
796
  ...withRequest ? [`import type { RouteRequest } from '${ROUTE_REQUEST_FROM}';`] : [],
797
+ ...localesModule === null ? [] : [`import type { LocaleOf } from '${I18N}';`],
790
798
  ...wantsState ? [`import type {} from '${options.stateModule}';`] : [],
799
+ ...localesModule === null ? [] : [`import type { locales } from '${localesModule}';`],
791
800
  `import type { paramSchemas, routes, searchReaders } from '${options.routesModule}';`,
792
801
  "",
793
802
  `declare module '${GENERATED_FROM}' {`,
@@ -806,6 +815,7 @@ const emitRegisterModule = (options) => {
806
815
  "}"
807
816
  ];
808
817
  if (wantsState) lines.push("", `declare module '${options.stateModule}' {`, " interface Register {", " routes: typeof routes;", " }", "}");
818
+ if (localesModule !== null) lines.push("", `declare module '${I18N}' {`, " interface Register {", " locale: LocaleOf<typeof locales>;", " }", "}");
809
819
  lines.push("");
810
820
  return lines.join("\n");
811
821
  };
@@ -855,12 +865,13 @@ const scanRoutes = async (dir) => {
855
865
  * person reads the file: `export const paramsSchema`, `export {
856
866
  * paramsSchema }`, and a destructured `export const { paramsSchema } =
857
867
  * locales` are all an export; the same words inside a string or a comment
858
- * are not, and `export type paramsSchema` is a type. A file that does not
859
- * parse exports nothing; the build reports the syntax error itself, where it
860
- * can name the line.
868
+ * are not, and `export type paramsSchema` is a type. `file` is the module's
869
+ * name, whose extension says whether it is TSX. A file that does not parse
870
+ * exports nothing; the build reports the syntax error itself, where it can
871
+ * name the line.
861
872
  */
862
- const exportsOf = (source) => {
863
- const { errors, module } = parseSync("route.tsx", source, { sourceType: "module" });
873
+ const exportsOf = (source, file) => {
874
+ const { errors, module } = parseSync(file, source, { sourceType: "module" });
864
875
  if (errors.length > 0) return /* @__PURE__ */ new Set();
865
876
  return new Set(module.staticExports.flatMap((statement) => statement.entries.filter((entry) => !entry.isType).map((entry) => entry.exportName.kind === "None" ? "*" : entry.exportName.name ?? "default")));
866
877
  };
@@ -876,7 +887,7 @@ const READS_EXPORTS = /* @__PURE__ */ new Set([
876
887
  */
877
888
  const readExports = async (routesDir, files) => new Map(await Promise.all(files.filter((file) => READS_EXPORTS.has(slotOf(file) ?? "")).map(async (file) => {
878
889
  try {
879
- return [file, exportsOf(await readFile(path.join(routesDir, file), "utf8"))];
890
+ return [file, exportsOf(await readFile(path.join(routesDir, file), "utf8"), file)];
880
891
  } catch {
881
892
  return [file, /* @__PURE__ */ new Set()];
882
893
  }
@@ -900,15 +911,41 @@ const silentRoutes = (exported) => [...exported].filter(([file, names]) => slotO
900
911
  path: file,
901
912
  message: `exports none of ${ROUTE_METHODS.join(", ")} — a route.ts answers the methods it exports`
902
913
  }));
903
- const dependsOnState = async (root) => {
914
+ /** Whether the application's manifest lists `name`. */
915
+ const dependsOn = async (root, name) => {
904
916
  try {
905
917
  const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
906
- return "@k8ordo/state" in (manifest.dependencies ?? {}) || "@k8ordo/state" in (manifest.devDependencies ?? {});
918
+ return name in (manifest.dependencies ?? {}) || name in (manifest.devDependencies ?? {});
907
919
  } catch {
908
920
  return false;
909
921
  }
910
922
  };
911
923
  /**
924
+ * Where the application declares its locale set: `null` when it does not
925
+ * depend on `@k8ordo/i18n`, the package does not resolve from the root, or
926
+ * there is no {@link LOCALES_MODULE}.
927
+ */
928
+ const localeSetOf = async (root) => {
929
+ if (!await dependsOn(root, "@k8ordo/i18n")) return null;
930
+ const module = path.join(root, LOCALES_MODULE);
931
+ let source;
932
+ try {
933
+ findPackageJSON("@k8ordo/i18n", module);
934
+ source = await readFile(module, "utf8");
935
+ } catch {
936
+ return null;
937
+ }
938
+ return {
939
+ module,
940
+ exportsLocales: exportsOf(source, module).has("locales")
941
+ };
942
+ };
943
+ /**
944
+ * Without the generated `Register`, `@k8ordo/i18n` holds a message to no
945
+ * locale set, and nothing else would say so.
946
+ */
947
+ const LOCALES_UNEXPORTED = `${LOCALES_MODULE} does not export \`locales\` by name (\`export *\` and a type-only export do not count), so @k8ordo/i18n's Register is not generated and a message missing a locale compiles — export the locale set from it as \`locales\``;
948
+ /**
912
949
  * Writes what the application would otherwise hand-write: the route table
913
950
  * and the type wiring. Nothing here decides anything the grammar has not
914
951
  * already decided — problems come back for the caller to report.
@@ -917,7 +954,7 @@ const generate = async (options) => {
917
954
  const files = await scanRoutes(options.routesDir);
918
955
  const parsed = parseRouteTree(files);
919
956
  const exported = await readExports(options.routesDir, files);
920
- const withState = await dependsOnState(options.root);
957
+ const withState = await dependsOn(options.root, "@k8ordo/state");
921
958
  const withSearch = pagesReadingSearch(exported);
922
959
  const problems = [
923
960
  ...parsed.problems,
@@ -928,11 +965,13 @@ const generate = async (options) => {
928
965
  const { tree } = parsed;
929
966
  if (problems.length > 0) return {
930
967
  problems,
968
+ warnings: [],
931
969
  routesModule: path.join(options.outDir, "routes.gen.ts")
932
970
  };
933
- const toRoutes = path.relative(options.outDir, options.routesDir).replaceAll(path.sep, "/");
971
+ const toRoutes = importPath(options.outDir, options.routesDir);
972
+ const localeSet = await localeSetOf(options.root);
934
973
  const routesSource = emitRoutesModule(tree, {
935
- importPrefix: toRoutes.startsWith(".") ? toRoutes : `./${toRoutes}`,
974
+ importPrefix: toRoutes,
936
975
  mode: options.mode,
937
976
  withParams: new Set([...exported].filter(([, names]) => names.has("paramsSchema")).map(([file]) => file)),
938
977
  withSearch
@@ -940,6 +979,7 @@ const generate = async (options) => {
940
979
  const registerSource = emitRegisterModule({
941
980
  routesModule: "./routes.gen",
942
981
  stateModule: withState ? "@k8ordo/state" : null,
982
+ localesModule: localeSet?.exportsLocales === true ? importPath(options.outDir, localeSet.module.replace(/\.ts$/u, "")) : null,
943
983
  mode: options.mode
944
984
  });
945
985
  await mkdir(options.outDir, { recursive: true });
@@ -949,9 +989,15 @@ const generate = async (options) => {
949
989
  await writeIfChanged(path.join(options.outDir, ".gitignore"), `# Generated by ${FRAMEWORK}.\n*\n`);
950
990
  return {
951
991
  problems,
992
+ warnings: localeSet?.exportsLocales === false ? [LOCALES_UNEXPORTED] : [],
952
993
  routesModule
953
994
  };
954
995
  };
996
+ /** The relative specifier the generated files import `to` by, from `from`. */
997
+ const importPath = (from, to) => {
998
+ const relative = path.relative(from, to).replaceAll(path.sep, "/");
999
+ return relative.startsWith(".") ? relative : `./${relative}`;
1000
+ };
955
1001
  /** Rewriting an unchanged file would restart HMR for no reason. */
956
1002
  const writeIfChanged = async (file, content) => {
957
1003
  try {
@@ -971,7 +1017,7 @@ const SERVER_REFERENCES_ONLY = /dynamically imported by \S*virtual:vite-rsc\/ser
971
1017
  const OUT_DIR = ".k8ordo";
972
1018
  const ESM_ENTRY = "[name].js";
973
1019
  /** Vite's own answer, so nothing downstream can disagree with it. */
974
- const isProduction = (mode) => (process.env["NODE_ENV"] ?? process.env["VITE_USER_NODE_ENV"] ?? mode) === "production";
1020
+ const isProduction = (mode) => (process.env.NODE_ENV ?? process.env.VITE_USER_NODE_ENV ?? mode) === "production";
975
1021
  /**
976
1022
  * Client components reach the browser as RSC references, discovered while
977
1023
  * rendering rather than by crawling the client entry — so React itself is
@@ -1019,6 +1065,7 @@ const FAMILY = /^@k8ordo\//u;
1019
1065
  const engine = (options, host) => {
1020
1066
  let root = "";
1021
1067
  let routesDir = "";
1068
+ let localesModule = "";
1022
1069
  let outDir = "";
1023
1070
  let logger;
1024
1071
  let devServer;
@@ -1084,6 +1131,7 @@ const engine = (options, host) => {
1084
1131
  if (!config.base.startsWith("/") || config.base.startsWith("//")) throw new Error(`k8ordo serves its pages under Vite's base, so base has to be a path from the root, like '/docs/' — got '${config.base}'`);
1085
1132
  ({root, logger} = config);
1086
1133
  routesDir = path.resolve(root, options.routesDir ?? "src/routes");
1134
+ localesModule = path.resolve(root, LOCALES_MODULE);
1087
1135
  outDir = path.resolve(root, OUT_DIR);
1088
1136
  },
1089
1137
  buildApp: {
@@ -1101,7 +1149,8 @@ const engine = (options, host) => {
1101
1149
  }
1102
1150
  },
1103
1151
  async buildStart() {
1104
- const { problems } = await generate(generateOptions());
1152
+ const { problems, warnings } = await generate(generateOptions());
1153
+ for (const warning of warnings) logger.warnOnce(`k8ordo: ${warning}`);
1105
1154
  if (problems.length > 0) this.error(`routes/ is not a valid pathname space:\n${problems.map((problem) => ` routes/${problem.path}: ${problem.message}`).join("\n")}`);
1106
1155
  },
1107
1156
  configureServer(server) {
@@ -1109,11 +1158,14 @@ const engine = (options, host) => {
1109
1158
  devServer = server;
1110
1159
  },
1111
1160
  async watchChange(file) {
1112
- if (!path.resolve(file).startsWith(`${routesDir}${path.sep}`)) return;
1161
+ const changed = path.resolve(file);
1162
+ if (!changed.startsWith(`${routesDir}${path.sep}`) && changed !== localesModule) return;
1113
1163
  const run = generating.then(() => generate(generateOptions()));
1114
1164
  generating = run.then(() => void 0, () => void 0);
1115
- const { problems } = await run;
1165
+ const { problems, warnings } = await run;
1116
1166
  for (const problem of problems) logger.error(`routes/${problem.path}: ${problem.message}`);
1167
+ for (const warning of warnings) logger.warnOnce(`k8ordo: ${warning}`);
1168
+ if (changed === localesModule) return;
1117
1169
  for (const environment of Object.values(devServer?.environments ?? {})) {
1118
1170
  const { moduleGraph } = environment;
1119
1171
  for (const module of moduleGraph.getModulesByFile(routesModule()) ?? []) moduleGraph.invalidateModule(module);
@@ -1267,7 +1319,7 @@ const serverMode = () => ({
1267
1319
  order: "post",
1268
1320
  async handler(builder) {
1269
1321
  const { root, logger } = builder.config;
1270
- const clientOut = builder.environments["client"]?.config.build.outDir;
1322
+ const clientOut = builder.environments.client?.config.build.outDir;
1271
1323
  if (clientOut === void 0) throw new Error("server build ran without the client build");
1272
1324
  const client = path.resolve(root, clientOut);
1273
1325
  const count = await precompress(client);
@@ -1928,7 +1980,7 @@ const staticMode = (options) => {
1928
1980
  },
1929
1981
  async configurePreviewServer(server) {
1930
1982
  const { config } = server;
1931
- const client = path.resolve(config.root, config.environments["client"]?.build.outDir ?? "dist/client");
1983
+ const client = path.resolve(config.root, config.environments.client?.build.outDir ?? "dist/client");
1932
1984
  const rules = await readRules(client);
1933
1985
  const answeredHere = async (request, response) => {
1934
1986
  const url = new URL(`${ORIGIN}${request.url ?? "/"}`);
@@ -1990,7 +2042,7 @@ const staticMode = (options) => {
1990
2042
  const file = path.relative(routesDir, module).split(path.sep).join("/");
1991
2043
  const slot = file.startsWith("..") ? null : slotOf(file);
1992
2044
  if (slot === "guard") throw new Error(guardRefusal([path.relative(root, module)]));
1993
- const names = READ_BY_NAME.has(slot ?? "") ? exportsOf(await readFile(module, "utf8")) : /* @__PURE__ */ new Set();
2045
+ const names = READ_BY_NAME.has(slot ?? "") ? exportsOf(await readFile(module, "utf8"), file) : /* @__PURE__ */ new Set();
1994
2046
  if (names.has("*")) throw new Error(reexportRefusal([path.relative(root, module)]));
1995
2047
  const methods = slot === "route" ? unwritable(names) : [];
1996
2048
  if (methods.length > 0) throw new Error(routeRefusal([[path.relative(root, module), methods]]));
@@ -2012,8 +2064,8 @@ const staticMode = (options) => {
2012
2064
  const importers = [...serverImporters].toSorted();
2013
2065
  const unanswerable = [...actions.length > 0 ? [actionRefusal(actions)] : [], ...importers.length > 0 ? [serverImportRefusal(importers)] : []];
2014
2066
  if (unanswerable.length > 0) throw new Error(unanswerable.join("\n\n"));
2015
- const rscOut = builder.environments["rsc"]?.config.build.outDir;
2016
- const client = builder.environments["client"]?.config;
2067
+ const rscOut = builder.environments.rsc?.config.build.outDir;
2068
+ const client = builder.environments.client?.config;
2017
2069
  if (rscOut === void 0 || client === void 0) throw new Error("static build ran without the rsc/client builds");
2018
2070
  const clientDir = path.resolve(root, client.build.outDir);
2019
2071
  const origin = options.site === void 0 ? ORIGIN : new URL(options.site).origin;
@@ -2103,7 +2155,7 @@ const staticMode = (options) => {
2103
2155
  rewrites,
2104
2156
  notFound: unmatched !== null
2105
2157
  });
2106
- else if (vercel === void 0 && process.env["VERCEL"] !== void 0 && plan.shells.length > 0) builder.config.logger.warn("k8ordo: this build runs on Vercel, which does not read _redirects — add vercel() from @k8ordo/framework/vercel, or the shells are never served");
2158
+ else if (vercel === void 0 && process.env.VERCEL !== void 0 && plan.shells.length > 0) builder.config.logger.warn("k8ordo: this build runs on Vercel, which does not read _redirects — add vercel() from @k8ordo/framework/vercel, or the shells are never served");
2107
2159
  const shells = plan.shells.length === 0 ? "" : `, ${String(plan.shells.length)} ${plan.shells.length === 1 ? "shell" : "shells"} (${plan.shells.map((shell) => shell.location).join(", ")})`;
2108
2160
  builder.config.logger.info(`k8ordo: wrote ${String(plan.paths.length)} routes${shells}${unmatched === null ? "" : " and 404.html"}${options.site === void 0 ? "" : " and sitemap.xml"}${plan.shells.length === 0 ? "" : " and _redirects"}`);
2109
2161
  }
package/docs/GUIDE.md CHANGED
@@ -133,10 +133,13 @@ export default defineConfig({ plugins: [framework({ mode: 'static' })] });
133
133
  // or framework({ mode: 'server' })
134
134
  ```
135
135
 
136
- The framework writes the route table and its types into `.k8ordo/`. Add it
137
- to `include` in `tsconfig.json` as a glob, `.k8ordo/**/*.ts` (an entry
138
- naming only the dot-directory leaves its files out), and `href()`'s paths
139
- and a page's `params` are typed. The generated files import without file
136
+ The framework writes the route table and its types into `.k8ordo/`,
137
+ including the `Register` augmentations an application would otherwise write
138
+ by hand — the table's, and `@k8ordo/i18n`'s when `src/i18n.ts` exports the
139
+ locale set by the name `locales`. Add it to `include` in `tsconfig.json` as
140
+ a glob, `.k8ordo/**/*.ts` (an entry naming only the dot-directory leaves its
141
+ files out), and `href()`'s paths and a page's `params` are typed, and with
142
+ that export every message's locales. The generated files import without file
140
143
  extensions, so they resolve under `moduleResolution: "bundler"`, as Vite
141
144
  does. A fresh clone has no `.k8ordo/` until `vite build` or `vite dev`
142
145
  writes it, so CI runs the build before `tsc`
package/docs/llms.txt CHANGED
@@ -59,7 +59,9 @@ Start with GUIDE.md, then follow the reference most relevant to the task.
59
59
  - [Routing](references/routing.md): the `routes/` grammar and every refusal
60
60
  the build names, where a `fallback.tsx` may sit, how a page is sent in
61
61
  each mode, `loading.tsx`, `route.ts` (methods and `405` under server
62
- mode, a file under static mode), the generated `.k8ordo/` files, titles,
62
+ mode, a file under static mode), the generated `.k8ordo/` files (the
63
+ route table, and the `Register`s: the table's, and `@k8ordo/i18n`'s when
64
+ `src/i18n.ts` exports `locales` by name), titles,
63
65
  and fetching the next page ahead (`data-k8ordo-prefetch`).
64
66
  - [Parameters](references/params.md): `paramsSchema` — a refused value is a
65
67
  pathname the pattern does not answer — and, under static mode, the `paths`
@@ -465,8 +465,8 @@ on evil.test would pass the check.
465
465
 
466
466
  ```ts
467
467
  // vite.config.ts
468
- import { framework } from '@k8ordo/framework/vite';
469
468
  import { vercel } from '@k8ordo/framework/vercel';
469
+ import { framework } from '@k8ordo/framework/vite';
470
470
  import { defineConfig } from 'vite';
471
471
 
472
472
  export default defineConfig({
@@ -311,10 +311,10 @@ import { defineRoutes } from '@k8ordo/framework/generated';
311
311
  import type { ParamsOf, ParsedParams } from '@k8ordo/framework/generated';
312
312
  import type { ComponentType, ReactNode } from 'react';
313
313
 
314
+ import layout from '../src/routes/layout';
314
315
  import page from '../src/routes/page';
315
- import products_page from '../src/routes/products/page';
316
316
  import products_id_page from '../src/routes/products/[id]/page';
317
- import layout from '../src/routes/layout';
317
+ import products_page from '../src/routes/products/page';
318
318
 
319
319
  type Page<P extends string, S extends readonly unknown[] = []> = ComponentType<{
320
320
  params: ParsedParams<P, S>;
@@ -384,6 +384,35 @@ import { href } from '@k8ordo/framework';
384
384
  <a href={href('/products/:id', { id })}>…</a>; // checked against routes/
385
385
  ```
386
386
 
387
+ It registers the locale set with `@k8ordo/i18n` too, when the application
388
+ has one: its `package.json` lists `@k8ordo/i18n` (in `dependencies` or
389
+ `devDependencies`), and `src/i18n.ts` exports the set as `locales`. That is
390
+ the `Register` an application outside the framework writes by hand, so here
391
+ it writes none, and a message missing a locale does not compile:
392
+
393
+ ```ts
394
+ // .k8ordo/register.gen.ts (the part for @k8ordo/i18n)
395
+ import type { LocaleOf } from '@k8ordo/i18n';
396
+
397
+ import type { locales } from '../src/i18n';
398
+
399
+ declare module '@k8ordo/i18n' {
400
+ interface Register {
401
+ locale: LocaleOf<typeof locales>;
402
+ }
403
+ }
404
+ ```
405
+
406
+ The export is read from the file's syntax, as a route file's are, and never
407
+ by running it: `export const locales = defineLocales(…)` and
408
+ `export { locales } from './locales'` count, a type-only export and
409
+ `export *` do not. A `src/i18n.ts` without the export gets nothing
410
+ generated and a warning naming it, since every message would compile
411
+ unchecked; an application that keeps its set elsewhere writes the
412
+ `Register` by hand. `vite dev` writes the file again as `src/i18n.ts` comes,
413
+ goes, or gains or loses the export. A hand-written augmentation left in the
414
+ application still compiles beside it, since both declare the same type.
415
+
387
416
  Neither file names `@k8ordo/router`: they import from
388
417
  `@k8ordo/framework/generated`, so the application's code, generated or not,
389
418
  reads only the framework.
@@ -397,7 +426,8 @@ vite build && tsc --noEmit
397
426
  ```
398
427
 
399
428
  Without the files, `tsc` checks against no table: `href` takes any string,
400
- params typed by a `paramsSchema` are strings, and under `mode: 'server'` the
429
+ params typed by a `paramsSchema` are strings, a message missing a locale
430
+ compiles, and under `mode: 'server'` the
401
431
  `request` and `search` props are not in `PageProps` or `LayoutProps`, so a
402
432
  page that reads them fails with `Property 'request' does not exist`. That is
403
433
  the error static mode gives on purpose, but here the mode is not the cause.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k8ordo/framework",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "The k8ordo framework: routes/ compiled into React Server Components, run with or without a server.",
5
5
  "keywords": [
6
6
  "k8ordo",
@@ -61,8 +61,8 @@
61
61
  "registry": "https://registry.npmjs.org"
62
62
  },
63
63
  "dependencies": {
64
- "@vitejs/plugin-react": "6.1.1",
65
- "@vitejs/plugin-rsc": "0.5.35",
64
+ "@vitejs/plugin-react": "6.1.2",
65
+ "@vitejs/plugin-rsc": "0.5.36",
66
66
  "compressible": "2.0.18",
67
67
  "fresh": "2.0.0",
68
68
  "mime-types": "3.0.2",
@@ -79,13 +79,13 @@
79
79
  "@types/mime-types": "3.0.1",
80
80
  "@types/range-parser": "1.2.7",
81
81
  "@types/react": "19.3.0",
82
- "publint": "0.3.24",
82
+ "publint": "0.3.25",
83
83
  "react": "19.3.0",
84
84
  "react-dom": "19.3.0",
85
85
  "typescript": "7.0.2",
86
- "vite": "npm:@voidzero-dev/vite-plus-core@0.3.2",
87
- "vite-plus": "0.3.2",
88
- "vitest": "5.0.2"
86
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
87
+ "vite-plus": "1.1.0",
88
+ "vitest": "5.0.3"
89
89
  },
90
90
  "peerDependencies": {
91
91
  "@k8ordo/router": "^1.0.0",