bosia 1.2.2 → 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.
@@ -6,7 +6,7 @@
6
6
  // disabled because nothing is wired up until this action runs.
7
7
 
8
8
  import { appState } from "./appState.svelte.ts";
9
- import { router } from "./router.svelte.ts";
9
+ import { go } from "./navigation.ts";
10
10
 
11
11
  export type ActionResult =
12
12
  | { type: "success"; status: number; data: any }
@@ -37,7 +37,11 @@ async function applyResult(
37
37
  const invalidateAll = opts.invalidateAll !== false;
38
38
 
39
39
  if (result.type === "redirect") {
40
- router.navigate(result.location);
40
+ // The action changed data — maybe the session (login/logout) — so reload every
41
+ // loader, also on a redirect to this same page (post/redirect/get). Its result
42
+ // is spent: an earlier failure's message must not outlive it.
43
+ appState.form = null;
44
+ await go(result.location, { source: "form" }, invalidateAll);
41
45
  return;
42
46
  }
43
47
  if (result.type === "error") {
@@ -47,6 +47,9 @@ async function main() {
47
47
  }
48
48
  }
49
49
  router.currentRoute = window.location.pathname + window.location.search + window.location.hash;
50
+ // After canonicalizing, before any await: a click during the chunk loads below
51
+ // moves currentRoute, and App must still know which route the server rendered.
52
+ router.hydratedRoute = router.currentRoute;
50
53
  initPrefetch();
51
54
 
52
55
  let ssrPageComponent = null;
@@ -79,8 +82,10 @@ async function main() {
79
82
  // next client navigation can decide which loaders to skip without an
80
83
  // extra fetch round-trip.
81
84
  if (match) {
82
- const url = new URL(window.location.href);
83
- const ctx = liveContext(window.location.pathname, match.params, url);
85
+ // The server's route, not window.location: a click during the chunk loads
86
+ // above already moved the address bar on.
87
+ const url = new URL(router.hydratedRoute, window.location.origin);
88
+ const ctx = liveContext(url.pathname, match.params, url);
84
89
  const ssrPageDeps: LoaderDeps | null = (window as any).__BOSIA_PAGE_DEPS__ ?? null;
85
90
  const ssrLayoutDeps: (LoaderDeps | null)[] = (window as any).__BOSIA_LAYOUT_DEPS__ ?? [];
86
91
  const pageId = (match.route as any).pageId as string | null;
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { onDestroy } from "svelte";
14
14
  import { appState } from "./appState.svelte.ts";
15
- import { router } from "./router.svelte.ts";
15
+ import { router, type NavType } from "./router.svelte.ts";
16
16
  import {
17
17
  afterListeners,
18
18
  beforeListeners,
@@ -106,7 +106,7 @@ export function invalidateAll(): Promise<void> {
106
106
  export interface GotoOptions {
107
107
  /** Use `history.replaceState` instead of `history.pushState`. */
108
108
  replaceState?: boolean;
109
- /** Mark every loader dirty so the next nav re-runs all of them. */
109
+ /** Re-run every loader for the destination — also when it is the current URL. */
110
110
  invalidateAll?: boolean;
111
111
  /** Skip the default scroll-to-top after navigation. */
112
112
  noScroll?: boolean;
@@ -118,41 +118,60 @@ export interface GotoOptions {
118
118
 
119
119
  /**
120
120
  * Navigate to `url` via the client router. Returns a Promise that resolves
121
- * after the navigation has settled (loaders ran, components mounted) or
122
- * immediately if the URL matches the current route.
121
+ * after the navigation has settled (loaders ran, components mounted). On the
122
+ * current URL it resolves immediately — unless `invalidateAll` is set, then
123
+ * after the loaders re-ran in place.
123
124
  */
124
125
  export function goto(url: string, opts: GotoOptions = {}): Promise<void> {
125
- if (typeof window === "undefined") return Promise.resolve();
126
-
127
- if (opts.invalidateAll) {
128
- appState.dirty.all = true;
129
- }
130
- if (opts.noScroll) {
131
- router.suppressScroll = true;
132
- }
126
+ return go(
127
+ url,
128
+ { replace: opts.replaceState, noScroll: opts.noScroll, source: "goto" },
129
+ !!opts.invalidateAll,
130
+ );
131
+ }
133
132
 
134
- const beforePath = router.currentRoute;
133
+ /** Internal — `goto()` and `use:enhance` redirects. `reload` re-runs every loader (enhance: unless `update({ invalidateAll: false })`). */
134
+ export function go(
135
+ url: string,
136
+ opts: { replace?: boolean; noScroll?: boolean; source?: NavType },
137
+ reload: boolean,
138
+ ): Promise<void> {
139
+ if (typeof window === "undefined") return Promise.resolve();
135
140
 
136
141
  return new Promise<void>((resolve) => {
137
- appState.navResolvers.push(resolve);
138
- router.navigate(url, { replace: opts.replaceState, source: "goto" });
139
- // `navigate()` short-circuits when `currentRoute === path` — the nav
140
- // effect won't fire, so nothing will drain the resolver. Resolve now.
141
- if (router.currentRoute === beforePath) {
142
- drainNavResolvers();
143
- }
142
+ const result = router.navigate(url, { replace: opts.replace, source: opts.source });
143
+ // The nav effect runs after this, so marks set now still reach it — and a nav
144
+ // that never starts (cancelled, left the app) leaves none behind for the next one.
145
+ if (result === "started" && opts.noScroll) router.suppressScroll = true;
146
+ const refetch = reload && (result === "started" || result === "same");
147
+ // The tick wakes the effect on the same URL, and on a new one it keeps a
148
+ // prefetch holding pre-mutation data from standing in for the refetch.
149
+ if (refetch) invalidateAll();
150
+ // "same" while a nav to it is still loading waits for it too.
151
+ const wait = result === "started" || (result === "same" && (reload || router.loading));
152
+ // Otherwise nothing will settle: resolve just this call — draining the queue
153
+ // would also release a nav still loading.
154
+ if (wait) appState.navResolvers.push(resolve);
155
+ else resolve();
144
156
  });
145
157
  }
146
158
 
147
159
  /** Internal — App.svelte calls this after each nav effect settles. */
148
- export function drainNavResolvers(): void {
160
+ // Takes the waiters now (a nav started meanwhile queues its own), releases them
161
+ // once `after` (the settling nav's scroll) is done.
162
+ export function drainNavResolvers(after?: Promise<unknown>): void {
149
163
  const queue = appState.navResolvers;
150
164
  appState.navResolvers = [];
151
- for (const fn of queue) {
152
- try {
153
- fn();
154
- } catch (err) {
155
- console.warn("[bosia] navResolver threw", err);
165
+ const run = () => {
166
+ for (const fn of queue) {
167
+ try {
168
+ fn();
169
+ } catch (err) {
170
+ console.warn("[bosia] navResolver threw", err);
171
+ }
156
172
  }
157
- }
173
+ };
174
+ // A throwing flush rejects the scroll; the waiters must not hang on it.
175
+ if (after) after.then(run, run);
176
+ else run();
158
177
  }
@@ -14,6 +14,9 @@ import { withBase } from "../basePath.ts";
14
14
  // pushes exactly the URL that was in the link, untouched.
15
15
 
16
16
  export type NavType = "link" | "goto" | "popstate" | "form" | "enter";
17
+ /** What `router.navigate()` did: started a client nav, found the URL already current,
18
+ * was cancelled by `beforeNavigate`, or handed off to a full page load. */
19
+ export type NavResult = "started" | "same" | "cancelled" | "left";
17
20
 
18
21
  function buildTarget(path: string): { url: URL; params: Record<string, string> } | null {
19
22
  if (typeof window === "undefined") return null;
@@ -94,8 +97,13 @@ export const router = new (class Router {
94
97
  /** Captured snapshot for the entry a popstate/reload landed on; consumed once by App.svelte. `undefined` = nothing to restore. */
95
98
  pendingSnapshot: unknown = undefined;
96
99
 
97
- navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}) {
98
- if (this.currentRoute === path) return;
100
+ /** The route the server rendered (canonical), recorded by hydrate.ts before App mounts. */
101
+ hydratedRoute = "";
102
+ /** True while App's nav effect is loading a run; `goto()` to the current URL waits on it. */
103
+ loading = false;
104
+
105
+ navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}): NavResult {
106
+ if (this.currentRoute === path) return "same";
99
107
  // Unknown route — let the server handle it (renders +error.svelte with 404)
100
108
  const queryHash = path.slice(path.split("?")[0].split("#")[0].length);
101
109
  const pathname = path.split("?")[0].split("#")[0];
@@ -114,12 +122,16 @@ export const router = new (class Router {
114
122
  `[bosia] "${path}" matched no route and is leaving the app — it is mounted at "${base}". Use "${withBase(base, path)}".`,
115
123
  );
116
124
  }
117
- window.location.href = path;
118
- return;
125
+ // A replace (loader redirect) must not leave a Back-button trap behind.
126
+ if (opts.replace) window.location.replace(path);
127
+ else window.location.href = path;
128
+ return "left";
119
129
  }
120
130
  // Canonicalize trailing slash before navigating (matches server 308 behavior)
121
131
  const canonical = canonicalPathname(pathname, (match.route as any).trailingSlash ?? "never");
122
132
  const finalPath = canonical !== null ? canonical + queryHash : path;
133
+ // `/about/` on `/about`: nothing to do, and no duplicate history entry.
134
+ if (this.currentRoute === finalPath) return "same";
123
135
 
124
136
  const navType: NavType = opts.source ?? "link";
125
137
  const fromTarget = buildTarget(this.currentRoute);
@@ -131,11 +143,12 @@ export const router = new (class Router {
131
143
  willUnload: false,
132
144
  cancel: () => {},
133
145
  };
134
- if (!fireBeforeNavigate(nav)) return;
146
+ if (!fireBeforeNavigate(nav)) return "cancelled";
135
147
 
136
148
  this.lastNavType = navType;
137
149
  this.isPush = true;
138
150
  this.pendingScroll = null;
151
+ this.suppressScroll = false; // a superseded noScroll goto's flag; go() re-sets its own after
139
152
  this.pendingSnapshot = undefined;
140
153
  this.currentRoute = finalPath;
141
154
  if (typeof history !== "undefined") {
@@ -148,6 +161,10 @@ export const router = new (class Router {
148
161
  history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
149
162
  }
150
163
  }
164
+ // Set now, not when App's effect runs a microtask later: a same-tick goto() to
165
+ // this URL must already see it loading.
166
+ this.loading = true;
167
+ return "started";
151
168
  }
152
169
 
153
170
  init() {
@@ -202,6 +219,7 @@ export const router = new (class Router {
202
219
  savePageSnapshot();
203
220
  historyIndex++;
204
221
  history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
222
+ this.suppressScroll = false;
205
223
  this.currentRoute = finalPath;
206
224
  }
207
225
  scrollToHash(anchor.hash);
@@ -238,6 +256,7 @@ export const router = new (class Router {
238
256
 
239
257
  this.lastNavType = "popstate";
240
258
  this.isPush = false;
259
+ this.suppressScroll = false;
241
260
  this.currentRoute = finalPath;
242
261
  });
243
262
 
@@ -4,10 +4,8 @@ import type { RouteMatch, TrailingSlash } from "./types.ts";
4
4
  // Single shared matcher used by both client and server at runtime.
5
5
  // Replaces the old code-generated match functions.
6
6
  //
7
- // Matching priority (same as old / SvelteKit):
8
- // 1. Exact match — "/about" === "/about"
9
- // 2. Dynamic match — "/blog/[slug]" matches "/blog/hello"
10
- // 3. Catch-all match — "/[...rest]" matches anything
7
+ // Routes arrive sorted by sortRoutes (routeFile.ts): segment by segment,
8
+ // static beats "[param]" beats "[...rest]" (same as SvelteKit). First match wins.
11
9
 
12
10
  // ─── Compiled Route Types ────────────────────────────────
13
11
 
@@ -19,6 +17,28 @@ interface CompiledRoute {
19
17
 
20
18
  // ─── Pattern Compiler ────────────────────────────────────
21
19
 
20
+ // Brackets wrap the whole segment; the name has no brackets or slash and doesn't
21
+ // start with a dot (so `[...]` isn't a param named "..."). Anything else —
22
+ // `[id].json`, `[[lang]]` — is static here, and the scanner fails the build on it.
23
+ const SEGMENT = /^\[(\.\.\.)?([^[\]/.][^[\]/]*)\]$/;
24
+
25
+ /** How every part of Bosia reads one route segment (matcher, sortRoutes, scanner):
26
+ * 0 static, 1 `[param]`, 2 `[...rest]`. Doubles as the sort rank. */
27
+ export function segmentRank(seg: string): 0 | 1 | 2 {
28
+ const m = SEGMENT.exec(seg);
29
+ return !m ? 0 : m[1] ? 2 : 1;
30
+ }
31
+
32
+ /** The param name a dynamic segment binds: `[slug]` → `slug`, `[...rest]` → `rest`. */
33
+ export function segmentName(seg: string): string {
34
+ return SEGMENT.exec(seg)?.[2] ?? "";
35
+ }
36
+
37
+ /** True when any segment of the pattern is a param or catch-all. */
38
+ export function isDynamic(pattern: string): boolean {
39
+ return pattern.split("/").some((seg) => segmentRank(seg) > 0);
40
+ }
41
+
22
42
  /** Escape regex special chars in a literal string segment. */
23
43
  function escapeRegex(s: string): string {
24
44
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -29,30 +49,25 @@ function escapeRegex(s: string): string {
29
49
  */
30
50
  function compilePattern(pattern: string): CompiledRoute {
31
51
  // No dynamic segments — exact match via ===
32
- if (!pattern.includes("[")) {
52
+ if (!isDynamic(pattern)) {
33
53
  return { regex: null!, paramNames: [], isExact: true };
34
54
  }
35
55
 
36
56
  const paramNames: string[] = [];
37
57
 
38
- // Catch-all: /prefix/[...name]
39
- const catchallMatch = pattern.match(/^(.*?)\/\[\.\.\.(\w+)\]$/);
40
- if (catchallMatch) {
41
- const prefix = catchallMatch[1] || "";
42
- paramNames.push(catchallMatch[2]!);
43
- const escaped = prefix ? escapeRegex(prefix) : "";
44
- // Root catch-all /[...rest] must have at least one char after /
45
- const regex = prefix ? new RegExp(`^${escaped}\\/(.+)$`) : new RegExp(`^\\/(.+)$`);
46
- return { regex, paramNames, isExact: false };
47
- }
48
-
49
- // Dynamic segments: /blog/[slug]/comments → ^\/blog\/([^/]+)\/comments$
58
+ // /blog/[slug]/comments → ^\/blog\/([^/]+)\/comments$
59
+ // A trailing [...rest] takes the rest of the path: /[lang]/[...rest]. Only the
60
+ // last segment gets (.+), so a hand-built table can't nest them (ReDoS); the
61
+ // scanner fails the build on a non-last catch-all anyway.
50
62
  const segments = pattern.split("/").filter(Boolean);
51
63
  let regexStr = "^";
52
- for (const seg of segments) {
64
+ for (const [i, seg] of segments.entries()) {
53
65
  regexStr += "\\/";
54
- if (seg.startsWith("[") && seg.endsWith("]")) {
55
- paramNames.push(seg.slice(1, -1));
66
+ const rank = segmentRank(seg);
67
+ if (rank) paramNames.push(segmentName(seg));
68
+ if (rank === 2 && i === segments.length - 1) {
69
+ regexStr += "(.+)";
70
+ } else if (rank) {
56
71
  regexStr += "([^/]+)";
57
72
  } else {
58
73
  regexStr += escapeRegex(seg);
@@ -66,7 +81,7 @@ function compilePattern(pattern: string): CompiledRoute {
66
81
  // Exact routes bucketed by pathname, keyed by the routes-array identity so
67
82
  // findMatch can O(1) Map.get before linearly scanning the dynamic/catch-all
68
83
  // remainder. WeakMap keeps it tied to whichever array (server/api/client) was
69
- // compiled; uncompiled arrays simply fall back to the full linear scan.
84
+ // compiled; findMatch compiles an array it hasn't seen on first lookup.
70
85
  const exactMaps = new WeakMap<object, Map<string, unknown>>();
71
86
 
72
87
  /**
@@ -82,62 +97,13 @@ export function compileRoutes<T extends { pattern: string }>(
82
97
  const compiled = compilePattern(route.pattern);
83
98
  (route as any)._compiled = compiled;
84
99
  // Exact patterns contain no dynamic segments, so pattern === pathname.
85
- if (compiled.isExact) exact.set(route.pattern, route);
100
+ // First wins, like the sorted scan.
101
+ if (compiled.isExact && !exact.has(route.pattern)) exact.set(route.pattern, route);
86
102
  }
87
103
  exactMaps.set(routes, exact);
88
104
  return routes as (T & { _compiled: CompiledRoute })[];
89
105
  }
90
106
 
91
- // ─── Legacy Pattern Matcher (fallback for uncompiled routes) ─
92
-
93
- /**
94
- * Match a URL pathname against a single route pattern.
95
- * Returns extracted params if matched, null otherwise.
96
- */
97
- function matchPattern(pattern: string, pathname: string): Record<string, string> | null {
98
- // Strip trailing slash (but keep "/" as-is)
99
- if (pathname.length > 1 && pathname.endsWith("/")) {
100
- pathname = pathname.slice(0, -1);
101
- }
102
-
103
- // Exact match
104
- if (pattern === pathname) return {};
105
-
106
- // Catch-all pattern: /[...name] or /prefix/[...name]
107
- const catchallMatch = pattern.match(/^(.*?)\/\[\.\.\.(\w+)\]$/);
108
- if (catchallMatch) {
109
- const prefix = catchallMatch[1] || "";
110
- const paramName = catchallMatch[2]!;
111
- if (prefix === "" || pathname.startsWith(prefix + "/") || pathname === prefix) {
112
- const rest = prefix ? pathname.slice(prefix.length + 1) : pathname.slice(1);
113
- // Don't let a root catch-all match "/" with an empty slug.
114
- // If you want the catch-all to also serve "/", add an explicit +page.svelte at the root.
115
- if (!prefix && rest === "") return null;
116
- return { [paramName]: rest };
117
- }
118
- return null;
119
- }
120
-
121
- // Dynamic segments: must have same segment count
122
- if (!pattern.includes("[")) return null;
123
-
124
- const patParts = pattern.split("/").filter(Boolean);
125
- const pathParts = pathname.split("/").filter(Boolean);
126
- if (patParts.length !== pathParts.length) return null;
127
-
128
- const params: Record<string, string> = {};
129
- for (let i = 0; i < patParts.length; i++) {
130
- const pp = patParts[i]!;
131
- const val = pathParts[i]!;
132
- if (pp.startsWith("[") && pp.endsWith("]")) {
133
- params[pp.slice(1, -1)] = val;
134
- } else if (pp !== val) {
135
- return null;
136
- }
137
- }
138
- return params;
139
- }
140
-
141
107
  // ─── Route Matching ──────────────────────────────────────
142
108
 
143
109
  /**
@@ -165,32 +131,33 @@ function matchCompiled(
165
131
 
166
132
  /**
167
133
  * Find the first matching route from a list.
168
- * Routes must be pre-sorted by priority (exact → dynamic → catch-all).
134
+ * Routes must be pre-sorted by sortRoutes (most specific segment first).
169
135
  * Single pass — first match wins.
170
136
  */
171
137
  export function findMatch<T extends { pattern: string }>(
172
138
  routes: T[],
173
139
  pathname: string,
174
140
  ): RouteMatch<T> | null {
175
- // Strip trailing slash (but keep "/" as-is)
141
+ // Strip one trailing slash (but keep "/" as-is), same as canonicalPathname.
176
142
  if (pathname.length > 1 && pathname.endsWith("/")) {
177
143
  pathname = pathname.slice(0, -1);
178
144
  }
179
145
 
180
- // Exact-match fast path: one Map.get instead of scanning every exact route.
181
- const exact = exactMaps.get(routes) as Map<string, T> | undefined;
182
- if (exact) {
183
- const hit = exact.get(pathname);
184
- if (hit) return { route: hit, params: {} };
146
+ // Arrays that skipped compileRoutes get compiled on first lookup.
147
+ let exact = exactMaps.get(routes) as Map<string, T> | undefined;
148
+ if (!exact) {
149
+ compileRoutes(routes);
150
+ exact = exactMaps.get(routes) as Map<string, T>;
185
151
  }
186
152
 
153
+ // Exact-match fast path: one Map.get instead of scanning every exact route.
154
+ const hit = exact.get(pathname);
155
+ if (hit) return { route: hit, params: {} };
156
+
187
157
  for (const route of routes) {
188
- const compiled = (route as any)._compiled as CompiledRoute | undefined;
189
- // Exact routes already covered by the Map lookup above — skip them.
190
- if (exact && compiled?.isExact) continue;
191
- const params = compiled
192
- ? matchCompiled(compiled, route.pattern, pathname)
193
- : matchPattern(route.pattern, pathname);
158
+ // A route pushed after compileRoutes compiles here, once.
159
+ const compiled: CompiledRoute = ((route as any)._compiled ??= compilePattern(route.pattern));
160
+ const params = matchCompiled(compiled, route.pattern, pathname);
194
161
  if (params !== null) return { route, params };
195
162
  }
196
163
 
@@ -7,6 +7,7 @@ import type { RuntimeTarget } from "./types/plugin.ts";
7
7
  import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
8
8
  import { currentBase } from "./appBase.ts";
9
9
  import { prerenderSkipReason, wantsPrerender } from "./scanner.ts";
10
+ import { isDynamic, segmentName, segmentRank } from "./matcher.ts";
10
11
 
11
12
  /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
12
13
  export function getEphemeralPort(): Promise<number> {
@@ -68,23 +69,38 @@ interface PrerenderTarget {
68
69
  * concrete values from an `entries()` record.
69
70
  */
70
71
  export function substituteParams(pattern: string, entry: Record<string, string>): string {
71
- let resolved = pattern;
72
- for (const [key, value] of Object.entries(entry)) {
73
- // `..` and `\` are never legitimate in a route segment — they let a build
74
- // emit prerendered HTML outside the intended output tree. Forward slashes
75
- // are only allowed for catch-all (`[...key]`) segments, which by design
76
- // expand to multiple path parts. Validate accordingly.
77
- const isRest = pattern.includes(`[...${key}]`);
78
- if (/\\|\.\./.test(value) || (!isRest && value.includes("/"))) {
79
- throw new Error(
80
- `Prerender entries(): unsafe value "${value}" for [${key}] — ` +
81
- `path traversal characters are not allowed in dynamic segment values.`,
82
- );
83
- }
84
- resolved = resolved.replace(`[...${key}]`, value);
85
- resolved = resolved.replace(`[${key}]`, value);
86
- }
87
- 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("/");
88
104
  }
89
105
 
90
106
  /**
@@ -121,6 +137,11 @@ export function prerenderApiOutPath(routePath: string): string {
121
137
  return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.json`;
122
138
  }
123
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
+
124
145
  /** Dynamic route → import module, call entries(), expand to concrete targets. */
125
146
  async function expandDynamicRoute(
126
147
  pattern: string,
@@ -130,10 +151,8 @@ async function expandDynamicRoute(
130
151
  ): Promise<PrerenderTarget[]> {
131
152
  try {
132
153
  const mod = await import(join(process.cwd(), filePath));
133
- if (typeof mod.entries !== "function") {
134
- console.warn(` ⚠️ ${pattern} has prerender=true but no entries() export — skipped`);
135
- return [];
136
- }
154
+ if (typeof mod.entries !== "function")
155
+ throw new Error("prerender = true on a dynamic route needs an entries() export");
137
156
  const entryList: Record<string, string>[] = await mod.entries();
138
157
  return entryList.map((entry) => ({
139
158
  path: substituteParams(pattern, entry),
@@ -141,8 +160,9 @@ async function expandDynamicRoute(
141
160
  trailingSlash: ts,
142
161
  }));
143
162
  } catch (err) {
144
- console.error(` ❌ Failed to resolve entries() for ${pattern}:`, err);
145
- 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 });
146
166
  }
147
167
  }
148
168
 
@@ -158,7 +178,7 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
158
178
  return [];
159
179
  }
160
180
  const ts = route.trailingSlash;
161
- if (route.pattern.includes("[")) return expandDynamicRoute(route.pattern, filePath, "page", ts);
181
+ if (isDynamic(route.pattern)) return expandDynamicRoute(route.pattern, filePath, "page", ts);
162
182
  return [{ path: route.pattern, kind: "page", trailingSlash: ts }];
163
183
  });
164
184
 
@@ -166,7 +186,7 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
166
186
  const filePath = join("src", "routes", route.server);
167
187
  const content = await Bun.file(filePath).text();
168
188
  if (!wantsPrerender(content)) return [];
169
- if (route.pattern.includes("["))
189
+ if (isDynamic(route.pattern))
170
190
  return expandDynamicRoute(route.pattern, filePath, "api", "never");
171
191
  return [{ path: route.pattern, kind: "api", trailingSlash: "never" }];
172
192
  });
@@ -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