@torpor/build 0.4.14 → 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 +20 -2
- package/dist/Jsonify-Dg6gChd3.d.mts +10 -0
- package/dist/Jsonify-Dg6gChd3.d.mts.map +1 -0
- package/dist/ParseRouteParams-DBmqlmLh.d.mts +42 -0
- package/dist/ParseRouteParams-DBmqlmLh.d.mts.map +1 -0
- package/dist/{RouteType-B6BMOyYw.mjs → Router-C4ADUfhv.mjs} +30 -42
- package/dist/Router-C4ADUfhv.mjs.map +1 -0
- package/dist/{Server-CF5wDJp6.d.mts → Server-C_IKav_e.d.mts} +44 -16
- package/dist/Server-C_IKav_e.d.mts.map +1 -0
- package/dist/{ServerEvent-CxPwA4lH.mjs → ServerEvent-Dljn5i7A.mjs} +37 -11
- package/dist/ServerEvent-Dljn5i7A.mjs.map +1 -0
- package/dist/Site-CMa153FA.d.mts +565 -0
- package/dist/Site-CMa153FA.d.mts.map +1 -0
- package/dist/StandardSchema-D28L8_zZ.d.mts +100 -0
- package/dist/StandardSchema-D28L8_zZ.d.mts.map +1 -0
- package/dist/TypedResponse-6lznqN4U.d.mts +22 -0
- package/dist/TypedResponse-6lznqN4U.d.mts.map +1 -0
- package/dist/{_page-BQs4WvCO.mjs → _page-4cQKs_Dn.mjs} +3 -5
- package/dist/_page-4cQKs_Dn.mjs.map +1 -0
- package/dist/bin/index.d.mts +1 -1
- package/dist/bin/index.js +15 -11
- package/dist/bin/index.js.map +1 -1
- package/dist/flattenHeaders-C_YYLdOq.mjs +114 -0
- package/dist/flattenHeaders-C_YYLdOq.mjs.map +1 -0
- package/dist/form.d.mts +35 -0
- package/dist/form.d.mts.map +1 -0
- package/dist/form.mjs +48 -0
- package/dist/form.mjs.map +1 -0
- package/dist/index.d.mts +72 -156
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +158 -26
- package/dist/index.mjs.map +1 -1
- package/dist/nav.d.mts +58 -5
- package/dist/nav.d.mts.map +1 -1
- package/dist/nav.mjs +116 -38
- package/dist/nav.mjs.map +1 -1
- package/dist/openapi.d.mts +144 -0
- package/dist/openapi.d.mts.map +1 -0
- package/dist/openapi.mjs +2 -0
- package/dist/pathTrie-D4Ax2Mu9.mjs +160 -0
- package/dist/pathTrie-D4Ax2Mu9.mjs.map +1 -0
- package/dist/plugin-D8Www11B.mjs +202 -0
- package/dist/plugin-D8Www11B.mjs.map +1 -0
- package/dist/{response-C5TtAsh1.mjs → response-Bg4w2S1q.mjs} +16 -39
- package/dist/response-Bg4w2S1q.mjs.map +1 -0
- package/dist/response.d.mts +309 -289
- package/dist/response.d.mts.map +1 -1
- package/dist/response.mjs +3 -4
- package/dist/run.d.mts +11 -4
- package/dist/run.d.mts.map +1 -1
- package/dist/run.mjs +2 -8
- package/dist/runOpenApi-BUi1RH1l.mjs +1381 -0
- package/dist/runOpenApi-BUi1RH1l.mjs.map +1 -0
- package/dist/schema.d.mts +32 -0
- package/dist/schema.d.mts.map +1 -0
- package/dist/schema.mjs +2 -0
- package/dist/server.d.mts +10 -8
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +132 -4
- package/dist/server.mjs.map +1 -0
- package/dist/state.d.mts.map +1 -1
- package/dist/state.mjs +2 -3
- package/dist/test.d.mts +8 -10
- package/dist/test.d.mts.map +1 -1
- package/dist/test.mjs +283 -79
- package/dist/test.mjs.map +1 -1
- package/dist/{seeOther-B4Yhu9iq.mjs → unprocessable-DAZbrDeR.mjs} +34 -13
- package/dist/unprocessable-DAZbrDeR.mjs.map +1 -0
- package/dist/validate-Ok4krlkX.mjs +40 -0
- package/dist/validate-Ok4krlkX.mjs.map +1 -0
- package/package.json +28 -17
- package/src/bin/index.ts +13 -2
- package/src/dev.ts +10 -0
- package/src/form/formDataToRecord.ts +23 -0
- package/src/form/readForm.ts +71 -0
- package/src/form.ts +4 -0
- package/src/index.ts +53 -1
- package/src/nav/api.test-d.ts +79 -0
- package/src/nav/api.ts +104 -0
- package/src/nav/formSubmit.ts +18 -4
- package/src/nav/navigate.ts +40 -15
- package/src/nav/route.ts +38 -0
- package/src/nav.ts +3 -1
- package/src/openapi/docsHtml.ts +25 -0
- package/src/openapi/document.ts +154 -0
- package/src/openapi/plugin.ts +78 -0
- package/src/openapi/types.ts +82 -0
- package/src/openapi.ts +14 -0
- package/src/response/TypedResponse.ts +17 -0
- package/src/response/badRequest.ts +14 -2
- package/src/response/created.ts +13 -2
- package/src/response/found.ts +2 -2
- package/src/response/movedPermanently.ts +2 -2
- package/src/response/notModified.ts +2 -2
- package/src/response/ok.ts +13 -4
- package/src/response/response.ts +4 -4
- package/src/response/unprocessable.ts +14 -2
- package/src/run/depCache.ts +175 -0
- package/src/run/devPlugin.ts +128 -0
- package/src/run/prepareTemplate.ts +6 -3
- package/src/run/run.ts +63 -39
- package/src/run/runBuild.ts +63 -12
- package/src/run/runDev.ts +146 -58
- package/src/run/runOpenApi.ts +52 -0
- package/src/run/runPreview.ts +17 -26
- package/src/run/staleTorpCopies.ts +100 -0
- package/src/run.ts +2 -1
- package/src/schema.ts +7 -0
- package/src/server/CookieHelper.ts +17 -7
- package/src/server/Server.ts +54 -30
- package/src/server/ServerEvent.ts +24 -1
- package/src/server/connect/connectMiddleware.ts +44 -40
- package/src/server/connect/flattenHeaders.ts +1 -23
- package/src/server/connect/requestToNodeMessage.ts +6 -3
- package/src/server/types/MiddlewareFunction.ts +18 -4
- package/src/site/Router.ts +52 -41
- package/src/site/Site.ts +184 -10
- package/src/site/checkLayoutSlots.ts +114 -0
- package/src/site/checkRoutes.ts +435 -0
- package/src/site/clientEntry.ts +22 -9
- package/src/site/layoutSlots.ts +53 -0
- package/src/site/manifest.ts +125 -6
- package/src/site/serverEntry.ts +341 -132
- package/src/state/$page.ts +2 -1
- package/src/state/$serverPage.ts +22 -0
- package/src/test/runTest.ts +306 -118
- package/src/types/Adapter.ts +12 -0
- package/src/types/Jsonify.ts +20 -0
- package/src/types/PageData.test-d.ts +136 -0
- package/src/types/PageData.ts +35 -0
- package/src/types/PageEndPoint.ts +25 -9
- package/src/types/PageForm.test-d.ts +78 -0
- package/src/types/PageForm.ts +29 -0
- package/src/types/PageLoadEvent.ts +16 -4
- package/src/types/PageLoadReturn.ts +13 -0
- package/src/types/PageProps.ts +14 -0
- package/src/types/PageServerAction.ts +8 -2
- package/src/types/PageServerEndPoint.test-d.ts +105 -0
- package/src/types/PageServerEndPoint.ts +99 -5
- package/src/types/PageServerLoad.ts +11 -3
- package/src/types/ParseRouteParams.test-d.ts +84 -0
- package/src/types/ParseRouteParams.ts +64 -0
- package/src/types/Route.ts +20 -1
- package/src/types/RouteHandler.ts +8 -1
- package/src/types/ServerEndPoint.test-d.ts +77 -0
- package/src/types/ServerEndPoint.ts +148 -16
- package/src/types/ServerHook.ts +10 -3
- package/src/types/ServerLoadEvent.ts +48 -3
- package/src/types/ServerRequest.ts +10 -2
- package/src/types/SitePlugin.ts +24 -0
- package/src/types/StandardSchema.ts +109 -0
- package/src/utils/pathToRegex.ts +4 -2
- package/src/utils/pathTrie.ts +182 -0
- package/src/utils/searchParamsToRecord.ts +18 -0
- package/src/utils/torporPackages.ts +150 -0
- package/src/utils/tsconfigAliases.ts +90 -0
- package/src/validation/ValidationError.ts +18 -0
- package/src/validation/endpoint.ts +83 -0
- package/src/validation/validate.ts +26 -0
- package/dist/RouteType-B6BMOyYw.mjs.map +0 -1
- package/dist/Server-CF5wDJp6.d.mts.map +0 -1
- package/dist/ServerEvent-CxPwA4lH.mjs.map +0 -1
- package/dist/Site-DgC6WWn1.d.mts +0 -78
- package/dist/Site-DgC6WWn1.d.mts.map +0 -1
- package/dist/_page-BQs4WvCO.mjs.map +0 -1
- package/dist/connectMiddleware-D7nehjj3.mjs +0 -252
- package/dist/connectMiddleware-D7nehjj3.mjs.map +0 -1
- package/dist/pathToRegex-TUAMc3zS.mjs +0 -11
- package/dist/pathToRegex-TUAMc3zS.mjs.map +0 -1
- package/dist/response-C5TtAsh1.mjs.map +0 -1
- package/dist/run-BPFtSYA3.mjs +0 -332
- package/dist/run-BPFtSYA3.mjs.map +0 -1
- package/dist/seeOther-B4Yhu9iq.mjs.map +0 -1
- package/src/server/Routerx.ts +0 -72
- package/src/server/connect/bufferToArrayBuffer.ts +0 -8
- package/src/server/connect/readableToBuffer.ts +0 -16
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "@torpor/view/compile";
|
|
4
|
+
import type Route from "../types/Route";
|
|
5
|
+
import { LAYOUT_ROUTE } from "../types/RouteType";
|
|
6
|
+
import type Site from "./Site";
|
|
7
|
+
import { createApiCallCheck, resolveSpecifier, type RouteCheckIssue } from "./checkRoutes";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The default import of a `.torp` component from a `.ts`/`.js` route file,
|
|
11
|
+
* e.g. `import component from "@/views/Layout.torp"`.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_TORP_IMPORT_RE = /import\s+[A-Za-z_$][\w$]*\s+from\s*["']([^"']+\.torp)["']/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Checks that every `_layout` route renders its default `<slot />`.
|
|
17
|
+
*
|
|
18
|
+
* The layout engine composes pages by passing each layout (and finally the
|
|
19
|
+
* page) as the default slot of its parent, so a layout that never renders
|
|
20
|
+
* `<slot />` drops its entire content: the server renders the layout's own
|
|
21
|
+
* markup with an empty content area (no error), and the first client-side
|
|
22
|
+
* navigation that reuses the layout fails on a null slot region.
|
|
23
|
+
*
|
|
24
|
+
* Runs at build time (failing the build) and at dev startup. Best-effort and
|
|
25
|
+
* static: a layout that renders `<slot />` conditionally (inside `@if`)
|
|
26
|
+
* passes here, and is caught at request time by the server render warning
|
|
27
|
+
* (see site/layoutSlots.ts).
|
|
28
|
+
*/
|
|
29
|
+
export function checkLayoutSlots(site: Site): RouteCheckIssue[] {
|
|
30
|
+
const issues: RouteCheckIssue[] = [];
|
|
31
|
+
for (const route of site.routes) {
|
|
32
|
+
if (route.type === LAYOUT_ROUTE) {
|
|
33
|
+
issues.push(...checkLayoutSlot(site, route));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return issues;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Checks a single layout route's component (the `.torp` route file itself,
|
|
41
|
+
* or the `.torp` file its `.ts`/`.js` endpoint re-exports as its default
|
|
42
|
+
* import). Routes without a file (inline endpoints) can't hold a component,
|
|
43
|
+
* so they are skipped.
|
|
44
|
+
*/
|
|
45
|
+
export function checkLayoutSlot(site: Site, route: Route): RouteCheckIssue[] {
|
|
46
|
+
if (route.type !== LAYOUT_ROUTE || !route.file) return [];
|
|
47
|
+
|
|
48
|
+
const check = createApiCallCheck(site);
|
|
49
|
+
|
|
50
|
+
// The component is either the route file itself (.torp) or a .ts/.js
|
|
51
|
+
// endpoint re-exporting a .torp component as its default export
|
|
52
|
+
let torpFile = path.join(site.root, route.file);
|
|
53
|
+
if (!route.file.endsWith(".torp")) {
|
|
54
|
+
const source = readIfExists(torpFile);
|
|
55
|
+
if (source === undefined) return [];
|
|
56
|
+
const match = DEFAULT_TORP_IMPORT_RE.exec(source);
|
|
57
|
+
if (!match) return [];
|
|
58
|
+
const resolved = resolveSpecifier(match[1], torpFile, check);
|
|
59
|
+
if (!resolved) return [];
|
|
60
|
+
torpFile = resolved;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const source = readIfExists(torpFile);
|
|
64
|
+
if (source === undefined) return [];
|
|
65
|
+
|
|
66
|
+
// Parse errors are reported when the file is compiled, not here
|
|
67
|
+
const parsed = parse(source);
|
|
68
|
+
if (!parsed.ok || !parsed.template) return [];
|
|
69
|
+
if (rendersDefaultSlot(parsed.template)) return [];
|
|
70
|
+
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
file: route.file,
|
|
74
|
+
line: 1,
|
|
75
|
+
severity: "error",
|
|
76
|
+
message:
|
|
77
|
+
"The layout never renders <slot />, so pages under it render without their content. " +
|
|
78
|
+
"Every _layout component must render <slot />, or pass it down to a child component " +
|
|
79
|
+
"it renders.",
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readIfExists(file: string): string | undefined {
|
|
85
|
+
try {
|
|
86
|
+
if (!existsSync(file) || !statSync(file).isFile()) return undefined;
|
|
87
|
+
return readFileSync(file, "utf8");
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Walks a parsed template looking for a default `<slot />` anywhere in the
|
|
95
|
+
* render tree — including nested inside a child component element, which is
|
|
96
|
+
* how a layout passes the page slot down (e.g. `<Shell><slot /></Shell>`).
|
|
97
|
+
*/
|
|
98
|
+
// HACK: no types on the internal parse tree nodes (same as the prettier plugin)
|
|
99
|
+
function rendersDefaultSlot(template: any): boolean {
|
|
100
|
+
const component = template.components?.[0];
|
|
101
|
+
if (!component) return false;
|
|
102
|
+
return walk(component.markup);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function walk(node: any): boolean {
|
|
106
|
+
if (
|
|
107
|
+
(node.type === "element" || node.type === "special") &&
|
|
108
|
+
node.tagName === "slot" &&
|
|
109
|
+
!node.attributes?.some((a: any) => a.name === "name")
|
|
110
|
+
) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return (node.children ?? []).some(walk);
|
|
114
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type Route from "../types/Route";
|
|
4
|
+
import {
|
|
5
|
+
ERROR_ROUTE,
|
|
6
|
+
HOOK_ROUTE,
|
|
7
|
+
HOOK_SERVER_ROUTE,
|
|
8
|
+
LAYOUT_ROUTE,
|
|
9
|
+
LAYOUT_SERVER_ROUTE,
|
|
10
|
+
PAGE_SERVER_ROUTE,
|
|
11
|
+
SERVER_ROUTE,
|
|
12
|
+
} from "../types/RouteType";
|
|
13
|
+
import tsconfigAliases, { type AliasEntry } from "../utils/tsconfigAliases";
|
|
14
|
+
import type Site from "./Site";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A problem found in a route file's type annotations by `checkRoutes`.
|
|
18
|
+
*/
|
|
19
|
+
export interface RouteCheckIssue {
|
|
20
|
+
/** The route file, relative to the site root */
|
|
21
|
+
file: string;
|
|
22
|
+
/** 1-based line number of the annotation */
|
|
23
|
+
line: number;
|
|
24
|
+
severity: "error" | "warning";
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ENDPOINT_TYPES = ["PageServerEndPoint", "PageEndPoint", "ServerEndPoint", "ServerHook"];
|
|
29
|
+
|
|
30
|
+
const USAGE_RE = new RegExp(`\\b(satisfies|as|:)\\s*\\b(${ENDPOINT_TYPES.join("|")})\\b`, "g");
|
|
31
|
+
|
|
32
|
+
const TYPE_NAME_RE = new RegExp(`^(?:${ENDPOINT_TYPES.join("|")})\\b`);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Checks the route files of a site against their type annotations:
|
|
36
|
+
*
|
|
37
|
+
* - Route path annotations (e.g. `PageServerEndPoint<"/posts/[id]">`) must
|
|
38
|
+
* match the route derived from the file's location, so types can't silently
|
|
39
|
+
* lie when files move
|
|
40
|
+
* - Exports that widen the endpoint type (`satisfies T as T`, `as T`, or a
|
|
41
|
+
* variable annotation) are flagged, because they destroy `PageData` /
|
|
42
|
+
* `PageForm` / param inference
|
|
43
|
+
* - Routes with dynamic params but no route annotation are flagged, because
|
|
44
|
+
* their params are loosely typed
|
|
45
|
+
*
|
|
46
|
+
* Only `.ts` route files are checked; annotations are matched by the standard
|
|
47
|
+
* type names (renamed imports are not detected). Best-effort, source-level —
|
|
48
|
+
* comments mentioning these patterns may produce false positives.
|
|
49
|
+
*/
|
|
50
|
+
export function checkRoutes(site: Site): RouteCheckIssue[] {
|
|
51
|
+
const issues: RouteCheckIssue[] = [];
|
|
52
|
+
for (const route of site.routes) {
|
|
53
|
+
issues.push(...checkRoute(site, route));
|
|
54
|
+
}
|
|
55
|
+
return issues;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Checks a single route's file. Returns no issues for routes without a `.ts`
|
|
60
|
+
* file (`.torp` components, inline endpoints, `.js` files).
|
|
61
|
+
*/
|
|
62
|
+
export function checkRoute(site: Site, route: Route): RouteCheckIssue[] {
|
|
63
|
+
if (!route.file || !route.file.endsWith(".ts")) return [];
|
|
64
|
+
let source: string;
|
|
65
|
+
try {
|
|
66
|
+
source = readFileSync(path.join(site.root, route.file), "utf8");
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
return checkRouteSource(source, route.file, expectedAnnotationPath(route), typeNameFor(route));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Checks a single route file's source. Exported for reuse (e.g. re-checking a
|
|
75
|
+
* changed file during dev).
|
|
76
|
+
* @param source The route file's source code
|
|
77
|
+
* @param file The file path, relative to the site root (used in messages)
|
|
78
|
+
* @param expected The route path derived from the file's location
|
|
79
|
+
* @param typeName The endpoint type name conventionally used by this route
|
|
80
|
+
* type (used in suggestions)
|
|
81
|
+
*/
|
|
82
|
+
export function checkRouteSource(
|
|
83
|
+
source: string,
|
|
84
|
+
file: string,
|
|
85
|
+
expected: string,
|
|
86
|
+
typeName: string,
|
|
87
|
+
): RouteCheckIssue[] {
|
|
88
|
+
const issues: RouteCheckIssue[] = [];
|
|
89
|
+
let sawAnnotation = false;
|
|
90
|
+
USAGE_RE.lastIndex = 0;
|
|
91
|
+
for (let m = USAGE_RE.exec(source); m; m = USAGE_RE.exec(source)) {
|
|
92
|
+
sawAnnotation = true;
|
|
93
|
+
const kind = m[1];
|
|
94
|
+
const usedTypeName = m[2];
|
|
95
|
+
const { routePath, end } = parseTypeArgs(source, m.index + m[0].length);
|
|
96
|
+
const line = lineOf(source, m.index);
|
|
97
|
+
if (routePath !== undefined && routePath !== expected) {
|
|
98
|
+
issues.push({
|
|
99
|
+
file,
|
|
100
|
+
line,
|
|
101
|
+
severity: "error",
|
|
102
|
+
message: `Route annotation "${routePath}" doesn't match this file's route "${expected}" (derived from its location). Did the file move?`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (kind === "as" || kind === ":") {
|
|
106
|
+
issues.push({
|
|
107
|
+
file,
|
|
108
|
+
line,
|
|
109
|
+
severity: "warning",
|
|
110
|
+
message:
|
|
111
|
+
kind === "as"
|
|
112
|
+
? `The \`as ${usedTypeName}\` cast widens the exported type, so PageData/PageForm/param inference falls back to loose types. Use \`satisfies ${usedTypeName}${routePathArg(expected, routePath)}\` without a cast instead.`
|
|
113
|
+
: `The variable annotation widens the exported type, so PageData/PageForm/param inference falls back to loose types. Use \`satisfies ${usedTypeName}${routePathArg(expected, routePath)}\` on the default export instead.`,
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
const cast = parseAsCast(source, end);
|
|
117
|
+
if (cast) {
|
|
118
|
+
issues.push({
|
|
119
|
+
file,
|
|
120
|
+
line,
|
|
121
|
+
severity: "warning",
|
|
122
|
+
message: `The \`as\` cast after \`satisfies\` widens the exported type, so PageData/PageForm/param inference falls back to loose types. Use \`satisfies ${usedTypeName}${routePathArg(expected, routePath ?? cast.routePath)}\` on its own.`,
|
|
123
|
+
});
|
|
124
|
+
if (cast.routePath !== undefined && cast.routePath !== expected) {
|
|
125
|
+
issues.push({
|
|
126
|
+
file,
|
|
127
|
+
line,
|
|
128
|
+
severity: "error",
|
|
129
|
+
message: `Route annotation "${cast.routePath}" doesn't match this file's route "${expected}" (derived from its location). Did the file move?`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// Skip the `as T` so it isn't reported again as a separate cast
|
|
133
|
+
USAGE_RE.lastIndex = cast.end;
|
|
134
|
+
} else if (routePath === undefined && expected.includes("[")) {
|
|
135
|
+
issues.push({
|
|
136
|
+
file,
|
|
137
|
+
line,
|
|
138
|
+
severity: "warning",
|
|
139
|
+
message: `No route path on the annotation, so params are loosely typed. Use \`satisfies ${usedTypeName}<"${expected}">\`.`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!sawAnnotation && file.endsWith(".ts") && expected.includes("[")) {
|
|
145
|
+
issues.push({
|
|
146
|
+
file,
|
|
147
|
+
line: 1,
|
|
148
|
+
severity: "warning",
|
|
149
|
+
message: `No route annotation, so params are loosely typed. Add \`satisfies ${typeName}<"${expected}">\` to the default export.`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return issues;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Prints issues to the console and returns the number of errors.
|
|
157
|
+
*/
|
|
158
|
+
export function reportRouteIssues(issues: RouteCheckIssue[]): number {
|
|
159
|
+
for (const issue of issues) {
|
|
160
|
+
console.error(`[torpor] ${issue.severity}: ${issue.file}:${issue.line} ${issue.message}`);
|
|
161
|
+
}
|
|
162
|
+
return issues.filter((i) => i.severity === "error").length;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const MAKE_API_RE = /\bmakeApi\s*<\s*(["'])([^"']+)\1\s*,\s*typeof\s+([A-Za-z_$][\w$]*)/g;
|
|
166
|
+
|
|
167
|
+
const DEFAULT_IMPORT_RE = /\bimport\s+(?:type\s+)?([A-Za-z_$][\w$]*)\s+from\s*(["'])([^"']+)\2/g;
|
|
168
|
+
|
|
169
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".torpor", ".vite", "coverage"]);
|
|
170
|
+
|
|
171
|
+
const SOURCE_FILE_RE = /\.(ts|js|torp)$/;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Precomputed state for checking `makeApi` calls: the site root, its route
|
|
175
|
+
* files keyed by path, and tsconfig path aliases for resolving imports.
|
|
176
|
+
*/
|
|
177
|
+
export interface ApiCallCheck {
|
|
178
|
+
root: string;
|
|
179
|
+
routesByFile: Map<string, Route>;
|
|
180
|
+
aliases: AliasEntry[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Creates the shared state used by `checkApiCallSource`, so per-file checks
|
|
185
|
+
* (e.g. re-checking a changed file during dev) don't rebuild it.
|
|
186
|
+
*/
|
|
187
|
+
export function createApiCallCheck(site: Site): ApiCallCheck {
|
|
188
|
+
const routesByFile = new Map<string, Route>();
|
|
189
|
+
for (const route of site.routes) {
|
|
190
|
+
if (route.file) routesByFile.set(route.file.replaceAll("\\", "/"), route);
|
|
191
|
+
}
|
|
192
|
+
return { root: site.root, routesByFile, aliases: tsconfigAliases(site.root) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Checks `makeApi<Route, typeof endpoint>` calls across the site's source
|
|
197
|
+
* files: the `Route` argument must match the route derived from the imported
|
|
198
|
+
* endpoint file's location. Calls whose endpoint isn't a type-only import of
|
|
199
|
+
* a known route file are skipped.
|
|
200
|
+
*/
|
|
201
|
+
export function checkApiCalls(site: Site): RouteCheckIssue[] {
|
|
202
|
+
const check = createApiCallCheck(site);
|
|
203
|
+
const issues: RouteCheckIssue[] = [];
|
|
204
|
+
for (const file of walkSourceFiles(site.root)) {
|
|
205
|
+
let source: string;
|
|
206
|
+
try {
|
|
207
|
+
source = readFileSync(file, "utf8");
|
|
208
|
+
} catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
issues.push(...checkApiCallSource(source, file, check));
|
|
212
|
+
}
|
|
213
|
+
return issues;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Checks the `makeApi` calls in a single file's source.
|
|
218
|
+
* @param source The file's source code
|
|
219
|
+
* @param file The file's absolute path (used to resolve relative imports and
|
|
220
|
+
* to derive the path shown in messages)
|
|
221
|
+
* @param check Shared state from `createApiCallCheck`
|
|
222
|
+
*/
|
|
223
|
+
export function checkApiCallSource(
|
|
224
|
+
source: string,
|
|
225
|
+
file: string,
|
|
226
|
+
check: ApiCallCheck,
|
|
227
|
+
): RouteCheckIssue[] {
|
|
228
|
+
const imports = new Map<string, string>();
|
|
229
|
+
for (let m = DEFAULT_IMPORT_RE.exec(source); m; m = DEFAULT_IMPORT_RE.exec(source)) {
|
|
230
|
+
imports.set(m[1], m[3]);
|
|
231
|
+
}
|
|
232
|
+
const issues: RouteCheckIssue[] = [];
|
|
233
|
+
for (let m = MAKE_API_RE.exec(source); m; m = MAKE_API_RE.exec(source)) {
|
|
234
|
+
const specifier = imports.get(m[3]);
|
|
235
|
+
if (!specifier) continue;
|
|
236
|
+
const resolved = resolveSpecifier(specifier, file, check);
|
|
237
|
+
if (!resolved) continue;
|
|
238
|
+
const relFile = path.relative(check.root, resolved).replaceAll("\\", "/");
|
|
239
|
+
const route = check.routesByFile.get(relFile);
|
|
240
|
+
if (!route) continue;
|
|
241
|
+
const expected = expectedAnnotationPath(route);
|
|
242
|
+
if (m[2] !== expected) {
|
|
243
|
+
issues.push({
|
|
244
|
+
file: path.relative(check.root, file).replaceAll("\\", "/"),
|
|
245
|
+
line: lineOf(source, m.index),
|
|
246
|
+
severity: "error",
|
|
247
|
+
message: `makeApi route "${m[2]}" doesn't match the endpoint's route "${expected}" (derived from ${relFile}). Did the endpoint move?`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return issues;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Resolves an import specifier to an existing file: relative specifiers are
|
|
256
|
+
* resolved against the importing file, others against tsconfig path aliases.
|
|
257
|
+
* Bare module specifiers that match no alias return undefined.
|
|
258
|
+
*/
|
|
259
|
+
export function resolveSpecifier(
|
|
260
|
+
specifier: string,
|
|
261
|
+
fromFile: string,
|
|
262
|
+
check: ApiCallCheck,
|
|
263
|
+
): string | undefined {
|
|
264
|
+
let candidate: string | undefined;
|
|
265
|
+
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
266
|
+
candidate = path.resolve(path.dirname(fromFile), specifier);
|
|
267
|
+
} else {
|
|
268
|
+
for (const alias of check.aliases) {
|
|
269
|
+
if (typeof alias.find === "string") {
|
|
270
|
+
if (specifier.startsWith(alias.find)) {
|
|
271
|
+
candidate = path.resolve(alias.replacement, specifier.slice(alias.find.length));
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
} else if (alias.find.test(specifier)) {
|
|
275
|
+
candidate = specifier.replace(alias.find, alias.replacement);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!candidate) return undefined;
|
|
281
|
+
const probes = [
|
|
282
|
+
candidate,
|
|
283
|
+
`${candidate}.ts`,
|
|
284
|
+
`${candidate}.js`,
|
|
285
|
+
path.join(candidate, "index.ts"),
|
|
286
|
+
path.join(candidate, "index.js"),
|
|
287
|
+
];
|
|
288
|
+
for (const probe of probes) {
|
|
289
|
+
if (existsSync(probe) && statSync(probe).isFile()) return probe;
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function walkSourceFiles(root: string): string[] {
|
|
295
|
+
const files: string[] = [];
|
|
296
|
+
const visit = (dir: string): void => {
|
|
297
|
+
let entries;
|
|
298
|
+
try {
|
|
299
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
300
|
+
} catch {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
if (entry.isDirectory()) {
|
|
305
|
+
if (!SKIP_DIRS.has(entry.name)) visit(path.join(dir, entry.name));
|
|
306
|
+
} else if (
|
|
307
|
+
entry.isFile() &&
|
|
308
|
+
SOURCE_FILE_RE.test(entry.name) &&
|
|
309
|
+
!entry.name.endsWith(".d.ts")
|
|
310
|
+
) {
|
|
311
|
+
files.push(path.join(dir, entry.name));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
visit(root);
|
|
316
|
+
return files;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The route path a file's annotation should use, derived from its manifest
|
|
321
|
+
* entry: internal suffixes (`/~server`, `/_layout` etc) are stripped so e.g.
|
|
322
|
+
* `posts/[id]/+page.server.ts` expects `"/posts/[id]"`.
|
|
323
|
+
*/
|
|
324
|
+
export function expectedAnnotationPath(route: Route): string {
|
|
325
|
+
let p = route.path;
|
|
326
|
+
switch (route.type) {
|
|
327
|
+
case PAGE_SERVER_ROUTE:
|
|
328
|
+
p = p.replace(/\/~server$/, "");
|
|
329
|
+
break;
|
|
330
|
+
case LAYOUT_ROUTE:
|
|
331
|
+
p = p.replace(/\/_layout$/, "");
|
|
332
|
+
break;
|
|
333
|
+
case LAYOUT_SERVER_ROUTE:
|
|
334
|
+
p = p.replace(/\/_layout\/~server$/, "");
|
|
335
|
+
break;
|
|
336
|
+
case HOOK_ROUTE:
|
|
337
|
+
p = p.replace(/\/_hook$/, "");
|
|
338
|
+
break;
|
|
339
|
+
case HOOK_SERVER_ROUTE:
|
|
340
|
+
p = p.replace(/\/_hook\/~server$/, "");
|
|
341
|
+
break;
|
|
342
|
+
case ERROR_ROUTE:
|
|
343
|
+
p = p.replace(/\/_error$/, "");
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
return p === "" ? "/" : p;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function typeNameFor(route: Route): string {
|
|
350
|
+
switch (route.type) {
|
|
351
|
+
case PAGE_SERVER_ROUTE:
|
|
352
|
+
case LAYOUT_SERVER_ROUTE:
|
|
353
|
+
return "PageServerEndPoint";
|
|
354
|
+
case SERVER_ROUTE:
|
|
355
|
+
return "ServerEndPoint";
|
|
356
|
+
case HOOK_ROUTE:
|
|
357
|
+
case HOOK_SERVER_ROUTE:
|
|
358
|
+
return "ServerHook";
|
|
359
|
+
default:
|
|
360
|
+
return "PageEndPoint";
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Parses the type arguments that follow an endpoint type name, returning the
|
|
366
|
+
* first argument when it is a string literal (the route path), plus the index
|
|
367
|
+
* after the closing `>` (or after the type name when there are no arguments).
|
|
368
|
+
*/
|
|
369
|
+
function parseTypeArgs(
|
|
370
|
+
source: string,
|
|
371
|
+
start: number,
|
|
372
|
+
): { routePath: string | undefined; end: number } {
|
|
373
|
+
let i = start;
|
|
374
|
+
while (i < source.length && /\s/.test(source[i])) i++;
|
|
375
|
+
if (source[i] !== "<") return { routePath: undefined, end: i };
|
|
376
|
+
i++;
|
|
377
|
+
let depth = 1;
|
|
378
|
+
let routePath: string | undefined;
|
|
379
|
+
let seenComma = false;
|
|
380
|
+
while (i < source.length) {
|
|
381
|
+
const c = source[i];
|
|
382
|
+
if (c === '"' || c === "'") {
|
|
383
|
+
const quote = c;
|
|
384
|
+
let j = i + 1;
|
|
385
|
+
while (j < source.length && source[j] !== quote) {
|
|
386
|
+
if (source[j] === "\\") j++;
|
|
387
|
+
j++;
|
|
388
|
+
}
|
|
389
|
+
if (routePath === undefined && !seenComma) routePath = source.slice(i + 1, j);
|
|
390
|
+
i = j + 1;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (c === "<") depth++;
|
|
394
|
+
else if (c === ">") {
|
|
395
|
+
depth--;
|
|
396
|
+
if (depth === 0) return { routePath, end: i + 1 };
|
|
397
|
+
} else if (c === "," && routePath === undefined) {
|
|
398
|
+
seenComma = true;
|
|
399
|
+
}
|
|
400
|
+
i++;
|
|
401
|
+
}
|
|
402
|
+
return { routePath, end: i };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Parses an `as T`-style cast that follows a `satisfies T` usage, returning
|
|
407
|
+
* its optional route path argument and the index after the cast.
|
|
408
|
+
*/
|
|
409
|
+
function parseAsCast(
|
|
410
|
+
source: string,
|
|
411
|
+
start: number,
|
|
412
|
+
): { routePath: string | undefined; end: number } | undefined {
|
|
413
|
+
let i = start;
|
|
414
|
+
while (i < source.length && /\s/.test(source[i])) i++;
|
|
415
|
+
if (!/^as\b/.test(source.slice(i, i + 3))) return undefined;
|
|
416
|
+
i += 2;
|
|
417
|
+
while (i < source.length && /\s/.test(source[i])) i++;
|
|
418
|
+
if (!TYPE_NAME_RE.test(source.slice(i))) return undefined;
|
|
419
|
+
i = i + source.slice(i).match(TYPE_NAME_RE)![0].length;
|
|
420
|
+
return parseTypeArgs(source, i);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function routePathArg(expected: string, actual: string | undefined): string {
|
|
424
|
+
// Keep the user's own (correct) path when widening a properly-annotated
|
|
425
|
+
// export; otherwise suggest the path derived from the file's location
|
|
426
|
+
return `<"${actual ?? expected}">`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function lineOf(source: string, index: number): number {
|
|
430
|
+
let line = 1;
|
|
431
|
+
for (let i = 0; i < index; i++) {
|
|
432
|
+
if (source[i] === "\n") line++;
|
|
433
|
+
}
|
|
434
|
+
return line;
|
|
435
|
+
}
|
package/src/site/clientEntry.ts
CHANGED
|
@@ -19,9 +19,8 @@ client.router = router;
|
|
|
19
19
|
|
|
20
20
|
// Intercept clicks on links
|
|
21
21
|
window.addEventListener("click", async (e) => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
const link = getLink(e.target);
|
|
23
|
+
if (link) {
|
|
25
24
|
if (ignoreAnchorMouseEvent(link, e)) {
|
|
26
25
|
return;
|
|
27
26
|
}
|
|
@@ -45,9 +44,8 @@ window.addEventListener("mouseover", maybePrefetch);
|
|
|
45
44
|
window.addEventListener("touchstart", maybePrefetch);
|
|
46
45
|
|
|
47
46
|
async function maybePrefetch(e: MouseEvent | TouchEvent) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
const link = getLink(e.target);
|
|
48
|
+
if (link) {
|
|
51
49
|
if (ignoreAnchorMouseEvent(link, e)) {
|
|
52
50
|
return;
|
|
53
51
|
}
|
|
@@ -77,16 +75,31 @@ async function maybePrefetch(e: MouseEvent | TouchEvent) {
|
|
|
77
75
|
}
|
|
78
76
|
}
|
|
79
77
|
|
|
78
|
+
function getLink(target: EventTarget | null): HTMLAnchorElement | undefined {
|
|
79
|
+
// The target may be a child element of the link (e.g. an icon inside it),
|
|
80
|
+
// so walk up to the nearest actual link. Fragment-only links (href="#...")
|
|
81
|
+
// are left to the browser's native jump-to-anchor behavior
|
|
82
|
+
if (!(target instanceof Element)) return;
|
|
83
|
+
const link = target.closest("a[href]");
|
|
84
|
+
if (link?.getAttribute("href")?.startsWith("#")) return;
|
|
85
|
+
return (link as HTMLAnchorElement) ?? undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
80
88
|
function ignoreAnchorMouseEvent(link: HTMLAnchorElement, e: MouseEvent | TouchEvent) {
|
|
81
|
-
// Ignore the event if
|
|
82
|
-
//
|
|
89
|
+
// Ignore the event if it was already handled (e.g. a user handler calling
|
|
90
|
+
// preventDefault), a modifier key is pressed, the target is not "_self"
|
|
91
|
+
// (e.g. "_blank"), rel="external", it's a download link, or it's in a
|
|
92
|
+
// contenteditable region
|
|
83
93
|
return (
|
|
94
|
+
e.defaultPrevented ||
|
|
84
95
|
e.altKey ||
|
|
85
96
|
e.ctrlKey ||
|
|
86
97
|
e.metaKey ||
|
|
87
98
|
e.shiftKey ||
|
|
88
99
|
(link.target && link.target !== "_self") ||
|
|
89
|
-
link.rel === "external"
|
|
100
|
+
link.rel === "external" ||
|
|
101
|
+
link.hasAttribute("download") ||
|
|
102
|
+
link.isContentEditable
|
|
90
103
|
);
|
|
91
104
|
}
|
|
92
105
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layouts are composed by the layout engine passing each layout (and finally
|
|
3
|
+
* the page) as the default slot of its parent — so every `_layout` component
|
|
4
|
+
* must render `<slot />`, or pass the default slot down to a child component
|
|
5
|
+
* it renders. A layout that doesn't drops its entire content: the server
|
|
6
|
+
* renders the layout's own markup with an empty content area (no error), and
|
|
7
|
+
* the first client-side navigation that reuses the layout crashes on a null
|
|
8
|
+
* slot region.
|
|
9
|
+
*
|
|
10
|
+
* The static `checkLayoutSlots` check catches this at build/dev time; these
|
|
11
|
+
* helpers report it at request time for the cases static analysis can't see
|
|
12
|
+
* (e.g. a `<slot />` rendered conditionally inside `@if`). They are
|
|
13
|
+
* server-side only — nothing here is imported into the client bundle.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the path of the outermost layout that never rendered its
|
|
18
|
+
* `<slot />`, or undefined when every level rendered.
|
|
19
|
+
*
|
|
20
|
+
* Slot level j (1-based) is the slot render that layout j - 1 receives: the
|
|
21
|
+
* root layout (layouts[0]) renders level 1, layout i renders level i + 1,
|
|
22
|
+
* and level layouts.length renders the page. A broken level makes every
|
|
23
|
+
* deeper level unreachable, so the first level that never ran identifies
|
|
24
|
+
* the offending layout.
|
|
25
|
+
*/
|
|
26
|
+
export function findMissingSlotLayout(
|
|
27
|
+
layouts: readonly { path: string }[],
|
|
28
|
+
invokedLevels: ReadonlySet<number>,
|
|
29
|
+
): string | undefined {
|
|
30
|
+
for (let level = 1; level <= layouts.length; level++) {
|
|
31
|
+
if (!invokedLevels.has(level)) {
|
|
32
|
+
return layouts[level - 1].path;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const warnedLayouts = new Set<string>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reports a layout that dropped its slot content. The condition is a genuine
|
|
42
|
+
* rendering bug (the page content is missing from the output), so it is not
|
|
43
|
+
* dev-gated — but it warns only once per layout path to avoid per-request
|
|
44
|
+
* noise.
|
|
45
|
+
*/ export function warnMissingSlotContent(layoutPath: string): void {
|
|
46
|
+
if (warnedLayouts.has(layoutPath)) return;
|
|
47
|
+
warnedLayouts.add(layoutPath);
|
|
48
|
+
console.warn(
|
|
49
|
+
`[torpor] The layout at "${layoutPath}" never rendered <slot />, so the page content ` +
|
|
50
|
+
`was not included in the render. Every _layout component must render <slot /> (or ` +
|
|
51
|
+
`pass the default slot down to a child component it renders).`,
|
|
52
|
+
);
|
|
53
|
+
}
|