@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 +4 -1
- package/dist/runtime/app-router.d.mts +4 -0
- package/dist/runtime/app-router.mjs +12 -15
- package/dist/runtime/browser-payloads.d.mts +8 -0
- package/dist/runtime/browser-payloads.mjs +23 -0
- package/dist/runtime/entry.browser.mjs +3 -1
- package/dist/serve.mjs +1 -1
- package/dist/vite.mjs +77 -25
- package/docs/GUIDE.md +7 -4
- package/docs/llms.txt +3 -1
- package/docs/references/deploy.md +1 -1
- package/docs/references/routing.md +33 -3
- package/package.json +7 -7
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
|
|
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
|
|
17
|
-
* lends it a way to apply what comes back, and to drop the
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
859
|
-
*
|
|
860
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
971
|
+
const toRoutes = importPath(options.outDir, options.routesDir);
|
|
972
|
+
const localeSet = await localeSetOf(options.root);
|
|
934
973
|
const routesSource = emitRoutesModule(tree, {
|
|
935
|
-
importPrefix: 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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
2016
|
-
const client = builder.environments
|
|
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
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
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,
|
|
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": "
|
|
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.
|
|
65
|
-
"@vitejs/plugin-rsc": "0.5.
|
|
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.
|
|
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@
|
|
87
|
-
"vite-plus": "
|
|
88
|
-
"vitest": "5.0.
|
|
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",
|