paramour 0.10.0 → 0.11.0

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.
@@ -45,6 +45,11 @@ export interface RouteDescription {
45
45
  readonly path: string;
46
46
  readonly router: RouterKind;
47
47
  readonly search: SearchDescription;
48
+ /**
49
+ * Present (and `true`) only for a route defined with `trailingSlash: true`,
50
+ * so descriptions of R6-default routes are unchanged.
51
+ */
52
+ readonly trailingSlash?: true;
48
53
  }
49
54
  /**
50
55
  * The `search:` slot's three shapes: absent config, a codec map, or the
package/dist/describe.js CHANGED
@@ -59,6 +59,7 @@ export function describeRoute(route) {
59
59
  path: route.path,
60
60
  router: route["~router"],
61
61
  search: describeSearch(route["~search"]),
62
+ ...(route["~trailingSlash"] ? { trailingSlash: true } : {}),
62
63
  };
63
64
  }
64
65
  /**
package/dist/path.d.ts CHANGED
@@ -61,6 +61,15 @@ export type PathSegment = {
61
61
  * joined with `/`. R2's element joining falls out of the same join as
62
62
  * everything else; a fully-elided path (an optional catch-all at the root)
63
63
  * yields "/".
64
+ *
65
+ * R6's one exception lives here, not in href, so `buildPath` and `href`
66
+ * always agree on the path: a route defined with `trailingSlash: true` gets a
67
+ * "/" after its last segment. The slash goes on the BUILT path, after any
68
+ * elision, so an elided optional catch-all yields "/docs/" and the root stays
69
+ * "/" rather than "//". It is a route-definition fact, not a Next config read,
70
+ * because route modules also run where no Next config exists (a CLI printing
71
+ * absolute links); a static export with `trailingSlash: true` serves exactly
72
+ * these URLs without a redirect.
64
73
  */
65
74
  export declare function buildPath<R extends AnyRoute>(route: R, params: InferParamsInput<R>): string;
66
75
  /**
package/dist/path.js CHANGED
@@ -13,9 +13,19 @@ const GROUP_SEGMENT = /^\(.*\)$/;
13
13
  * joined with `/`. R2's element joining falls out of the same join as
14
14
  * everything else; a fully-elided path (an optional catch-all at the root)
15
15
  * yields "/".
16
+ *
17
+ * R6's one exception lives here, not in href, so `buildPath` and `href`
18
+ * always agree on the path: a route defined with `trailingSlash: true` gets a
19
+ * "/" after its last segment. The slash goes on the BUILT path, after any
20
+ * elision, so an elided optional catch-all yields "/docs/" and the root stays
21
+ * "/" rather than "//". It is a route-definition fact, not a Next config read,
22
+ * because route modules also run where no Next config exists (a CLI printing
23
+ * absolute links); a static export with `trailingSlash: true` serves exactly
24
+ * these URLs without a redirect.
16
25
  */
17
26
  export function buildPath(route, params) {
18
- return `/${encodeParams(route, params).join("/")}`;
27
+ const path = `/${encodeParams(route, params).join("/")}`;
28
+ return route["~trailingSlash"] && path !== "/" ? `${path}/` : path;
19
29
  }
20
30
  /**
21
31
  * Decodes a params source against a route's codecs, the sync twin of
@@ -308,7 +318,9 @@ export function tokenizePath(path) {
308
318
  throw new ParamourError(`route path must start with "/": "${path}"`);
309
319
  }
310
320
  if (path !== "/" && path.endsWith("/")) {
311
- throw new ParamourError(`route path must not end with "/": "${path}"`);
321
+ // The literal is the route's identity (registry key, Href brand), so it
322
+ // never carries the slash; the built URL can, via the define option.
323
+ throw new ParamourError(`route path must not end with "/": "${path}" (to build links with a trailing slash, pass trailingSlash: true in the route config)`);
312
324
  }
313
325
  if (path === "/")
314
326
  return [];
package/dist/route.d.ts CHANGED
@@ -226,17 +226,26 @@ export interface Route<Path extends string, PC extends ParamsConfig<Path>, SC ex
226
226
  * registry — tree-shaking is untouched.
227
227
  */
228
228
  readonly "~segments": readonly PathSegment[];
229
+ /**
230
+ * The define-time `trailingSlash` option (R6's exception): `buildPath`, and
231
+ * so `href`, append "/" to every non-root path when true. Required, not
232
+ * optional: an optional member here turns the wrong-router diagnostics
233
+ * (an AppRoute passed where AnyPagesRoute is expected) into
234
+ * exactOptionalPropertyTypes noise. Readers test it for truthiness, so a
235
+ * plain-JS hand-built route without it gets the R6 default.
236
+ */
237
+ readonly "~trailingSlash": boolean;
229
238
  }
230
239
  /**
231
240
  * Conditional on the path shape: dynamic paths REQUIRE `params` with exactly
232
241
  * the extracted segment names; static paths REJECT it (`?: never` — may be
233
242
  * absent, may never be present, which under exactOptionalPropertyTypes holds
234
- * even for non-fresh objects).
243
+ * even for non-fresh objects). `trailingSlash` is the same on both branches.
235
244
  */
236
- export type RouteConfig<Path extends string, PC extends ParamsConfig<Path>, SC extends SearchSlot> = [PathParamNames<Path>] extends [never] ? {
245
+ export type RouteConfig<Path extends string, PC extends ParamsConfig<Path>, SC extends SearchSlot> = [PathParamNames<Path>] extends [never] ? TrailingSlashOption & {
237
246
  readonly params?: never;
238
247
  readonly search?: SC;
239
- } : {
248
+ } : TrailingSlashOption & {
240
249
  readonly params: ConformParams<Path, PC>;
241
250
  readonly search?: SC;
242
251
  };
@@ -310,6 +319,19 @@ type PresentRegisteredPaths = (ParamourRegister extends {
310
319
  * and decode expectations, so it is excluded here.
311
320
  */
312
321
  type StaticPathsOf<P extends string> = P extends `${string}[${string}` ? never : P;
322
+ /**
323
+ * The route-config option behind R6's exception. Not exported on its own:
324
+ * it is part of {@link RouteConfig}, the type wrappers should name.
325
+ */
326
+ interface TrailingSlashOption {
327
+ /**
328
+ * Build every non-root path with a trailing slash: `/asset/`,
329
+ * `/docs/a/b/`, `/asset/?q=1#top`; the root stays `/`. Set it to match
330
+ * `trailingSlash` in `next.config` (`paramour doctor` warns when they
331
+ * disagree). Defaults to `false`, wire-format rule R6.
332
+ */
333
+ readonly trailingSlash?: boolean;
334
+ }
313
335
  /**
314
336
  * Defines an App Router route: the URL-shaped path literal plus its
315
337
  * param/search codec configs. Validates the literal eagerly — fail-fast at
package/dist/route.js CHANGED
@@ -161,13 +161,19 @@ async function rebrandRejection(promise) {
161
161
  */
162
162
  function routeData(router, path, config) {
163
163
  const segments = tokenizePath(path);
164
- const { params, search } = config;
164
+ const { params, search, trailingSlash } = config;
165
+ // Plain-JS backstop, fail-fast like the literal checks: a truthy string
166
+ // ("false") read loosely would silently change every link the route builds.
167
+ if (trailingSlash !== undefined && typeof trailingSlash !== "boolean") {
168
+ throw new ParamourError(`route "${path}": trailingSlash must be a boolean, got ${describeType(trailingSlash)}`);
169
+ }
165
170
  return {
166
171
  path,
167
172
  "~params": params ?? {},
168
173
  "~router": router,
169
174
  "~search": search ?? {},
170
175
  "~segments": segments,
176
+ "~trailingSlash": trailingSlash ?? false,
171
177
  };
172
178
  }
173
179
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paramour",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {