@webjsdev/server 0.8.11 → 0.8.12

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/src/importmap.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { digestHex } from './crypto-utils.js';
4
4
  import { jsonForScriptTag } from './script-tag-json.js';
5
+ import { withBasePath } from './base-path.js';
5
6
 
6
7
  // Local attribute escaper. Matches ssr.js's escapeAttr (the source
7
8
  // of truth for HTML attribute escaping in this package). Kept inline
@@ -26,6 +27,45 @@ function escapeAttr(s) {
26
27
  /** @type {Record<string, string>} */
27
28
  let _extraEntries = {};
28
29
 
30
+ /**
31
+ * The normalized `webjs.basePath` for a sub-path deployment (issue #256),
32
+ * `''` (the default) for a root mount. When non-empty, every same-origin
33
+ * absolute importmap TARGET (the `/__webjs/core/*` core entries and any
34
+ * same-origin `/__webjs/vendor/*` local vendor target) is prefixed with it
35
+ * so module resolution works under the prefix. A cross-origin `https://`
36
+ * CDN vendor target is absolute and is left untouched. Set once at boot by
37
+ * `setBasePath`, which recomputes the importmap hash so `importMapHash()`
38
+ * stays synchronous on the hot path.
39
+ *
40
+ * @type {string}
41
+ */
42
+ let _basePath = '';
43
+
44
+ /**
45
+ * Bind the importmap to a sub-path deployment's base path (issue #256).
46
+ * Called once at boot by `dev.js`. With the empty default the map is
47
+ * byte-identical to a root mount. The importmap hash is recomputed eagerly
48
+ * (like `setCoreInstall` / `setVendorEntries`) so `importMapHash()` stays
49
+ * synchronous on the per-request SSR path.
50
+ *
51
+ * @param {string} basePath the normalized base path (`''` = root mount)
52
+ * @returns {Promise<void>}
53
+ */
54
+ export async function setBasePath(basePath) {
55
+ _basePath = basePath || '';
56
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
57
+ }
58
+
59
+ /**
60
+ * The active base path, for callers that prefix their own emitted URLs
61
+ * against the same value (ssr.js's boot specifiers, preloads, reload src).
62
+ *
63
+ * @returns {string}
64
+ */
65
+ export function basePath() {
66
+ return _basePath;
67
+ }
68
+
29
69
  /**
30
70
  * SRI integrity hashes keyed by FINAL URL (post-importmap-rewrite).
31
71
  * Populated only when a pin file with `integrity` is present;
@@ -292,7 +332,12 @@ export function buildImportMap() {
292
332
  // even though the content didn't actually change.
293
333
  /** @type {Record<string, string>} */
294
334
  const imports = {};
295
- for (const k of Object.keys(merged).sort()) imports[k] = merged[k];
335
+ // Prefix every same-origin absolute target with the sub-path base
336
+ // (issue #256). `withBasePath` is a no-op when the base path is empty
337
+ // (so a root-mounted app's map is byte-identical) and leaves a
338
+ // cross-origin `https://` CDN vendor target untouched (only the
339
+ // framework's own `/__webjs/*` and same-origin vendor targets move).
340
+ for (const k of Object.keys(merged).sort()) imports[k] = withBasePath(merged[k], _basePath);
296
341
 
297
342
  // Emit `integrity` per the importmap-integrity spec (Chrome 132+,
298
343
  // Safari 18.4+, Firefox flagged). Browsers without support ignore
@@ -305,11 +350,17 @@ export function buildImportMap() {
305
350
  // unrelated pin file edits, and leak removed URLs to the wire.
306
351
  const out = { imports };
307
352
  const usedUrls = new Set(Object.values(imports));
308
- const intKeys = Object.keys(_vendorIntegrity).filter(k => usedUrls.has(k)).sort();
353
+ // Integrity keys are the FINAL post-rewrite URLs, so prefix a same-origin
354
+ // local vendor key with the base path to match its (now prefixed) imports
355
+ // value. A cross-origin CDN key is untouched by `withBasePath` and lines
356
+ // up with its unprefixed imports value.
357
+ const intKeys = Object.keys(_vendorIntegrity)
358
+ .filter(k => usedUrls.has(withBasePath(k, _basePath)))
359
+ .sort();
309
360
  if (intKeys.length) {
310
361
  /** @type {Record<string, string>} */
311
362
  const integrity = {};
312
- for (const k of intKeys) integrity[k] = _vendorIntegrity[k];
363
+ for (const k of intKeys) integrity[withBasePath(k, _basePath)] = _vendorIntegrity[k];
313
364
  out.integrity = integrity;
314
365
  }
315
366
  return out;
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Declarative permanent / temporary redirects (issue #254).
3
+ *
4
+ * webjs already ships `redirect(url)` (a per-request throw sentinel) for
5
+ * imperative, request-time redirects. This module adds the missing
6
+ * DECLARATIVE surface: a config of old-path -> new-path rules an app
7
+ * declares once and the framework applies at the very start of request
8
+ * handling, before routing / SSR / asset serving. This is what SEO wants
9
+ * for a moved URL, a permanent 308 (or legacy 301) so link equity
10
+ * transfers and search engines update their index.
11
+ *
12
+ * Config lives in `package.json` -> `webjs.redirects`, an array of
13
+ * `{ source, destination, permanent?, statusCode? }`, cohesive with the
14
+ * #232 `webjs.headers` config (same `source` URLPattern matching, same
15
+ * fail-safe "a malformed entry is dropped at config-load, never a
16
+ * throw" posture, patterns compiled ONCE at boot):
17
+ *
18
+ * "webjs": { "redirects": [
19
+ * { "source": "/old", "destination": "/new" },
20
+ * { "source": "/blog/:slug", "destination": "/posts/:slug" },
21
+ * { "source": "/legacy", "destination": "/", "permanent": false },
22
+ * { "source": "/docs", "destination": "https://docs.example.com" }
23
+ * ] }
24
+ *
25
+ * Status code. `permanent` defaults to true (308 Permanent Redirect);
26
+ * `permanent: false` is 307 (Temporary Redirect). 308 / 307 are the
27
+ * MODERN choice because they preserve the request method and body
28
+ * (a redirected POST stays a POST). The legacy 301 / 302 do not
29
+ * guarantee that. An app that needs a specific legacy code (e.g. a 301
30
+ * for a tool that only understands it) can set `statusCode` explicitly,
31
+ * which wins over `permanent`.
32
+ *
33
+ * Destination. A `destination` may be:
34
+ * - a path (`/new`), optionally referencing named groups captured by
35
+ * the source pattern (`/posts/:slug` filled from `/blog/:slug`),
36
+ * - an absolute URL (`https://docs.example.com`) for an external
37
+ * redirect (group substitution applies there too).
38
+ *
39
+ * Query string. The incoming query string is PRESERVED by default and
40
+ * appended to the destination (a destination that carries its own query
41
+ * is merged, the destination's keys winning). This matches Next.js's
42
+ * default redirect behavior.
43
+ */
44
+
45
+ /**
46
+ * Trailing-slash canonicalization (issue #255).
47
+ *
48
+ * A page reachable at BOTH `/about` and `/about/` is duplicate content:
49
+ * webjs's file router matches both (every route pattern ends with `/?$`,
50
+ * so the slashed and unslashed forms render IDENTICAL HTML), but search
51
+ * engines treat them as two URLs that split link equity, and the client
52
+ * router caches them under two keys. The trailing-slash policy picks ONE
53
+ * canonical form and 308-redirects the other to it, exactly like the
54
+ * `webjs.redirects` config does for a moved URL.
55
+ *
56
+ * Config lives in `package.json` -> `webjs.trailingSlash`, cohesive with
57
+ * `webjs.redirects` / `webjs.headers` / `webjs.csp`:
58
+ *
59
+ * "webjs": { "trailingSlash": "never" } // /about/ -> /about (recommended)
60
+ * "webjs": { "trailingSlash": "always" } // /about -> /about/
61
+ * "webjs": { "trailingSlash": "ignore" } // no canonicalization (default)
62
+ *
63
+ * Default. Absent or `"ignore"` means NO redirect (current behavior, so an
64
+ * existing app is unchanged). Most apps want `"never"`; it is the
65
+ * recommendation, but it is opt-in so adding the feature never silently
66
+ * starts 308-ing an app that was happy serving both forms.
67
+ *
68
+ * Rules (a redirect is a permanent 308, so the SEO equity transfers and a
69
+ * redirected POST stays a POST):
70
+ * - `never`: a path ending in `/` (other than the root `/`) redirects to
71
+ * the same path without the trailing slash.
72
+ * - `always`: a path with NO trailing slash redirects to the same path
73
+ * WITH one, UNLESS the last segment looks like a file (has a dot in it,
74
+ * e.g. `/foo.js`, `/image.png`); a file path is left alone, since
75
+ * `/foo.js/` is not a sensible canonical form.
76
+ * - The ROOT path `/` is ALWAYS left alone under either policy.
77
+ * - The query string and hash are preserved on the redirect.
78
+ * - `/__webjs/*` framework paths are exempt (handled by the caller).
79
+ */
80
+
81
+ /** Permanent (308) is the canonicalization status, like a moved URL. */
82
+ const CANONICAL_STATUS = 308;
83
+
84
+ /** The valid `webjs.trailingSlash` policy values. */
85
+ const TRAILING_SLASH_POLICIES = new Set(['never', 'always', 'ignore']);
86
+
87
+ /**
88
+ * Read the trailing-slash policy from the app's package.json
89
+ * (`webjs.trailingSlash`). Returns `'never'` / `'always'` / `'ignore'`,
90
+ * defaulting to `'ignore'` (no canonicalization) for an absent, malformed,
91
+ * or unrecognized value, so a missing or typo'd config is a no-op rather
92
+ * than a throw or an accidental redirect.
93
+ *
94
+ * @param {unknown} pkg parsed package.json (or any object)
95
+ * @returns {'never' | 'always' | 'ignore'}
96
+ */
97
+ export function readTrailingSlashPolicy(pkg) {
98
+ const raw =
99
+ pkg &&
100
+ typeof pkg === 'object' &&
101
+ /** @type {any} */ (pkg).webjs &&
102
+ /** @type {any} */ (pkg).webjs.trailingSlash;
103
+ if (typeof raw === 'string' && TRAILING_SLASH_POLICIES.has(raw)) {
104
+ return /** @type {'never' | 'always' | 'ignore'} */ (raw);
105
+ }
106
+ return 'ignore';
107
+ }
108
+
109
+ /**
110
+ * Apply the trailing-slash canonicalization policy to an incoming request.
111
+ * Returns a 308 redirect Response to the canonical form when the request
112
+ * path is non-canonical under the policy, else null so the request falls
113
+ * through to normal routing (or to the declarative redirects). Runs AFTER
114
+ * `applyRedirects` (see the wiring in `dev.js`): an explicit `webjs.redirects`
115
+ * rule wins first, then the survivor is slash-canonicalized. This does NOT
116
+ * guarantee loop-freedom. A redirect whose `destination` CONTRADICTS the slash
117
+ * policy (e.g. policy `never` with `{ destination: '/x/' }`) ping-pongs forever
118
+ * (`/x` -> 308 `/x/` -> 308 `/x` -> ...). There is no server-side loop guard
119
+ * (matching `applyRedirects`), so keeping a redirect destination consistent with
120
+ * the policy is the app author's responsibility.
121
+ *
122
+ * Framework-internal `/__webjs/*` paths are never canonicalized (the caller
123
+ * also guards this, defense in depth here).
124
+ *
125
+ * SECURITY: the canonical path is built from `url.pathname` and emitted as the
126
+ * `Location`, so a request whose path is a NETWORK-PATH REFERENCE (begins with
127
+ * `//`, or `/\` which the URL parser normalizes to `//`) would otherwise emit a
128
+ * protocol-relative `Location` (`//attacker.com`) that the browser resolves to a
129
+ * FOREIGN origin, an open redirect. Such a path is not a normal route, so we
130
+ * REFUSE to canonicalize it (return null) and let the router 404 it, rather than
131
+ * emit a cross-origin redirect. The sibling `applyRedirects` avoids this by only
132
+ * ever emitting app-authored `destination` literals (and keeping user-controlled
133
+ * `:slug` captures percent-encoded so they cannot escape the origin); here the
134
+ * Location is derived from the request path, so the guard is on the path itself.
135
+ *
136
+ * @param {Request} req
137
+ * @param {'never' | 'always' | 'ignore'} policy
138
+ * @returns {Response | null}
139
+ */
140
+ export function applyTrailingSlash(req, policy) {
141
+ if (policy !== 'never' && policy !== 'always') return null;
142
+ let url;
143
+ try {
144
+ url = new URL(req.url);
145
+ } catch {
146
+ return null;
147
+ }
148
+ const path = url.pathname;
149
+ // The root path is always canonical under either policy.
150
+ if (path === '/') return null;
151
+ if (path.startsWith('/__webjs/')) return null;
152
+ // Refuse a network-path reference (`//host`, or `/\host` normalized to
153
+ // `//host`): canonicalizing it would emit a protocol-relative, cross-origin
154
+ // Location (an open redirect). Let the router handle the weird path instead.
155
+ if (!isSameOriginPath(path)) return null;
156
+
157
+ /** @type {string | null} */
158
+ let canonical = null;
159
+ if (policy === 'never') {
160
+ // Strip a single trailing slash. (A multi-slash path like `/about//`
161
+ // collapses one slash per redirect; the next request re-canonicalizes,
162
+ // and the common single-slash case settles in one hop.)
163
+ if (path.endsWith('/')) canonical = path.replace(/\/+$/, '') || '/';
164
+ } else {
165
+ // policy === 'always'
166
+ if (!path.endsWith('/') && !lastSegmentLooksLikeFile(path)) {
167
+ canonical = path + '/';
168
+ }
169
+ }
170
+ if (canonical === null || canonical === path) return null;
171
+
172
+ // Preserve the query string and hash on the canonical URL.
173
+ const location = canonical + url.search + url.hash;
174
+ return new Response(null, {
175
+ status: CANONICAL_STATUS,
176
+ headers: { location: location },
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Whether the LAST path segment looks like a file (contains a dot), e.g.
182
+ * `/foo.js` or `/assets/logo.png`. Such a path must NOT get a trailing
183
+ * slash added under the `always` policy: a file is a leaf, not a "page"
184
+ * directory, so `/foo.js/` is never a sensible canonical form.
185
+ *
186
+ * @param {string} path a pathname (no query / hash)
187
+ * @returns {boolean}
188
+ */
189
+ function lastSegmentLooksLikeFile(path) {
190
+ const lastSlash = path.lastIndexOf('/');
191
+ const segment = path.slice(lastSlash + 1);
192
+ return segment.includes('.');
193
+ }
194
+
195
+ /**
196
+ * Whether a pathname is a SAFE same-origin path (a single leading slash, not a
197
+ * network-path reference). A path beginning with `//` or `/\` resolves to a
198
+ * foreign origin when emitted as a `Location`, so it is rejected. The URL parser
199
+ * normalizes a backslash to a forward slash in the pathname, so `/\evil.com` is
200
+ * seen here as `//evil.com`; the explicit backslash check is belt-and-braces in
201
+ * case a caller passes a raw, unparsed path.
202
+ *
203
+ * @param {string} path a pathname
204
+ * @returns {boolean}
205
+ */
206
+ function isSameOriginPath(path) {
207
+ if (path[0] !== '/') return false;
208
+ const second = path[1];
209
+ return second !== '/' && second !== '\\';
210
+ }
211
+
212
+ /** Default status for `permanent: true` (the SEO permanent redirect). */
213
+ const PERMANENT_STATUS = 308;
214
+ /** Default status for `permanent: false` (a temporary redirect). */
215
+ const TEMPORARY_STATUS = 307;
216
+
217
+ /** Redirect status codes a `statusCode` override may legitimately set. */
218
+ const ALLOWED_STATUS = new Set([301, 302, 303, 307, 308]);
219
+
220
+ /**
221
+ * Read the redirect config from the app's package.json
222
+ * (`webjs.redirects`) and compile it to a cached array of rules, each
223
+ * pairing a URLPattern (matched on the pathname) against a destination
224
+ * template + resolved status. A malformed or absent config yields an
225
+ * empty array (no redirects), never a throw: a broken redirect config
226
+ * must not take the request pipeline down. Each malformed entry is
227
+ * DROPPED with a one-line warning, so a single typo never disables the
228
+ * valid rules around it.
229
+ *
230
+ * @param {unknown} pkg parsed package.json (or any object)
231
+ * @returns {Array<{ pattern: URLPattern, destination: string, status: number }>}
232
+ */
233
+ export function compileRedirectRules(pkg) {
234
+ const raw =
235
+ pkg &&
236
+ typeof pkg === 'object' &&
237
+ /** @type {any} */ (pkg).webjs &&
238
+ /** @type {any} */ (pkg).webjs.redirects;
239
+ if (!Array.isArray(raw)) return [];
240
+ /** @type {Array<{ pattern: URLPattern, destination: string, status: number }>} */
241
+ const rules = [];
242
+ for (const entry of raw) {
243
+ if (!entry || typeof entry !== 'object') continue;
244
+ const source = /** @type {any} */ (entry).source;
245
+ const destination = /** @type {any} */ (entry).destination;
246
+ if (typeof source !== 'string' || !source) {
247
+ warnDrop('source must be a non-empty string', entry);
248
+ continue;
249
+ }
250
+ if (typeof destination !== 'string' || !destination) {
251
+ warnDrop('destination must be a non-empty string', entry);
252
+ continue;
253
+ }
254
+ let pattern;
255
+ try {
256
+ // Match on the pathname only, like the #232 header config. A bare
257
+ // path string is the common Next-style usage; URLPattern treats it
258
+ // as the pathname component, so `:slug` / `:rest*` syntax works.
259
+ pattern = new URLPattern({ pathname: source });
260
+ } catch {
261
+ warnDrop(`invalid source pattern "${source}"`, entry);
262
+ continue;
263
+ }
264
+ const status = resolveStatus(entry);
265
+ if (status === null) {
266
+ warnDrop(`invalid statusCode on "${source}"`, entry);
267
+ continue;
268
+ }
269
+ rules.push({ pattern, destination, status });
270
+ }
271
+ return rules;
272
+ }
273
+
274
+ /**
275
+ * Resolve the redirect status for one entry. `statusCode` wins when set
276
+ * (must be one of the allowed redirect codes), else `permanent` chooses
277
+ * 308 (default true) vs 307.
278
+ *
279
+ * @param {any} entry
280
+ * @returns {number | null} the status, or null if `statusCode` is invalid
281
+ */
282
+ function resolveStatus(entry) {
283
+ const raw = entry.statusCode;
284
+ if (raw !== undefined && raw !== null) {
285
+ const n = Number(raw);
286
+ if (!Number.isInteger(n) || !ALLOWED_STATUS.has(n)) return null;
287
+ return n;
288
+ }
289
+ // `permanent` defaults to TRUE (the SEO permanent redirect). Only an
290
+ // explicit `false` opts into the temporary 307.
291
+ return entry.permanent === false ? TEMPORARY_STATUS : PERMANENT_STATUS;
292
+ }
293
+
294
+ /** @param {string} reason @param {unknown} entry */
295
+ function warnDrop(reason, entry) {
296
+ // eslint-disable-next-line no-console
297
+ console.warn(`[webjs] dropping invalid webjs.redirects entry (${reason}):`, entry);
298
+ }
299
+
300
+ /**
301
+ * Substitute named groups captured by the source pattern into the
302
+ * destination template. A `:name` token in the destination is replaced
303
+ * by the matching group's value (URL-pathname-encoded by URLPattern's
304
+ * own capture). An undefined group leaves the literal token in place
305
+ * (a misconfigured destination, not a crash).
306
+ *
307
+ * @param {string} destination the destination template
308
+ * @param {Record<string, string | undefined>} groups URLPattern exec groups
309
+ * @returns {string}
310
+ */
311
+ function fillGroups(destination, groups) {
312
+ if (!groups) return destination;
313
+ // Replace `:name` tokens. The name charset matches URLPattern's
314
+ // (letters, digits, underscore). A token with no matching group is
315
+ // left untouched.
316
+ return destination.replace(/:([A-Za-z0-9_]+)/g, (whole, name) => {
317
+ const v = groups[name];
318
+ return v === undefined ? whole : v;
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Build the final redirect Location for a matched rule: fill named
324
+ * groups, then merge the incoming query string onto the destination
325
+ * (preserved by default, the destination's own query keys winning).
326
+ *
327
+ * @param {{ destination: string, status: number }} rule
328
+ * @param {Record<string, string | undefined>} groups
329
+ * @param {URL} url the incoming request URL
330
+ * @returns {string} the Location header value
331
+ */
332
+ function buildLocation(rule, groups, url) {
333
+ let dest = fillGroups(rule.destination, groups);
334
+ const incoming = url.search; // includes the leading '?', or '' when absent
335
+ if (!incoming) return dest;
336
+ // Preserve the incoming query string. If the destination already
337
+ // carries its own query, merge, with the destination's keys winning
338
+ // (an explicit redirect target is intentional).
339
+ const hashIdx = dest.indexOf('#');
340
+ const hash = hashIdx === -1 ? '' : dest.slice(hashIdx);
341
+ const noHash = hashIdx === -1 ? dest : dest.slice(0, hashIdx);
342
+ const qIdx = noHash.indexOf('?');
343
+ const base = qIdx === -1 ? noHash : noHash.slice(0, qIdx);
344
+ const destQuery = qIdx === -1 ? '' : noHash.slice(qIdx + 1);
345
+ const merged = new URLSearchParams(incoming.slice(1));
346
+ for (const [k, v] of new URLSearchParams(destQuery)) merged.set(k, v);
347
+ const qs = merged.toString();
348
+ return base + (qs ? '?' + qs : '') + hash;
349
+ }
350
+
351
+ /**
352
+ * Apply the declarative redirect rules to an incoming request. Returns a
353
+ * redirect Response (308 / 307 / the configured status, with the
354
+ * computed `Location`) on the FIRST matching rule, else null so the
355
+ * request falls through to normal routing. Framework-internal paths
356
+ * (`/__webjs/*`) are never redirected.
357
+ *
358
+ * Compiled rules are passed in (built once at boot), so this is O(rules)
359
+ * per request with no per-request pattern compilation.
360
+ *
361
+ * @param {Request} req
362
+ * @param {Array<{ pattern: URLPattern, destination: string, status: number }>} rules
363
+ * @returns {Response | null}
364
+ */
365
+ export function applyRedirects(req, rules) {
366
+ if (!rules || !rules.length) return null;
367
+ let url;
368
+ try {
369
+ url = new URL(req.url);
370
+ } catch {
371
+ return null;
372
+ }
373
+ // Never redirect framework-internal paths (probes, the core runtime,
374
+ // the action endpoint, the dev reload stream). They are infrastructure,
375
+ // not app URLs.
376
+ if (url.pathname.startsWith('/__webjs/')) return null;
377
+
378
+ for (const rule of rules) {
379
+ const match = rule.pattern.exec({ pathname: url.pathname });
380
+ if (!match) continue;
381
+ const groups = (match.pathname && match.pathname.groups) || {};
382
+ const location = buildLocation(rule, groups, url);
383
+ return new Response(null, {
384
+ status: rule.status,
385
+ headers: { location: location },
386
+ });
387
+ }
388
+ return null;
389
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Route-types generator (#258).
3
+ *
4
+ * `generateRouteTypes(appDir)` walks `app/` (reusing `buildRouteTable`, the
5
+ * one route enumerator) and emits the `.d.ts` TEXT that augments
6
+ * `@webjsdev/core`, narrowing the `Route` href union and the per-route
7
+ * `params` shape. It is the opt-in codegen behind `webjs types`; the static
8
+ * types in `@webjsdev/core` (`PageProps`, `LayoutProps`, `Route`, …) work
9
+ * without it (un-generated apps see `Route = string`).
10
+ *
11
+ * Design choices:
12
+ * - PAGES ONLY. A `route.{js,ts}` handler is an API endpoint, not a
13
+ * navigable HTML page, so its path is excluded from the navigable `Route`
14
+ * union. Pages (including page-action pages) are what a valid href points
15
+ * at.
16
+ * - The route KEY is the literal pattern (`/blog/[slug]`), derived from the
17
+ * page's directory with route groups `(group)` stripped and `_private`
18
+ * dirs excluded, matching `buildRouteTable`'s own URL normalization.
19
+ * - The optional catch-all `[[...slug]]` emits TWO `WebjsRoutes` keys (the
20
+ * with-segment `/docs/[[...slug]]` and the without-segment `/docs`), so a
21
+ * bare `/docs` and a `/docs/a/b` both type-check. Keeping the with/without
22
+ * split in the generator lets the pure `RoutePattern` type stay simple.
23
+ * - Param object shapes are known here: `[slug]` -> `{ slug: string }`,
24
+ * `[...rest]` -> `{ rest: string[] }`, `[[...rest]]` -> `{ rest?: string[] }`.
25
+ * - Deterministic: keys are sorted so re-running yields a byte-identical
26
+ * file (clean diffs, idempotent).
27
+ */
28
+
29
+ import { buildRouteTable } from './router.js';
30
+
31
+ /** @param {string} seg */
32
+ function isUrlSegment(seg) {
33
+ if (seg.startsWith('(') && seg.endsWith(')')) return false; // route group
34
+ if (seg.startsWith('_')) return false; // private
35
+ return true;
36
+ }
37
+
38
+ /**
39
+ * The literal route key for a page directory, e.g. `app/blog/[slug]/page.ts`
40
+ * (routeDir `blog/[slug]`) -> `/blog/[slug]`. The root page (routeDir `.`)
41
+ * -> `/`. Route groups and private segments are dropped from the path.
42
+ *
43
+ * @param {string} routeDir POSIX-style, `.` for the app root.
44
+ * @returns {string}
45
+ */
46
+ export function routeKeyFromDir(routeDir) {
47
+ if (routeDir === '.' || routeDir === '') return '/';
48
+ const segs = routeDir.split('/').filter(isUrlSegment);
49
+ if (segs.length === 0) return '/';
50
+ return '/' + segs.join('/');
51
+ }
52
+
53
+ /**
54
+ * @typedef {{ name: string, kind: 'single' | 'catchAll' | 'optionalCatchAll' }} DynSeg
55
+ */
56
+
57
+ /**
58
+ * Extract the dynamic segments of a route key in order.
59
+ *
60
+ * @param {string} key A literal route key like `/blog/[slug]`.
61
+ * @returns {DynSeg[]}
62
+ */
63
+ export function dynamicSegments(key) {
64
+ /** @type {DynSeg[]} */
65
+ const out = [];
66
+ for (const seg of key.split('/')) {
67
+ if (seg.startsWith('[[...') && seg.endsWith(']]')) {
68
+ out.push({ name: seg.slice(5, -2), kind: 'optionalCatchAll' });
69
+ } else if (seg.startsWith('[...') && seg.endsWith(']')) {
70
+ out.push({ name: seg.slice(4, -1), kind: 'catchAll' });
71
+ } else if (seg.startsWith('[') && seg.endsWith(']')) {
72
+ out.push({ name: seg.slice(1, -1), kind: 'single' });
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * The TypeScript params-object literal for a route key, e.g.
80
+ * `{ slug: string }` / `{ rest: string[] }` / `{ slug?: string[] }`. Returns
81
+ * null for a static route (no entry needed in `RouteParamMap`).
82
+ *
83
+ * @param {string} key
84
+ * @returns {string | null}
85
+ */
86
+ export function paramTypeForKey(key) {
87
+ const dyn = dynamicSegments(key);
88
+ if (dyn.length === 0) return null;
89
+ const fields = dyn.map((d) => {
90
+ if (d.kind === 'single') return `${d.name}: string`;
91
+ if (d.kind === 'catchAll') return `${d.name}: string[]`;
92
+ return `${d.name}?: string[]`; // optionalCatchAll
93
+ });
94
+ return `{ ${fields.join('; ')} }`;
95
+ }
96
+
97
+ /**
98
+ * Build the set of `WebjsRoutes` HREF keys for a route key. These keys drive
99
+ * the `Route` href union, so each must be a form the pure `RoutePattern` type
100
+ * can expand cleanly (it only understands the single `[x]` and catch-all
101
+ * `[...x]` segments, NOT the doubled `[[...x]]`). So:
102
+ * - A static route yields itself.
103
+ * - A normal dynamic route (`[x]` / `[...x]`) yields itself.
104
+ * - An OPTIONAL catch-all `[[...x]]` yields TWO keys: the WITHOUT-segment
105
+ * form (the segment elided, the `//` collapsed) so a bare path matches,
106
+ * and a NORMALIZED with-segment form where `[[...x]]` is rewritten to the
107
+ * plain catch-all `[...x]` so the deep path matches. The author-facing
108
+ * literal `[[...x]]` stays the `RouteParamMap` key (that is what a page
109
+ * passes to `PageProps`), but it is NOT a Route-union key.
110
+ *
111
+ * @param {string} key
112
+ * @returns {string[]}
113
+ */
114
+ export function webjsRoutesKeysForKey(key) {
115
+ if (!key.includes('[[...')) return [key];
116
+ // With-segment: rewrite each `[[...name]]` to the plain catch-all `[...name]`.
117
+ const withSeg = key.replace(/\[\[\.\.\.([^\]]+)\]\]/g, '[...$1]');
118
+ // Without-segment: drop the optional catch-all segment entirely.
119
+ let without = key.replace(/\/\[\[\.\.\.[^\]]+\]\]/g, '');
120
+ if (without === '') without = '/';
121
+ const out = new Set([withSeg]);
122
+ out.add(without);
123
+ return [...out];
124
+ }
125
+
126
+ /**
127
+ * Generate the augmentation `.d.ts` text for an app's routes.
128
+ *
129
+ * @param {string} appDir The app root (the dir containing `app/`).
130
+ * @returns {Promise<string>}
131
+ */
132
+ export async function generateRouteTypes(appDir) {
133
+ const table = await buildRouteTable(appDir);
134
+
135
+ /** @type {Set<string>} */
136
+ const routeKeys = new Set();
137
+ /** @type {Map<string, string>} */
138
+ const paramEntries = new Map();
139
+
140
+ for (const page of table.pages) {
141
+ const key = routeKeyFromDir(page.routeDir);
142
+ const paramType = paramTypeForKey(key);
143
+ if (paramType) paramEntries.set(key, paramType);
144
+ for (const k of webjsRoutesKeysForKey(key)) routeKeys.add(k);
145
+ }
146
+
147
+ const sortedKeys = [...routeKeys].sort();
148
+ const sortedParamKeys = [...paramEntries.keys()].sort();
149
+
150
+ const routeLines = sortedKeys.map((k) => ` ${JSON.stringify(k)}: true;`);
151
+ const paramLines = sortedParamKeys.map(
152
+ (k) => ` ${JSON.stringify(k)}: ${paramEntries.get(k)};`,
153
+ );
154
+
155
+ // The without-segment optional-catch-all key has no dynamic segment, so it
156
+ // gets no RouteParamMap entry (its params are optional anyway); the
157
+ // with-segment key carries the `{ name?: string[] }` shape.
158
+
159
+ const out = `// AUTO-GENERATED by \`webjs types\`. Do not edit. Regenerated from app/ routes.
160
+ //
161
+ // Augments @webjsdev/core so the Route href union, navigate(), and per-route
162
+ // params are typed for this app. Regenerated per machine (gitignored, like
163
+ // Next's .next/types). Re-run \`webjs types\` after adding or removing a route.
164
+ import '@webjsdev/core';
165
+
166
+ declare module '@webjsdev/core' {
167
+ interface WebjsRoutes {
168
+ ${routeLines.join('\n')}
169
+ }
170
+ interface RouteParamMap {
171
+ ${paramLines.join('\n')}
172
+ }
173
+ }
174
+ `;
175
+ return out;
176
+ }