@paramour-js/next 0.9.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.
@@ -5,7 +5,7 @@ import { message } from "../cli-io.js";
5
5
  import { loadConfigFile } from "../config.js";
6
6
  import { checkArtifact, formatRouteDiff, } from "../generate.js";
7
7
  import { tsconfigCheck } from "../init/scaffold.js";
8
- import { detectWrapState, findNextConfig } from "../init/wrap-next-config.js";
8
+ import { detectWrapState, findNextConfig, readTrailingSlash, } from "../init/wrap-next-config.js";
9
9
  import { discoverRouteDefinitions, routeKey, } from "../list/discover-route-defs.js";
10
10
  import { scanRoutes } from "../scan.js";
11
11
  import { skillsDoctorChecks } from "../skills/doctor.js";
@@ -151,7 +151,16 @@ export async function runDoctorChecks(projectRoot) {
151
151
  status: coverage.ok ? "pass" : "warn",
152
152
  });
153
153
  // 8. Route-definition discovery health (list's engine).
154
- checks.push(await discoveryCheck(projectRoot, config, routes));
154
+ const discovered = await discoveryCheck(projectRoot, config, routes);
155
+ checks.push(discovered.check);
156
+ // 9. Route definitions build the URLs next.config serves. Reported only
157
+ // when there is an answer: definitions exist and the config's
158
+ // trailingSlash reads statically.
159
+ if (nextConfig !== undefined && discovered.definitions.length > 0) {
160
+ const check = await trailingSlashCheck(nextConfig.path, discovered.definitions);
161
+ if (check !== undefined)
162
+ checks.push(check);
163
+ }
155
164
  return checks;
156
165
  }
157
166
  async function discoveryCheck(projectRoot, config, routes) {
@@ -177,18 +186,24 @@ async function discoveryCheck(projectRoot, config, routes) {
177
186
  detail.push(`duplicate definition of ${duplicate.path} (${duplicate.router}) in ${duplicate.file} — ${duplicate.firstFile} wins`);
178
187
  }
179
188
  return {
180
- detail,
181
- label: `route definitions: ${String(discovery.definitions.length)} found in ${String(files.size)} module${files.size === 1 ? "" : "s"}`,
182
- status: discovery.loadFailures.length > 0 || discovery.duplicates.length > 0
183
- ? "warn"
184
- : "pass",
189
+ check: {
190
+ detail,
191
+ label: `route definitions: ${String(discovery.definitions.length)} found in ${String(files.size)} module${files.size === 1 ? "" : "s"}`,
192
+ status: discovery.loadFailures.length > 0 || discovery.duplicates.length > 0
193
+ ? "warn"
194
+ : "pass",
195
+ },
196
+ definitions: discovery.definitions,
185
197
  };
186
198
  }
187
199
  catch (error) {
188
200
  return {
189
- detail: [message(error)],
190
- label: "route definitions: discovery failed",
191
- status: "warn",
201
+ check: {
202
+ detail: [message(error)],
203
+ label: "route definitions: discovery failed",
204
+ status: "warn",
205
+ },
206
+ definitions: [],
192
207
  };
193
208
  }
194
209
  }
@@ -206,6 +221,43 @@ function readManifest(projectRoot, name) {
206
221
  }
207
222
  }
208
223
  }
224
+ /**
225
+ * Warn-level: a route whose `trailingSlash` disagrees with next.config's
226
+ * builds a URL the app only reaches through a redirect (or, in a static
227
+ * export, a path with no file behind it). `undefined` when the config's value
228
+ * cannot be read statically — no finding beats a guessed one.
229
+ */
230
+ async function trailingSlashCheck(nextConfigPath, definitions) {
231
+ let configured;
232
+ try {
233
+ configured = await readTrailingSlash(readFileSync(nextConfigPath, "utf8"));
234
+ }
235
+ catch {
236
+ return undefined;
237
+ }
238
+ if (configured === undefined)
239
+ return undefined;
240
+ const name = basename(nextConfigPath);
241
+ const setting = `trailingSlash: ${String(configured)}`;
242
+ // The routes come from the app's own paramour, which may predate the
243
+ // option and lack the member: missing reads as the R6 default.
244
+ const mismatched = definitions.filter((definition) => (definition.route["~trailingSlash"] ?? false) !==
245
+ configured);
246
+ if (mismatched.length === 0) {
247
+ return {
248
+ label: `trailing slash: route definitions match ${name} (${setting})`,
249
+ status: "pass",
250
+ };
251
+ }
252
+ const fix = configured
253
+ ? "add trailingSlash: true to its definition"
254
+ : "remove trailingSlash: true from its definition";
255
+ return {
256
+ detail: mismatched.map((definition) => `${definition.route.path} (${definition.route["~router"]}) in ${definition.file}: ${fix}`),
257
+ label: `trailing slash: ${String(mismatched.length)} route definition${mismatched.length === 1 ? "" : "s"} disagree${mismatched.length === 1 ? "s" : ""} with ${name} (${setting})`,
258
+ status: "warn",
259
+ };
260
+ }
209
261
  function versionCheck(projectRoot) {
210
262
  const coreManifest = readManifest(projectRoot, "paramour");
211
263
  const nextManifest = readManifest(projectRoot, "@paramour-js/next");
@@ -28,6 +28,16 @@ export declare function detectWrapState(source: string): Promise<"not-wrapped" |
28
28
  export declare function findNextConfig(projectRoot: string): FoundNextConfig | undefined;
29
29
  /** The `manual` fallback text — also printed when no config file exists. */
30
30
  export declare function manualSnippet(): string;
31
+ /**
32
+ * Reads `trailingSlash` from a next.config source for `doctor`, statically:
33
+ * the default export (or CJS `module.exports`) is followed through wrapper
34
+ * calls (first argument), TS `as`/`satisfies`, and top-level `const`
35
+ * bindings to an object literal. A literal without the key is Next's default,
36
+ * `false`. Anything else (a config function, a non-literal value, a spread
37
+ * that could carry the key) is `undefined`: doctor stays quiet rather than
38
+ * guess, since evaluating the config would run user code.
39
+ */
40
+ export declare function readTrailingSlash(source: string): Promise<boolean | undefined>;
31
41
  /**
32
42
  * The transform: add the named import (unless present under any alias) and
33
43
  * rewrap the default export — identifier, object literal, existing wrapper
@@ -41,6 +41,49 @@ export function manualSnippet() {
41
41
  "export default withTypedRoutes(nextConfig);",
42
42
  ].join("\n");
43
43
  }
44
+ /**
45
+ * Reads `trailingSlash` from a next.config source for `doctor`, statically:
46
+ * the default export (or CJS `module.exports`) is followed through wrapper
47
+ * calls (first argument), TS `as`/`satisfies`, and top-level `const`
48
+ * bindings to an object literal. A literal without the key is Next's default,
49
+ * `false`. Anything else (a config function, a non-literal value, a spread
50
+ * that could carry the key) is `undefined`: doctor stays quiet rather than
51
+ * guess, since evaluating the config would run user code.
52
+ */
53
+ export async function readTrailingSlash(source) {
54
+ const { parseModule } = await import("magicast");
55
+ let program;
56
+ try {
57
+ program = parseModule(source).$ast;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ const body = asNodes(program.body);
63
+ const bindings = new Map();
64
+ let exported;
65
+ for (const statement of body) {
66
+ if (statement.type === "VariableDeclaration") {
67
+ for (const declarator of asNodes(statement.declarations)) {
68
+ const id = asNode(declarator.id);
69
+ if (id?.type === "Identifier" && typeof id.name === "string") {
70
+ bindings.set(id.name, declarator.init);
71
+ }
72
+ }
73
+ }
74
+ else if (statement.type === "ExportDefaultDeclaration") {
75
+ exported = statement.declaration;
76
+ }
77
+ else if (statement.type === "ExpressionStatement") {
78
+ const expression = asNode(statement.expression);
79
+ if (expression?.type === "AssignmentExpression" &&
80
+ isModuleExports(asNode(expression.left))) {
81
+ exported = expression.right;
82
+ }
83
+ }
84
+ }
85
+ return trailingSlashOf(exported, bindings, 0);
86
+ }
44
87
  /**
45
88
  * The transform: add the named import (unless present under any alias) and
46
89
  * rewrap the default export — identifier, object literal, existing wrapper
@@ -82,6 +125,28 @@ export async function wrapNextConfigSource(source) {
82
125
  return { snippet: manualSnippet(), status: "manual" };
83
126
  }
84
127
  }
128
+ function asNode(value) {
129
+ return typeof value === "object" &&
130
+ value !== null &&
131
+ typeof value.type === "string"
132
+ ? value
133
+ : undefined;
134
+ }
135
+ function asNodes(value) {
136
+ return Array.isArray(value)
137
+ ? value.flatMap((item) => asNode(item) ?? [])
138
+ : [];
139
+ }
140
+ function isModuleExports(node) {
141
+ if (node?.type !== "MemberExpression" || node.computed === true)
142
+ return false;
143
+ const object = asNode(node.object);
144
+ const property = asNode(node.property);
145
+ return (object?.type === "Identifier" &&
146
+ object.name === "module" &&
147
+ property?.type === "Identifier" &&
148
+ property.name === "exports");
149
+ }
85
150
  function isWrappedCall(value, local) {
86
151
  if (typeof value !== "object" || value === null)
87
152
  return false;
@@ -94,6 +159,65 @@ function isWrappedCall(value, local) {
94
159
  ((local !== undefined && node.$callee === local) ||
95
160
  node.$callee.endsWith(".withTypedRoutes")));
96
161
  }
162
+ function propertyName(node) {
163
+ if (node.computed === true)
164
+ return undefined;
165
+ const key = asNode(node.key);
166
+ if (key?.type === "Identifier" && typeof key.name === "string") {
167
+ return key.name;
168
+ }
169
+ if (key?.type === "StringLiteral" && typeof key.value === "string") {
170
+ return key.value;
171
+ }
172
+ return undefined;
173
+ }
174
+ /**
175
+ * One resolution step of {@link readTrailingSlash}. The depth bound stops a
176
+ * self-referencing binding (`const a = f(a)`) from looping.
177
+ */
178
+ function trailingSlashOf(value, bindings, depth) {
179
+ const node = asNode(value);
180
+ if (node === undefined || depth > 16)
181
+ return undefined;
182
+ switch (node.type) {
183
+ case "CallExpression": {
184
+ const [first] = asNodes(node.arguments);
185
+ return trailingSlashOf(first, bindings, depth + 1);
186
+ }
187
+ case "Identifier": {
188
+ return typeof node.name === "string" && bindings.has(node.name)
189
+ ? trailingSlashOf(bindings.get(node.name), bindings, depth + 1)
190
+ : undefined;
191
+ }
192
+ case "ObjectExpression": {
193
+ // Last write wins, as at runtime; any spread could carry the key.
194
+ let result = false;
195
+ for (const property of asNodes(node.properties)) {
196
+ if (property.type === "SpreadElement")
197
+ return undefined;
198
+ if (propertyName(property) !== "trailingSlash")
199
+ continue;
200
+ const literal = asNode(property.value);
201
+ result =
202
+ property.type === "ObjectProperty" &&
203
+ literal?.type === "BooleanLiteral" &&
204
+ typeof literal.value === "boolean"
205
+ ? literal.value
206
+ : undefined;
207
+ }
208
+ return result;
209
+ }
210
+ case "ParenthesizedExpression":
211
+ case "TSAsExpression":
212
+ case "TSNonNullExpression":
213
+ case "TSSatisfiesExpression": {
214
+ return trailingSlashOf(node.expression, bindings, depth + 1);
215
+ }
216
+ default: {
217
+ return undefined;
218
+ }
219
+ }
220
+ }
97
221
  function withTypedRoutesLocal(items) {
98
222
  return items.find((item) => item.from === "@paramour-js/next" && item.imported === "withTypedRoutes")?.local;
99
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paramour-js/next",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -51,7 +51,7 @@
51
51
  "react-dom": "^19.2.0",
52
52
  "typescript": "^6.0.3",
53
53
  "zod": "^4.4.3",
54
- "paramour": "0.9.0"
54
+ "paramour": "0.11.0"
55
55
  },
56
56
  "description": "Next.js integration for paramour: withTypedRoutes, App and Pages Router hooks, PageProps glue, and the codegen CLI.",
57
57
  "author": "Jason Paff <jasonpaff@gmail.com>",
@@ -12,7 +12,7 @@ Paramour is a type-safe routing companion for Next.js: each route is defined onc
12
12
  1. Import only from package barrels: `paramour`, `@paramour-js/next`, `@paramour-js/next/app`, `@paramour-js/next/pages`, `@paramour-js/next/testing`. Never import `dist/` or deep source paths.
13
13
  2. Codec modifier legality is type-state: illegal chains do not compile (the method's type becomes `never`) and throw at runtime for JS callers. The rules:
14
14
  - `.optional()` and `.default()` apply only to a bare, unmodified single-value codec — at most ONE of the two, at most once. `.optional().default()`, `.default().optional()`, and any repeat are illegal.
15
- - `.catch()` applies at most once and combines with either presence modifier in either order. It recovers parse failures of PRESENT wire values only — never absence.
15
+ - `.catch()` applies at most once and combines with either presence modifier in either order. It recovers parse failures of PRESENT wire values only — never absence. On an `.optional()` codec the fallback may be `undefined`, so a bad value reads as absent (`.optional().catch(undefined)`).
16
16
  - `p.array(...)` codecs take no `.optional()`/`.default()` (an absent key and `[]` are the same wire state). `.catch()` is allowed.
17
17
  - Codecs in a `params:` config take no presence modifiers at all (`.optional()`/`.default()` are illegal there); `.catch()` is allowed.
18
18
  - `p.csv(element)`/`p.array(element)` elements must be bare unmodified scalars: no modifiers, no csv inside csv, no array-arity element.
@@ -27,18 +27,18 @@ Wire grammars are strict and anchored — no `Number()` coercion, no whitespace,
27
27
 
28
28
  ## Modifier chains
29
29
 
30
- | Modifier | Effect on decode | Effect on href input | Effect on URL |
31
- | ------------------------------- | -------------------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------- |
32
- | (none — required) | Absent key is a decode issue | Key required | Always emitted when building |
33
- | `.optional()` | Absent → `undefined`; field type `T \| undefined` | Key omittable | Omitted key emits nothing |
34
- | `.default(value)` | Absent → default; field type stays `T` | Key omittable | Value equal to the default ELIDES (compared by serialized wire form) — one canonical URL per state |
35
- | `.default(() => value)` | Absent → factory result; field type stays `T` | Key omittable | NEVER elides (a time-varying factory would swallow explicit values) |
36
- | `.catch(v)` / `.catch(() => v)` | A PRESENT value that fails parsing → fallback. Never covers absence. | No change | No change |
30
+ | Modifier | Effect on decode | Effect on href input | Effect on URL |
31
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------- |
32
+ | (none — required) | Absent key is a decode issue | Key required | Always emitted when building |
33
+ | `.optional()` | Absent → `undefined`; field type `T \| undefined` | Key omittable | Omitted key emits nothing |
34
+ | `.default(value)` | Absent → default; field type stays `T` | Key omittable | Value equal to the default ELIDES (compared by serialized wire form) — one canonical URL per state |
35
+ | `.default(() => value)` | Absent → factory result; field type stays `T` | Key omittable | NEVER elides (a time-varying factory would swallow explicit values) |
36
+ | `.catch(v)` / `.catch(() => v)` | A PRESENT value that fails parsing → fallback. Never covers absence. After `.optional()`, the fallback may be `undefined` (bad value → absent). | No change | No change |
37
37
 
38
38
  Legality (compile-time type-state — illegal calls type as `never`; runtime throws for JS):
39
39
 
40
- - Legal: `p.integer()`, `.optional()`, `.default(1)`, `.catch(0)`, `.optional().catch(0)`, `.catch(0).optional()`, `.default(1).catch(0)`, `.catch(0).default(1)`, `p.csv().default([])`, `p.array().catch([])`.
41
- - Illegal: `.optional().default(...)`, `.default(...).optional()`, `.optional().optional()`, `.default(...).default(...)`, `.catch(...).catch(...)`, `p.array().optional()`, `p.array().default(...)`, any modifier on a csv/array ELEMENT (`p.csv(p.string().optional())`), `p.csv(p.csv())`, `p.array(p.array())`, and `.default(value)` where the value's type includes a function member (use the factory form).
40
+ - Legal: `p.integer()`, `.optional()`, `.default(1)`, `.catch(0)`, `.optional().catch(0)`, `.optional().catch(undefined)`, `.catch(0).optional()`, `.default(1).catch(0)`, `.catch(0).default(1)`, `p.csv().default([])`, `p.array().catch([])`.
41
+ - Illegal: `.optional().default(...)`, `.default(...).optional()`, `.optional().optional()`, `.default(...).default(...)`, `.catch(...).catch(...)`, `.catch(undefined)` on a required/defaulted codec (apply `.optional()` first), `p.array().optional()`, `p.array().default(...)`, any modifier on a csv/array ELEMENT (`p.csv(p.string().optional())`), `p.csv(p.csv())`, `p.array(p.array())`, and `.default(value)` where the value's type includes a function member (use the factory form).
42
42
  - `params:` codecs additionally forbid `.optional()`/`.default()` (path optionality comes from `[[...slug]]`); `.catch()` is fine.
43
43
 
44
44
  Value vs factory `.default()`: value defaults are serialized eagerly at definition time (an invalid default fails immediately) and participate in URL elision; factory defaults are invoked per decode (fresh reference per call — use for mutable objects) and never elide. Array value defaults are handed out as fresh shallow copies per decode.
@@ -92,7 +92,7 @@ href(productRoute, { params: { id: 1 }, hash: "reviews" }); // "#reviews" append
92
92
  href("/about", { hash: "team" }); // string form: registered STATIC paths only
93
93
  ```
94
94
 
95
- `href` returns `Href` — a string subtype accepted by `next/link`, `router.push`, `redirect` unchanged. Required params/search make the options argument required; defaulted/optional/array keys are omittable. Serialization failures (bad value, empty segment, required catch-all given `[]`) throw `SerializeError` at link-build time. Lower-level pieces: `buildPath(route, params)`, `searchToString(config, input)`, `encodeStaticParams(route, params)` for `generateStaticParams`/`getStaticPaths`.
95
+ `href` returns `Href` — a string subtype accepted by `next/link`, `router.push`, `redirect` unchanged. Required params/search make the options argument required; defaulted/optional/array keys are omittable. Serialization failures (bad value, empty segment, required catch-all given `[]`) throw `SerializeError` at link-build time. A route defined with `trailingSlash: true` (match `next.config`'s `trailingSlash`) builds `/asset/?q=1` instead of `/asset?q=1`; the root stays `/` and the path literal never ends in `/`. Lower-level pieces: `buildPath(route, params)`, `searchToString(config, input)`, `encodeStaticParams(route, params)` for `generateStaticParams`/`getStaticPaths`.
96
96
 
97
97
  ## Client hooks
98
98
 
@@ -76,15 +76,15 @@ Precedence: CLI flags → config file → discovery. Unknown keys are rejected (
76
76
 
77
77
  ## Wire-format summary (numbered spec: docs/reference/wire-format on paramour.dev)
78
78
 
79
- | Family | Scope |
80
- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81
- | S | Byte layer: `%20` never `+` (S2); absence = omitted key, `""` = `key=` (S3); never bare `?flag` (S4); deterministic declaration order (S5); hash only from `href`'s explicit option, verbatim (S10) |
82
- | P | Parse layer: duplicate values on a scalar codec are an error, catchable (P5); absent array key → `[]` (P6); unknown keys ignored and never validated (P8) |
83
- | SS | `rawSearch`: explicit wrapper only; schema sees every key on decode; encode is a raw pass-through, schema never runs; no elision/round-trip |
84
- | D | Codec grammar: `.catch()` recovers parse failures never absence (D2); presence governs absence and every declared key appears in decode output (D4); params take no presence modifiers (D5); a catch-all codec describes one element (D6); value defaults elide (D8) |
85
- | CV | `p.csv`: one comma-joined wire value; `""` ↔ `[]`; empty elements are parse failures; serialize rejects elements that are empty or contain commas |
86
- | R | Route segments: one segment per `[param]` (R1); catch-all elements encode independently, inner `/` → `%2F` (R2); optional catch-all elides, required `[]` is a `SerializeError` (R3); empty segment value is a `SerializeError` (R4); App Router param props arrive percent-ENCODED and core decodes them — Pages surfaces opt out via `percentDecode: false` (R5); no trailing slashes (R6) |
87
- | PP | `p.array` typed elements by composition (PP1); `p.index` is 1-based on the wire, 0-based in memory (PP5) |
79
+ | Family | Scope |
80
+ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81
+ | S | Byte layer: `%20` never `+` (S2); absence = omitted key, `""` = `key=` (S3); never bare `?flag` (S4); deterministic declaration order (S5); hash only from `href`'s explicit option, verbatim (S10) |
82
+ | P | Parse layer: duplicate values on a scalar codec are an error, catchable (P5); absent array key → `[]` (P6); unknown keys ignored and never validated (P8) |
83
+ | SS | `rawSearch`: explicit wrapper only; schema sees every key on decode; encode is a raw pass-through, schema never runs; no elision/round-trip |
84
+ | D | Codec grammar: `.catch()` recovers parse failures never absence (D2); presence governs absence and every declared key appears in decode output (D4); params take no presence modifiers (D5); a catch-all codec describes one element (D6); value defaults elide (D8) |
85
+ | CV | `p.csv`: one comma-joined wire value; `""` ↔ `[]`; empty elements are parse failures; serialize rejects elements that are empty or contain commas |
86
+ | R | Route segments: one segment per `[param]` (R1); catch-all elements encode independently, inner `/` → `%2F` (R2); optional catch-all elides, required `[]` is a `SerializeError` (R3); empty segment value is a `SerializeError` (R4); App Router param props arrive percent-ENCODED and core decodes them — Pages surfaces opt out via `percentDecode: false` (R5); no trailing slashes (R6), unless the route sets `trailingSlash: true`, which ends every non-root built path in `/` before `?`/`#` |
87
+ | PP | `p.array` typed elements by composition (PP1); `p.index` is 1-based on the wire, 0-based in memory (PP5) |
88
88
 
89
89
  Facts agents trip on:
90
90