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.
- package/package.json +1 -1
- package/src/core/build.ts +10 -2
- package/src/core/client/App.svelte +363 -273
- 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/matcher.ts +52 -85
- package/src/core/prerender.ts +45 -25
- 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/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
|
|