bosia 1.2.1 → 1.2.3
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/package.json +1 -1
- package/src/core/build.ts +10 -2
- package/src/core/client/App.svelte +365 -250
- package/src/core/client/enhance.ts +6 -2
- package/src/core/client/hydrate.ts +7 -2
- package/src/core/client/navigation.ts +46 -27
- package/src/core/client/router.svelte.ts +24 -5
- package/src/core/headScan.ts +6 -0
- package/src/core/matcher.ts +52 -85
- package/src/core/prerender.ts +134 -35
- package/src/core/renderer.ts +2 -2
- package/src/core/routeFile.ts +21 -17
- package/src/core/routeTypes.ts +14 -11
- package/src/core/scanner.ts +76 -13
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +1 -1
package/src/core/prerender.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, cpSync, existsSync } from "fs";
|
|
1
|
+
import { writeFileSync, mkdirSync, cpSync, existsSync, readFileSync } from "fs";
|
|
2
2
|
import { createServer } from "net";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import type { RouteManifest, TrailingSlash } from "./types.ts";
|
|
5
|
+
import type { RuntimeTarget } from "./types/plugin.ts";
|
|
5
6
|
|
|
6
7
|
import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
|
|
7
8
|
import { currentBase } from "./appBase.ts";
|
|
8
9
|
import { prerenderSkipReason, wantsPrerender } from "./scanner.ts";
|
|
10
|
+
import { isDynamic, segmentName, segmentRank } from "./matcher.ts";
|
|
9
11
|
|
|
10
12
|
/** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
|
|
11
13
|
export function getEphemeralPort(): Promise<number> {
|
|
@@ -28,6 +30,26 @@ export function getEphemeralPort(): Promise<number> {
|
|
|
28
30
|
|
|
29
31
|
const CORE_DIR = import.meta.dir;
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* True when the app's wrangler config serves `404.html` for unknown paths
|
|
35
|
+
* (`not_found_handling: "404-page"`) and `public/404.html` doesn't provide one,
|
|
36
|
+
* so a failed 404 probe must fail the build. Reads the text, skipping whole-line
|
|
37
|
+
* `//` and `#` comments; a block comment around the key still counts.
|
|
38
|
+
*/
|
|
39
|
+
export function needsStatic404(cwd = process.cwd()): boolean {
|
|
40
|
+
if (existsSync(join(cwd, "public", "404.html"))) return false;
|
|
41
|
+
for (const name of ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]) {
|
|
42
|
+
const file = join(cwd, name);
|
|
43
|
+
if (!existsSync(file)) continue;
|
|
44
|
+
const text = readFileSync(file, "utf-8")
|
|
45
|
+
.split("\n")
|
|
46
|
+
.filter((line) => !/^\s*(\/\/|#)/.test(line))
|
|
47
|
+
.join("\n");
|
|
48
|
+
return /not_found_handling"?\s*[:=]\s*"404-page"/.test(text);
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
|
|
32
54
|
const PRERENDER_CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6;
|
|
33
55
|
|
|
@@ -47,23 +69,38 @@ interface PrerenderTarget {
|
|
|
47
69
|
* concrete values from an `entries()` record.
|
|
48
70
|
*/
|
|
49
71
|
export function substituteParams(pattern: string, entry: Record<string, string>): string {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
// Per segment, so an inserted value is never re-read as a placeholder.
|
|
73
|
+
return pattern
|
|
74
|
+
.split("/")
|
|
75
|
+
.map((seg) => {
|
|
76
|
+
const rank = segmentRank(seg);
|
|
77
|
+
if (!rank) return seg;
|
|
78
|
+
const name = segmentName(seg);
|
|
79
|
+
if (entry[name] == null || !Object.hasOwn(entry, name))
|
|
80
|
+
throw new Error(`entries() gave no value for ${seg}`);
|
|
81
|
+
// A catch-all may end in one "/" ("guide/intro/"); the out path drops it.
|
|
82
|
+
const value = rank === 2 ? String(entry[name]).replace(/\/$/, "") : String(entry[name]);
|
|
83
|
+
const bad = (why: string): never => {
|
|
84
|
+
throw new Error(`entries() value "${entry[name]}" for ${seg} is unsafe — ${why}`);
|
|
85
|
+
};
|
|
86
|
+
// Check what the server will see: `%2e%2e` and `%2F` decode to `..` and `/`.
|
|
87
|
+
let decoded = value;
|
|
88
|
+
try {
|
|
89
|
+
decoded = decodeURIComponent(value);
|
|
90
|
+
} catch {
|
|
91
|
+
bad("invalid percent-encoding");
|
|
92
|
+
}
|
|
93
|
+
// `..` and `\` let a build write outside the output tree.
|
|
94
|
+
if (/\\|\.\./.test(decoded)) bad("path traversal characters are not allowed");
|
|
95
|
+
// The fetched URL would differ from the file written.
|
|
96
|
+
if (/[?#]/.test(decoded)) bad("`?` and `#` are not allowed");
|
|
97
|
+
if (rank === 1 && decoded.includes("/")) bad("`/` is only allowed in a [...rest] param");
|
|
98
|
+
// An empty or "." part would land on the parent route's file.
|
|
99
|
+
if (decoded.split("/").some((part) => part === "" || part === "."))
|
|
100
|
+
bad("empty or `.` parts are not allowed");
|
|
101
|
+
return value;
|
|
102
|
+
})
|
|
103
|
+
.join("/");
|
|
67
104
|
}
|
|
68
105
|
|
|
69
106
|
/**
|
|
@@ -100,6 +137,11 @@ export function prerenderApiOutPath(routePath: string): string {
|
|
|
100
137
|
return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.json`;
|
|
101
138
|
}
|
|
102
139
|
|
|
140
|
+
/** A dynamic prerender route whose entries() can't be used. build.ts prints `message` and exits. */
|
|
141
|
+
export class PrerenderEntriesError extends Error {
|
|
142
|
+
override name = "PrerenderEntriesError";
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
/** Dynamic route → import module, call entries(), expand to concrete targets. */
|
|
104
146
|
async function expandDynamicRoute(
|
|
105
147
|
pattern: string,
|
|
@@ -109,10 +151,8 @@ async function expandDynamicRoute(
|
|
|
109
151
|
): Promise<PrerenderTarget[]> {
|
|
110
152
|
try {
|
|
111
153
|
const mod = await import(join(process.cwd(), filePath));
|
|
112
|
-
if (typeof mod.entries !== "function")
|
|
113
|
-
|
|
114
|
-
return [];
|
|
115
|
-
}
|
|
154
|
+
if (typeof mod.entries !== "function")
|
|
155
|
+
throw new Error("prerender = true on a dynamic route needs an entries() export");
|
|
116
156
|
const entryList: Record<string, string>[] = await mod.entries();
|
|
117
157
|
return entryList.map((entry) => ({
|
|
118
158
|
path: substituteParams(pattern, entry),
|
|
@@ -120,8 +160,9 @@ async function expandDynamicRoute(
|
|
|
120
160
|
trailingSlash: ts,
|
|
121
161
|
}));
|
|
122
162
|
} catch (err) {
|
|
123
|
-
|
|
124
|
-
|
|
163
|
+
// Fail the build: skipping the route would silently drop every page it lists.
|
|
164
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
165
|
+
throw new PrerenderEntriesError(`Prerender failed for ${pattern}: ${why}`, { cause: err });
|
|
125
166
|
}
|
|
126
167
|
}
|
|
127
168
|
|
|
@@ -137,7 +178,7 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
|
|
|
137
178
|
return [];
|
|
138
179
|
}
|
|
139
180
|
const ts = route.trailingSlash;
|
|
140
|
-
if (route.pattern
|
|
181
|
+
if (isDynamic(route.pattern)) return expandDynamicRoute(route.pattern, filePath, "page", ts);
|
|
141
182
|
return [{ path: route.pattern, kind: "page", trailingSlash: ts }];
|
|
142
183
|
});
|
|
143
184
|
|
|
@@ -145,7 +186,7 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
|
|
|
145
186
|
const filePath = join("src", "routes", route.server);
|
|
146
187
|
const content = await Bun.file(filePath).text();
|
|
147
188
|
if (!wantsPrerender(content)) return [];
|
|
148
|
-
if (route.pattern
|
|
189
|
+
if (isDynamic(route.pattern))
|
|
149
190
|
return expandDynamicRoute(route.pattern, filePath, "api", "never");
|
|
150
191
|
return [{ path: route.pattern, kind: "api", trailingSlash: "never" }];
|
|
151
192
|
});
|
|
@@ -157,23 +198,44 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
|
|
|
157
198
|
return results.flat();
|
|
158
199
|
}
|
|
159
200
|
|
|
160
|
-
export async function prerenderStaticRoutes(
|
|
201
|
+
export async function prerenderStaticRoutes(
|
|
202
|
+
manifest: RouteManifest,
|
|
203
|
+
target: RuntimeTarget = "bun",
|
|
204
|
+
): Promise<void> {
|
|
161
205
|
const targets = await detectPrerenderRoutes(manifest);
|
|
162
|
-
|
|
206
|
+
// On Workers the worker answers unknown paths, so 404.html is never required.
|
|
207
|
+
const require404 = target !== "workers" && needsStatic404();
|
|
208
|
+
// No routes to prerender: start the server only for the required 404.html.
|
|
209
|
+
if (targets.length === 0 && !require404) return;
|
|
163
210
|
|
|
164
|
-
console.log(
|
|
211
|
+
console.log(
|
|
212
|
+
targets.length
|
|
213
|
+
? `\n🖨️ Prerendering ${targets.length} route(s)...`
|
|
214
|
+
: "\n🖨️ Building 404.html (wrangler sets 404-page)...",
|
|
215
|
+
);
|
|
165
216
|
|
|
166
217
|
const port = await getEphemeralPort();
|
|
218
|
+
// No BOSIA_REUSE_PORT: the child must fail to bind rather than share a port
|
|
219
|
+
// with another server, whose pages we'd then save as ours. The ready loop
|
|
220
|
+
// stops once the child exits, but a server that grabbed the port first can
|
|
221
|
+
// still answer /_health before the child dies. Have /_health echo a nonce
|
|
222
|
+
// from the child's env if that race ever shows up.
|
|
223
|
+
const { BOSIA_REUSE_PORT: _, ...env } = process.env;
|
|
167
224
|
const child = Bun.spawn(["bun", "run", `${OUT_DIR}/server/index.js`], {
|
|
168
225
|
env: {
|
|
169
|
-
...
|
|
226
|
+
...env,
|
|
170
227
|
NODE_ENV: "production",
|
|
171
228
|
PORT: String(port),
|
|
172
229
|
NODE_PATH: BOSIA_NODE_PATH,
|
|
173
230
|
},
|
|
174
231
|
stdout: "ignore",
|
|
175
|
-
|
|
232
|
+
// Show render errors (a throwing +error.svelte, a crashing loader).
|
|
233
|
+
stderr: "inherit",
|
|
176
234
|
});
|
|
235
|
+
// exitCode stays null when a signal kills the child (OOM, segfault), so
|
|
236
|
+
// watch `exited` instead.
|
|
237
|
+
let exited = false;
|
|
238
|
+
child.exited.then(() => (exited = true));
|
|
177
239
|
|
|
178
240
|
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
|
179
241
|
let receivedSignal: NodeJS.Signals | null = null;
|
|
@@ -191,7 +253,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
191
253
|
const base = `http://localhost:${port}${currentBase()}`;
|
|
192
254
|
let ready = false;
|
|
193
255
|
const deadline = Date.now() + 10_000;
|
|
194
|
-
while (Date.now() < deadline) {
|
|
256
|
+
while (Date.now() < deadline && !exited) {
|
|
195
257
|
try {
|
|
196
258
|
const res = await fetch(`${base}/_health`);
|
|
197
259
|
if (res.ok) {
|
|
@@ -204,8 +266,14 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
204
266
|
await Bun.sleep(50);
|
|
205
267
|
}
|
|
206
268
|
|
|
207
|
-
if (!ready) {
|
|
208
|
-
|
|
269
|
+
if (!ready || exited) {
|
|
270
|
+
const why = child.signalCode ?? `exit code ${child.exitCode ?? "none"}`;
|
|
271
|
+
if (require404)
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Prerender server failed to start (${why}), so 404.html could not be built, but ` +
|
|
274
|
+
`wrangler sets not_found_handling "404-page". Fix the server, or add public/404.html.`,
|
|
275
|
+
);
|
|
276
|
+
console.error(`❌ Prerender server failed to start (${why})`);
|
|
209
277
|
return;
|
|
210
278
|
}
|
|
211
279
|
|
|
@@ -286,7 +354,38 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
286
354
|
Array.from({ length: Math.min(PRERENDER_CONCURRENCY, targets.length) }, worker),
|
|
287
355
|
);
|
|
288
356
|
|
|
289
|
-
|
|
357
|
+
// Static hosts (Cloudflare assets) serve index.html with a 200 for unknown
|
|
358
|
+
// paths unless a 404.html exists. Save the server's own 404 page while it's up.
|
|
359
|
+
// Goes to dist/static, not prerendered/, so the Bun server doesn't route /404
|
|
360
|
+
// to it, though it (and static hosts) still serve /404.html with a 200.
|
|
361
|
+
// Skipped on Workers: the worker answers unknown paths, and an uploaded file
|
|
362
|
+
// would only add that soft 404. A public/404.html overwrites it.
|
|
363
|
+
// One probe, one locale: add per-locale probe paths (e.g. /id/404.html) when asked.
|
|
364
|
+
if (target !== "workers")
|
|
365
|
+
try {
|
|
366
|
+
const res = await fetch(`${base}/__bosia-404-probe`, {
|
|
367
|
+
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
368
|
+
});
|
|
369
|
+
if (res.status !== 404) throw new Error(`expected a 404 page, got ${res.status}`);
|
|
370
|
+
// No +error.svelte (or it crashed) → plain-text "Not Found", not a page.
|
|
371
|
+
if (!res.headers.get("content-type")?.includes("text/html"))
|
|
372
|
+
throw new Error(`expected an HTML 404 page, got ${res.headers.get("content-type")}`);
|
|
373
|
+
mkdirSync(`${OUT_DIR}/static`, { recursive: true });
|
|
374
|
+
writeFileSync(`${OUT_DIR}/static/404.html`, await res.text());
|
|
375
|
+
console.log(` ✅ 404 page → ${OUT_DIR}/static/404.html`);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
// Only a deploy that serves 404.html needs it; elsewhere (an app whose
|
|
378
|
+
// hooks redirect every request) a missing one is fine, so just warn.
|
|
379
|
+
const reason = (err as Error).message;
|
|
380
|
+
if (require404)
|
|
381
|
+
throw new Error(
|
|
382
|
+
`404.html could not be built (${reason}), but wrangler sets ` +
|
|
383
|
+
`not_found_handling "404-page". Make unknown paths answer 404, or add public/404.html.`,
|
|
384
|
+
);
|
|
385
|
+
console.warn(` ⚠️ 404.html skipped: ${reason}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (targets.length) console.log("✅ Prerendering complete");
|
|
290
389
|
} finally {
|
|
291
390
|
for (const sig of signals) process.off(sig, onSignal);
|
|
292
391
|
child.kill();
|
package/src/core/renderer.ts
CHANGED
|
@@ -45,7 +45,7 @@ import { reportDevErrorFromCatch } from "./devErrorReport.ts";
|
|
|
45
45
|
import { dev500Response } from "./dev-500.ts";
|
|
46
46
|
import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
|
|
47
47
|
import { getAppHtmlSegments } from "./appHtml.ts";
|
|
48
|
-
import { hasTitle } from "./headScan.ts";
|
|
48
|
+
import { errorTitle, hasTitle } from "./headScan.ts";
|
|
49
49
|
import type { AppHtmlSegments } from "./appHtml.ts";
|
|
50
50
|
|
|
51
51
|
// Shared, stateless — one instance instead of a fresh allocation per stream.
|
|
@@ -1217,7 +1217,7 @@ export async function renderErrorPage(
|
|
|
1217
1217
|
// head, so a real title hasTitle misses still comes first), except when
|
|
1218
1218
|
// app.html has its own <title>: that would come first, so the status title
|
|
1219
1219
|
// takes the metadata slot ahead of it instead.
|
|
1220
|
-
const statusTitle =
|
|
1220
|
+
const statusTitle = errorTitle(status, message);
|
|
1221
1221
|
const errMeta = (head: string): Metadata | null =>
|
|
1222
1222
|
appHtmlHasTitle(appHtmlSegments) && !hasTitle(head) ? { title: statusTitle } : null;
|
|
1223
1223
|
|
package/src/core/routeFile.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFileSync, mkdirSync } from "fs";
|
|
|
2
2
|
import type { RouteManifest } from "./types.ts";
|
|
3
3
|
import { currentBase } from "./appBase.ts";
|
|
4
4
|
import { toPosix } from "./paths.ts";
|
|
5
|
+
import { segmentRank } from "./matcher.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The pattern the *client* router matches against. It sees real browser URLs, so
|
|
@@ -28,23 +29,26 @@ function clientPattern(pattern: string): string {
|
|
|
28
29
|
// serverRoutes — used by SSR renderer (+ pageServer + layoutServers)
|
|
29
30
|
// apiRoutes — used by API handler
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
32
|
+
/** Most specific first: compared segment by segment, the first differing kind
|
|
33
|
+
* decides (so `/label/[id]` beats `/[a]/[b]`, `/blog/[...r]` beats `/[a]/about`).
|
|
34
|
+
* Ties fall back to more segments first, then alphabetical. findMatch takes the
|
|
35
|
+
* first match, so this order is the routing precedence. */
|
|
36
|
+
export function sortRoutes<T extends { pattern: string }>(routes: T[]): T[] {
|
|
37
|
+
return routes
|
|
38
|
+
.map((route) => ({ route, ranks: route.pattern.split("/").filter(Boolean).map(segmentRank) }))
|
|
39
|
+
.sort((a, b) => {
|
|
40
|
+
const ra = a.ranks;
|
|
41
|
+
const rb = b.ranks;
|
|
42
|
+
for (let i = 0; i < Math.min(ra.length, rb.length); i++) {
|
|
43
|
+
if (ra[i] !== rb[i]) return ra[i]! - rb[i]!;
|
|
44
|
+
}
|
|
45
|
+
if (ra.length !== rb.length) return rb.length - ra.length;
|
|
46
|
+
// plain code-unit compare: same order on every machine, unlike localeCompare
|
|
47
|
+
const pa = a.route.pattern;
|
|
48
|
+
const pb = b.route.pattern;
|
|
49
|
+
return pa < pb ? -1 : pa > pb ? 1 : 0;
|
|
50
|
+
})
|
|
51
|
+
.map((x) => x.route);
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/** `defaultTitle` is what a page with no title of its own is called: app.html's
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import type { RouteManifest } from "./types.ts";
|
|
4
|
+
import { segmentName, segmentRank } from "./matcher.ts";
|
|
4
5
|
import { toPosix } from "./paths.ts";
|
|
5
6
|
|
|
6
7
|
// ─── Route Types Generator ────────────────────────────────
|
|
@@ -23,13 +24,7 @@ function paramsForDir(dir: string): string[] {
|
|
|
23
24
|
.filter((s) => !/^\(.*\)$/.test(s));
|
|
24
25
|
const out: string[] = [];
|
|
25
26
|
for (const seg of segs) {
|
|
26
|
-
|
|
27
|
-
if (catchAll) {
|
|
28
|
-
out.push(catchAll[1]!);
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
const dyn = seg.match(/^\[(\w+)\]$/);
|
|
32
|
-
if (dyn) out.push(dyn[1]!);
|
|
27
|
+
if (segmentRank(seg)) out.push(segmentName(seg));
|
|
33
28
|
}
|
|
34
29
|
return out;
|
|
35
30
|
}
|
|
@@ -76,7 +71,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
76
71
|
|
|
77
72
|
const params = paramsForDir(dir);
|
|
78
73
|
const paramsType =
|
|
79
|
-
params.length === 0
|
|
74
|
+
params.length === 0
|
|
75
|
+
? `{}`
|
|
76
|
+
: `{ ${params.map((p) => `${/^[A-Za-z_$][\w$]*$/.test(p) ? p : JSON.stringify(p)}: string`).join("; ")} }`;
|
|
80
77
|
|
|
81
78
|
const lines: string[] = ["// AUTO-GENERATED by bosia — do not edit\n"];
|
|
82
79
|
lines.push(`import type { LoadEvent, MetadataEvent, RequestEvent, Metadata } from 'bosia';`);
|
|
@@ -101,7 +98,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
101
98
|
|
|
102
99
|
if (info.pageServer) {
|
|
103
100
|
lines.push(``);
|
|
104
|
-
lines.push(
|
|
101
|
+
lines.push(
|
|
102
|
+
`import type { load as _pageLoad } from ${JSON.stringify(srcBase + "+page.server.ts")};`,
|
|
103
|
+
);
|
|
105
104
|
lines.push(`export type PageServerLoad = (event: _LoadEvent) => any;`);
|
|
106
105
|
lines.push(
|
|
107
106
|
`export type PageMetadataLoad = (event: _MetadataEvent) => Metadata | Promise<Metadata>;`,
|
|
@@ -131,7 +130,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
131
130
|
// ActionData — union of all action return types, unwrapping ActionFailure
|
|
132
131
|
if (info.pageServer) {
|
|
133
132
|
lines.push(``);
|
|
134
|
-
lines.push(
|
|
133
|
+
lines.push(
|
|
134
|
+
`import type { actions as _actions } from ${JSON.stringify(srcBase + "+page.server.ts")};`,
|
|
135
|
+
);
|
|
135
136
|
lines.push(
|
|
136
137
|
`type _ActionReturn<T> = T extends (...args: any[]) => infer R ? Awaited<R> : never;`,
|
|
137
138
|
);
|
|
@@ -144,7 +145,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
if (info.layoutServer) {
|
|
147
|
-
lines.push(
|
|
148
|
+
lines.push(
|
|
149
|
+
`\nimport type { load as _layoutLoad } from ${JSON.stringify(srcBase + "+layout.server.ts")};`,
|
|
150
|
+
);
|
|
148
151
|
lines.push(`export type LayoutServerLoad = (event: _LoadEvent) => any;`);
|
|
149
152
|
lines.push(`export type LayoutData = Awaited<ReturnType<typeof _layoutLoad>>;`);
|
|
150
153
|
lines.push(`export type LayoutProps = { data: LayoutData; params: Params; children: any };`);
|
package/src/core/scanner.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdirSync, existsSync, readFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import type { PageRoute, ApiRoute, RouteManifest, TrailingSlash } from "./types.ts";
|
|
4
|
+
import { segmentName, segmentRank } from "./matcher.ts";
|
|
4
5
|
|
|
5
6
|
// ─── Route Scanner ───────────────────────────────────────
|
|
6
7
|
// Walks src/routes/ and produces a RouteManifest.
|
|
@@ -192,19 +193,22 @@ export function scanRoutes(): RouteManifest {
|
|
|
192
193
|
|
|
193
194
|
walk("", [], [], [], [], "never", null);
|
|
194
195
|
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
196
|
+
// Invalid segments first, so they get their own message, not a conflict or warning.
|
|
197
|
+
checkParams(pages, (p) => p.page);
|
|
198
|
+
checkParams(apis, (a) => a.server);
|
|
199
|
+
|
|
200
|
+
// Warn when a trailing catch-all exists but no page covers its prefix.
|
|
201
|
+
// e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte),
|
|
202
|
+
// "/[lang]/[...rest]" needs "/[lang]" for "/en".
|
|
203
|
+
const pageKeys = new Set(pages.map((p) => routeKey(p.pattern)));
|
|
200
204
|
for (const p of pages) {
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
const exactEquivalent =
|
|
204
|
-
if (!
|
|
205
|
+
const cut = p.pattern.lastIndexOf("/");
|
|
206
|
+
if (segmentRank(p.pattern.slice(cut + 1)) === 2) {
|
|
207
|
+
const exactEquivalent = p.pattern.slice(0, cut) || "/";
|
|
208
|
+
if (!pageKeys.has(routeKey(exactEquivalent))) {
|
|
205
209
|
console.warn(
|
|
206
|
-
`⚠️
|
|
207
|
-
` Add a +page.svelte at
|
|
210
|
+
`⚠️ The catch-all "${p.pattern}" doesn't match "${exactEquivalent}" itself.\n` +
|
|
211
|
+
` Add a +page.svelte at "${exactEquivalent}" unless another route already serves it.`,
|
|
208
212
|
);
|
|
209
213
|
}
|
|
210
214
|
}
|
|
@@ -228,7 +232,7 @@ export function scanRoutes(): RouteManifest {
|
|
|
228
232
|
function preventConflicts<T extends { pattern: string }>(routes: T[], fileOf: (r: T) => string) {
|
|
229
233
|
const seen = new Map<string, T>();
|
|
230
234
|
for (const r of routes) {
|
|
231
|
-
const key = r.pattern
|
|
235
|
+
const key = routeKey(r.pattern);
|
|
232
236
|
const first = seen.get(key);
|
|
233
237
|
if (!first) {
|
|
234
238
|
seen.set(key, r);
|
|
@@ -244,7 +248,66 @@ function preventConflicts<T extends { pattern: string }>(routes: T[], fileOf: (r
|
|
|
244
248
|
}
|
|
245
249
|
}
|
|
246
250
|
|
|
247
|
-
|
|
251
|
+
const KINDS = ["", "[]", "[...]"];
|
|
252
|
+
|
|
253
|
+
/** The URL shape of a pattern, param names dropped: `/[lang]/[...rest]` → `/[]/[...]`. */
|
|
254
|
+
function routeKey(pattern: string): string {
|
|
255
|
+
return pattern.replace(/[^/]+/g, (seg) => {
|
|
256
|
+
const rank = segmentRank(seg);
|
|
257
|
+
return rank ? KINDS[rank]! : seg;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Fail on params the matcher can't serve: brackets that don't wrap a whole
|
|
263
|
+
* segment, a `[...rest]` that isn't the last segment, `__proto__`, or one name
|
|
264
|
+
* bound twice. Mid-path catch-alls (`docs/[...path]/edit`) are left out on
|
|
265
|
+
* purpose: each needs an unanchored `(.+)` (several backtrack for seconds on a
|
|
266
|
+
* long URL) and span-aware ranking.
|
|
267
|
+
* Add both together if that route shape is ever needed.
|
|
268
|
+
*/
|
|
269
|
+
function checkParams<T extends { pattern: string }>(routes: T[], fileOf: (r: T) => string) {
|
|
270
|
+
for (const r of routes) {
|
|
271
|
+
const segs = r.pattern.split("/").filter(Boolean);
|
|
272
|
+
const names = new Set<string>();
|
|
273
|
+
const fail = (problem: string) => {
|
|
274
|
+
throw new RouteConflictError(
|
|
275
|
+
`The "${r.pattern}" route is invalid: ${problem}.\n src/routes/${fileOf(r)}`,
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
for (const [i, seg] of segs.entries()) {
|
|
279
|
+
const rank = segmentRank(seg);
|
|
280
|
+
if (!rank) {
|
|
281
|
+
// `[...]`, `[.x]`, `[id].json`, `post-[slug]`, `[[lang]]` would match only their literal text.
|
|
282
|
+
if (/^\[(\.\.\.)?[^[\]/]*\]$/.test(seg))
|
|
283
|
+
fail(`${seg} needs a param name that doesn't start with a dot, like [slug] or [...rest]`);
|
|
284
|
+
if (/[[\]]/.test(seg)) fail(`${seg} must be a whole segment like [slug] or [...rest]`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const name = segmentName(seg);
|
|
288
|
+
if (rank === 2 && i !== segs.length - 1) fail(`${seg} must be the last part of the route`);
|
|
289
|
+
const dots = rank === 2 ? "..." : "";
|
|
290
|
+
if (name.includes("="))
|
|
291
|
+
fail(`${seg}: param matchers aren't supported, use [${dots}${name.split("=")[0]}]`);
|
|
292
|
+
if (name.endsWith("?"))
|
|
293
|
+
fail(`${seg}: optional params aren't supported, use [${dots}${name.slice(0, -1)}]`);
|
|
294
|
+
if (/^…|[.…]$/.test(name))
|
|
295
|
+
fail(`${seg}: did you mean [...${name.replace(/^…|[.…]+$/g, "")}]?`);
|
|
296
|
+
if (!PARAM_NAME.test(name))
|
|
297
|
+
fail(`${seg}: a param name uses letters, digits, _, $, - and inner dots only`);
|
|
298
|
+
// params is a plain object: assigning "__proto__" sets the prototype, not a key.
|
|
299
|
+
if (name === "__proto__") fail(`"__proto__" can't be a param name`);
|
|
300
|
+
if (names.has(name)) fail(`the param "${name}" is used twice`);
|
|
301
|
+
names.add(name);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// `[slüg]`, `[$id]`, `[my-path]`, `[file.name]`, `[1st]`; no spaces, quotes, `…` or edge dots.
|
|
307
|
+
const PARAM_NAME = /^[\p{L}\p{N}_$-]+(\.[\p{L}\p{N}_$-]+)*$/u;
|
|
308
|
+
|
|
309
|
+
/** A route tree the build refuses (two files at one URL, or an invalid param).
|
|
310
|
+
* Callers print `message` and exit. */
|
|
248
311
|
export class RouteConflictError extends Error {
|
|
249
312
|
override name = "RouteConflictError";
|
|
250
313
|
}
|
|
@@ -29,7 +29,7 @@ This page lives at routes/(public)/blog/[slug]/+page.svelte but is served at /bl
|
|
|
29
29
|
|
|
30
30
|
The matched value is available as params.slug inside +page.server.ts load() and inside the page component via the params prop.
|
|
31
31
|
|
|
32
|
-
The route matcher
|
|
32
|
+
The route matcher compares routes part by part: a fixed name beats a param, which beats a catch-all.`,
|
|
33
33
|
},
|
|
34
34
|
};
|
|
35
35
|
|