@paramour-js/next 0.11.1 → 0.11.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/dist/doctor/checks.js +1 -2
- package/dist/init/wrap-next-config.d.ts +7 -6
- package/dist/init/wrap-next-config.js +110 -10
- package/package.json +2 -2
- package/skills/paramour/SKILL.md +1 -1
- package/skills/paramour/references/authoring.md +1 -1
- package/skills/paramour/references/migration.md +1 -1
package/dist/doctor/checks.js
CHANGED
|
@@ -241,8 +241,7 @@ async function trailingSlashCheck(nextConfigPath, definitions) {
|
|
|
241
241
|
const setting = `trailingSlash: ${String(configured)}`;
|
|
242
242
|
// The routes come from the app's own paramour, which may predate the
|
|
243
243
|
// option and lack the member: missing reads as the R6 default.
|
|
244
|
-
const mismatched = definitions.filter((definition) => (definition.route["~trailingSlash"] ?? false) !==
|
|
245
|
-
configured);
|
|
244
|
+
const mismatched = definitions.filter((definition) => (definition.route["~trailingSlash"] ?? false) !== configured);
|
|
246
245
|
if (mismatched.length === 0) {
|
|
247
246
|
return {
|
|
248
247
|
label: `trailing slash: route definitions match ${name} (${setting})`,
|
|
@@ -30,12 +30,13 @@ export declare function findNextConfig(projectRoot: string): FoundNextConfig | u
|
|
|
30
30
|
export declare function manualSnippet(): string;
|
|
31
31
|
/**
|
|
32
32
|
* Reads `trailingSlash` from a next.config source for `doctor`, statically:
|
|
33
|
-
* the default export (or CJS `module.exports`) is followed through
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* the default export (or CJS `module.exports`) is followed through
|
|
34
|
+
* single-argument wrapper calls, TS `as`/`satisfies`, and top-level bindings
|
|
35
|
+
* to an object literal. A literal without the key is Next's default, `false`.
|
|
36
|
+
* Anything else (a config function, a non-literal value, a spread that could
|
|
37
|
+
* carry the key, a multi-argument call, a binding written after its
|
|
38
|
+
* declaration) is `undefined`: doctor stays quiet rather than guess, since
|
|
39
|
+
* evaluating the config would run user code.
|
|
39
40
|
*/
|
|
40
41
|
export declare function readTrailingSlash(source: string): Promise<boolean | undefined>;
|
|
41
42
|
/**
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
const IMPORT_LINE = `import { withTypedRoutes } from "@paramour-js/next";`;
|
|
4
|
+
/** {@link writtenBindings}' name for `module.exports` (no identifier has a dot). */
|
|
5
|
+
const MODULE_EXPORTS = "module.exports";
|
|
6
|
+
/** The `Object` statics that write their first argument's properties. */
|
|
7
|
+
const OBJECT_WRITERS = new Set([
|
|
8
|
+
"assign",
|
|
9
|
+
"defineProperties",
|
|
10
|
+
"defineProperty",
|
|
11
|
+
]);
|
|
4
12
|
/**
|
|
5
13
|
* Wrap-state probe for `doctor`: same import/callee detection the codemod
|
|
6
14
|
* uses for idempotence, without mutating anything.
|
|
@@ -43,12 +51,13 @@ export function manualSnippet() {
|
|
|
43
51
|
}
|
|
44
52
|
/**
|
|
45
53
|
* Reads `trailingSlash` from a next.config source for `doctor`, statically:
|
|
46
|
-
* the default export (or CJS `module.exports`) is followed through
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
54
|
+
* the default export (or CJS `module.exports`) is followed through
|
|
55
|
+
* single-argument wrapper calls, TS `as`/`satisfies`, and top-level bindings
|
|
56
|
+
* to an object literal. A literal without the key is Next's default, `false`.
|
|
57
|
+
* Anything else (a config function, a non-literal value, a spread that could
|
|
58
|
+
* carry the key, a multi-argument call, a binding written after its
|
|
59
|
+
* declaration) is `undefined`: doctor stays quiet rather than guess, since
|
|
60
|
+
* evaluating the config would run user code.
|
|
52
61
|
*/
|
|
53
62
|
export async function readTrailingSlash(source) {
|
|
54
63
|
const { parseModule } = await import("magicast");
|
|
@@ -59,6 +68,7 @@ export async function readTrailingSlash(source) {
|
|
|
59
68
|
catch {
|
|
60
69
|
return undefined;
|
|
61
70
|
}
|
|
71
|
+
const written = writtenBindings(program);
|
|
62
72
|
const body = asNodes(program.body);
|
|
63
73
|
const bindings = new Map();
|
|
64
74
|
let exported;
|
|
@@ -66,7 +76,9 @@ export async function readTrailingSlash(source) {
|
|
|
66
76
|
if (statement.type === "VariableDeclaration") {
|
|
67
77
|
for (const declarator of asNodes(statement.declarations)) {
|
|
68
78
|
const id = asNode(declarator.id);
|
|
69
|
-
if (id?.type === "Identifier" &&
|
|
79
|
+
if (id?.type === "Identifier" &&
|
|
80
|
+
typeof id.name === "string" &&
|
|
81
|
+
!written.has(id.name)) {
|
|
70
82
|
bindings.set(id.name, declarator.init);
|
|
71
83
|
}
|
|
72
84
|
}
|
|
@@ -78,7 +90,7 @@ export async function readTrailingSlash(source) {
|
|
|
78
90
|
const expression = asNode(statement.expression);
|
|
79
91
|
if (expression?.type === "AssignmentExpression" &&
|
|
80
92
|
isModuleExports(asNode(expression.left))) {
|
|
81
|
-
exported = expression.right;
|
|
93
|
+
exported = written.has(MODULE_EXPORTS) ? undefined : expression.right;
|
|
82
94
|
}
|
|
83
95
|
}
|
|
84
96
|
}
|
|
@@ -181,8 +193,13 @@ function trailingSlashOf(value, bindings, depth) {
|
|
|
181
193
|
return undefined;
|
|
182
194
|
switch (node.type) {
|
|
183
195
|
case "CallExpression": {
|
|
184
|
-
|
|
185
|
-
|
|
196
|
+
// Only a single-argument wrapper (`withTypedRoutes(config)`, curried
|
|
197
|
+
// `withX(options)(config)`) is followed. With more arguments nothing
|
|
198
|
+
// says which one is the config, and plugin options often come first.
|
|
199
|
+
const [only, ...rest] = asNodes(node.arguments);
|
|
200
|
+
return rest.length === 0
|
|
201
|
+
? trailingSlashOf(only, bindings, depth + 1)
|
|
202
|
+
: undefined;
|
|
186
203
|
}
|
|
187
204
|
case "Identifier": {
|
|
188
205
|
return typeof node.name === "string" && bindings.has(node.name)
|
|
@@ -221,3 +238,86 @@ function trailingSlashOf(value, bindings, depth) {
|
|
|
221
238
|
function withTypedRoutesLocal(items) {
|
|
222
239
|
return items.find((item) => item.from === "@paramour-js/next" && item.imported === "withTypedRoutes")?.local;
|
|
223
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* The binding a write lands on: an identifier, or `module.exports`. TS
|
|
243
|
+
* wrappers are seen through, so `(config as NextConfig).x = …` counts.
|
|
244
|
+
*/
|
|
245
|
+
function writeTarget(value) {
|
|
246
|
+
let node = asNode(value);
|
|
247
|
+
while (node?.type === "ParenthesizedExpression" ||
|
|
248
|
+
node?.type === "TSAsExpression" ||
|
|
249
|
+
node?.type === "TSNonNullExpression" ||
|
|
250
|
+
node?.type === "TSSatisfiesExpression") {
|
|
251
|
+
node = asNode(node.expression);
|
|
252
|
+
}
|
|
253
|
+
if (node?.type === "Identifier" && typeof node.name === "string") {
|
|
254
|
+
return node.name;
|
|
255
|
+
}
|
|
256
|
+
return isModuleExports(node) ? MODULE_EXPORTS : undefined;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Every binding the program writes after its declaration, which makes its
|
|
260
|
+
* declared value an unreliable read: a direct property write or delete
|
|
261
|
+
* (`c.x = …`, `c[k] = …`, `delete c.x`), an `Object.assign` /
|
|
262
|
+
* `Object.defineProperty` / `Object.defineProperties` target, or a
|
|
263
|
+
* reassignment (`c = …`, or `module.exports` assigned more than once). A
|
|
264
|
+
* write to a nested object (`c.experimental.x = …`) is not counted, since it
|
|
265
|
+
* cannot change `c.trailingSlash`. The whole program is searched, not just
|
|
266
|
+
* the top level, because a static-export toggle usually sits under an `if`.
|
|
267
|
+
* Scope is ignored, so a shadowing parameter of the same name also counts;
|
|
268
|
+
* that errs toward unknown, which only costs a finding.
|
|
269
|
+
*/
|
|
270
|
+
function writtenBindings(program) {
|
|
271
|
+
const written = new Set();
|
|
272
|
+
let moduleExportsAssignments = 0;
|
|
273
|
+
const add = (target) => {
|
|
274
|
+
if (target !== undefined)
|
|
275
|
+
written.add(target);
|
|
276
|
+
};
|
|
277
|
+
const visit = (node) => {
|
|
278
|
+
if (node.type === "AssignmentExpression") {
|
|
279
|
+
const left = asNode(node.left);
|
|
280
|
+
// `module.exports` is itself a member expression: test it first.
|
|
281
|
+
if (isModuleExports(left)) {
|
|
282
|
+
moduleExportsAssignments += 1;
|
|
283
|
+
}
|
|
284
|
+
else if (left?.type === "MemberExpression") {
|
|
285
|
+
add(writeTarget(left.object));
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
add(writeTarget(left));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else if (node.type === "UnaryExpression" && node.operator === "delete") {
|
|
292
|
+
const argument = asNode(node.argument);
|
|
293
|
+
if (argument?.type === "MemberExpression") {
|
|
294
|
+
add(writeTarget(argument.object));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (node.type === "CallExpression") {
|
|
298
|
+
const callee = asNode(node.callee);
|
|
299
|
+
const object = asNode(callee?.object);
|
|
300
|
+
const property = asNode(callee?.property);
|
|
301
|
+
if (callee?.type === "MemberExpression" &&
|
|
302
|
+
callee.computed !== true &&
|
|
303
|
+
object?.type === "Identifier" &&
|
|
304
|
+
object.name === "Object" &&
|
|
305
|
+
property?.type === "Identifier" &&
|
|
306
|
+
typeof property.name === "string" &&
|
|
307
|
+
OBJECT_WRITERS.has(property.name)) {
|
|
308
|
+
add(writeTarget(asNodes(node.arguments)[0]));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const child of Object.values(node)) {
|
|
312
|
+
for (const item of Array.isArray(child) ? asNodes(child) : [child]) {
|
|
313
|
+
const next = asNode(item);
|
|
314
|
+
if (next !== undefined)
|
|
315
|
+
visit(next);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
visit(program);
|
|
320
|
+
if (moduleExportsAssignments > 1)
|
|
321
|
+
written.add(MODULE_EXPORTS);
|
|
322
|
+
return written;
|
|
323
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paramour-js/next",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
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.11.
|
|
54
|
+
"paramour": "0.11.3"
|
|
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>",
|
package/skills/paramour/SKILL.md
CHANGED
|
@@ -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. On an `.optional()` codec the fallback may be `undefined`, so a bad value reads as absent (`.optional().catch(undefined)`).
|
|
15
|
+
- `.catch()` applies at most once and combines with either presence modifier in either order, except that an `undefined` fallback needs `.optional()` first. 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.
|
|
@@ -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. 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`.
|
|
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 `/`. When every route needs it, re-export a typed wrapper (`export const defineAppRoute: typeof defineRoute = (path, config) => defineRoute(path, { trailingSlash: true, ...config });`) instead of repeating it. The string form `href("/about")` has no route object, so it never adds the slash: in a `trailingSlash` app, link through the route object. In tests, set `process.env.__NEXT_TRAILING_SLASH = "true"`, or `next/link` strips the slash from rendered hrefs. Lower-level pieces: `buildPath(route, params)`, `searchToString(config, input)`, `encodeStaticParams(route, params)` for `generateStaticParams`/`getStaticPaths`.
|
|
96
96
|
|
|
97
97
|
## Client hooks
|
|
98
98
|
|
|
@@ -76,7 +76,7 @@ export default async function ProductPage(props: RouteProps) {
|
|
|
76
76
|
- `sp.x ?? "default"` / `Number(sp.x ?? "1")` → codec with `.default(value)`. The decoded field is non-optional and the default value elides from built URLs.
|
|
77
77
|
- "May be absent, no fallback" (`typeof sp.x === "string" ? sp.x : undefined`) → `.optional()`. Decoded as `T | undefined`.
|
|
78
78
|
- Silent-coercion tolerance (old code shrugged off garbage, e.g. `Number(...)` producing `NaN` handled downstream) → add `.catch(fallback)` so a malformed PRESENT value falls back instead of failing the decode. `.catch()` never covers absence — combine with `.default()`/`.optional()` for that.
|
|
79
|
-
- Multi-value keys (`sp.tags` handled as `string | string[]`) → `p.array()` for repeated keys (`?tags=a&tags=b`) or `p.csv()` for one comma-joined key (`?tags=a,b`). Match whichever wire form the app already emits.
|
|
79
|
+
- Multi-value keys (`sp.tags` handled as `string | string[]`) → `p.array()` for repeated keys (`?tags=a&tags=b`) or `p.csv()` for one comma-joined key (`?tags=a,b`). Match whichever wire form the app already emits. Lists that nuqs's `parseAsArrayOf` already wrote (elements with `%2C`-escaped commas, bad elements dropped) → `nuqsArrayOf(element)` from `@paramour-js/nuqs`, not `p.csv()`.
|
|
80
80
|
- Enumerated strings → `p.enum(["a", "b"])`; numbers → `p.number()`; booleans (`sp.flag === "true"`) → `p.boolean()`; dates → `p.isoDate()` (YYYY-MM-DD) or `p.timestamp()` (ISO instant; emits UTC).
|
|
81
81
|
|
|
82
82
|
### Behavior change to decide explicitly
|