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.
@@ -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
- let resolved = pattern;
51
- for (const [key, value] of Object.entries(entry)) {
52
- // `..` and `\` are never legitimate in a route segment — they let a build
53
- // emit prerendered HTML outside the intended output tree. Forward slashes
54
- // are only allowed for catch-all (`[...key]`) segments, which by design
55
- // expand to multiple path parts. Validate accordingly.
56
- const isRest = pattern.includes(`[...${key}]`);
57
- if (/\\|\.\./.test(value) || (!isRest && value.includes("/"))) {
58
- throw new Error(
59
- `Prerender entries(): unsafe value "${value}" for [${key}] — ` +
60
- `path traversal characters are not allowed in dynamic segment values.`,
61
- );
62
- }
63
- resolved = resolved.replace(`[...${key}]`, value);
64
- resolved = resolved.replace(`[${key}]`, value);
65
- }
66
- return resolved;
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
- console.warn(` ⚠️ ${pattern} has prerender=true but no entries() export — skipped`);
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
- console.error(` ❌ Failed to resolve entries() for ${pattern}:`, err);
124
- return [];
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.includes("[")) return expandDynamicRoute(route.pattern, filePath, "page", ts);
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.includes("["))
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(manifest: RouteManifest): Promise<void> {
201
+ export async function prerenderStaticRoutes(
202
+ manifest: RouteManifest,
203
+ target: RuntimeTarget = "bun",
204
+ ): Promise<void> {
161
205
  const targets = await detectPrerenderRoutes(manifest);
162
- if (targets.length === 0) return;
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(`\n🖨️ Prerendering ${targets.length} route(s)...`);
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
- ...process.env,
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
- stderr: "ignore",
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
- console.error("❌ Prerender server failed to start");
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
- console.log("✅ Prerendering complete");
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();
@@ -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 = `${status} — ${message}`;
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
 
@@ -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
- function routePriority(pattern: string): number {
32
- if (!pattern.includes("[")) return 0; // exact
33
- if (!pattern.includes("[...")) return 1; // dynamic
34
- return 2; // catch-all
35
- }
36
-
37
- function sortRoutes<T extends { pattern: string }>(routes: T[]): T[] {
38
- return [...routes].sort((a, b) => {
39
- const pa = routePriority(a.pattern);
40
- const pb = routePriority(b.pattern);
41
- if (pa !== pb) return pa - pb;
42
- // same tier: more segments first, then alphabetical
43
- const sa = a.pattern.split("/").length;
44
- const sb = b.pattern.split("/").length;
45
- if (sa !== sb) return sb - sa;
46
- return a.pattern.localeCompare(b.pattern);
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
@@ -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
- const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
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 ? `{}` : `{ ${params.map((p) => `${p}: string`).join("; ")} }`;
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(`import type { load as _pageLoad } from '${srcBase}+page.server.ts';`);
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(`import type { actions as _actions } from '${srcBase}+page.server.ts';`);
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(`\nimport type { load as _layoutLoad } from '${srcBase}+layout.server.ts';`);
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 };`);
@@ -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
- // Warn when a catch-all exists but no exact route covers its prefix.
196
- // e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
197
- const exactPatterns = new Set(
198
- pages.filter((p) => !p.pattern.includes("[")).map((p) => p.pattern),
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 m = p.pattern.match(/^(.*?)\/\[\.\.\.(\w+)\]$/);
202
- if (m) {
203
- const exactEquivalent = m[1] || "/";
204
- if (!exactPatterns.has(exactEquivalent)) {
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
- `⚠️ No exact route for "${exactEquivalent}" — the catch-all "${p.pattern}" will NOT match it.\n` +
207
- ` Add a +page.svelte at the "${exactEquivalent}" level to serve that URL.`,
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.replace(/\[\.\.\.\w+\]/g, "[...]").replace(/\[\w+\]/g, "[]");
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
- /** Two route files resolve to the same URL. Callers print `message` and exit. */
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 uses 3-pass priority: exact matches first, then dynamic segments, then catch-all routes.`,
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