@paramour-js/next 0.1.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.
- package/LICENSE +21 -0
- package/bin/paramour.js +19 -0
- package/dist/app.d.ts +76 -0
- package/dist/app.js +32 -0
- package/dist/cli-args.d.ts +28 -0
- package/dist/cli-args.js +35 -0
- package/dist/cli-inputs.d.ts +32 -0
- package/dist/cli-inputs.js +80 -0
- package/dist/cli-io.d.ts +15 -0
- package/dist/cli-io.js +16 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +5 -0
- package/dist/collisions.d.ts +37 -0
- package/dist/collisions.js +80 -0
- package/dist/commands/doctor.d.ts +7 -0
- package/dist/commands/doctor.js +56 -0
- package/dist/commands/generate.d.ts +11 -0
- package/dist/commands/generate.js +222 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +205 -0
- package/dist/commands/list.d.ts +9 -0
- package/dist/commands/list.js +94 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +99 -0
- package/dist/doctor/checks.d.ts +16 -0
- package/dist/doctor/checks.js +231 -0
- package/dist/emit.d.ts +35 -0
- package/dist/emit.js +74 -0
- package/dist/generate.d.ts +70 -0
- package/dist/generate.js +106 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/init/scaffold.d.ts +39 -0
- package/dist/init/scaffold.js +244 -0
- package/dist/init/wrap-next-config.d.ts +41 -0
- package/dist/init/wrap-next-config.js +99 -0
- package/dist/list/discover-route-defs.d.ts +52 -0
- package/dist/list/discover-route-defs.js +121 -0
- package/dist/list/render.d.ts +35 -0
- package/dist/list/render.js +132 -0
- package/dist/lock.d.ts +29 -0
- package/dist/lock.js +88 -0
- package/dist/pages.d.ts +49 -0
- package/dist/pages.js +62 -0
- package/dist/run-cli.d.ts +11 -0
- package/dist/run-cli.js +53 -0
- package/dist/scan-app.d.ts +30 -0
- package/dist/scan-app.js +149 -0
- package/dist/scan-pages.d.ts +10 -0
- package/dist/scan-pages.js +102 -0
- package/dist/scan.d.ts +32 -0
- package/dist/scan.js +77 -0
- package/dist/select.d.ts +94 -0
- package/dist/select.js +195 -0
- package/dist/watch.d.ts +39 -0
- package/dist/watch.js +87 -0
- package/dist/with-typed-routes.d.ts +44 -0
- package/dist/with-typed-routes.js +200 -0
- package/package.json +67 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { resolveRouteDirs } from "../scan.js";
|
|
4
|
+
/**
|
|
5
|
+
* Insert `"paramour": "paramour generate"` into a package.json's scripts,
|
|
6
|
+
* preserving the file's own indentation and trailing-newline choice.
|
|
7
|
+
* Throws on malformed JSON — a broken package.json is init's one hard
|
|
8
|
+
* prerequisite failure.
|
|
9
|
+
*/
|
|
10
|
+
export function addPackageScript(text) {
|
|
11
|
+
const parsed = JSON.parse(text);
|
|
12
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
13
|
+
throw new Error("package.json must contain an object");
|
|
14
|
+
}
|
|
15
|
+
const pkg = parsed;
|
|
16
|
+
if (pkg.scripts !== undefined &&
|
|
17
|
+
(typeof pkg.scripts !== "object" ||
|
|
18
|
+
pkg.scripts === null ||
|
|
19
|
+
Array.isArray(pkg.scripts))) {
|
|
20
|
+
// Spreading an array/string would silently rewrite it as index keys.
|
|
21
|
+
throw new Error(`"scripts" must be an object`);
|
|
22
|
+
}
|
|
23
|
+
const scripts = pkg.scripts;
|
|
24
|
+
if (scripts?.paramour !== undefined)
|
|
25
|
+
return { changed: false, text };
|
|
26
|
+
pkg.scripts = { ...scripts, paramour: "paramour generate" };
|
|
27
|
+
const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
|
|
28
|
+
const trailing = text.endsWith("\n") ? "\n" : "";
|
|
29
|
+
return { changed: true, text: JSON.stringify(pkg, null, indent) + trailing };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The detect-and-verify summary: route dirs discoverable, both packages
|
|
33
|
+
* declared, tsconfig covering the artifact. Warn-level throughout — a fresh
|
|
34
|
+
* project legitimately fails several of these, so none affect init's exit
|
|
35
|
+
* code.
|
|
36
|
+
*/
|
|
37
|
+
export function checkSetup(projectRoot, artifactPath) {
|
|
38
|
+
const checks = [];
|
|
39
|
+
try {
|
|
40
|
+
const dirs = resolveRouteDirs(projectRoot);
|
|
41
|
+
const found = [dirs.appDir, dirs.pagesDir]
|
|
42
|
+
.filter((dir) => dir !== undefined)
|
|
43
|
+
.map((dir) => `${relative(projectRoot, dir).replaceAll("\\", "/")}/`);
|
|
44
|
+
checks.push(found.length > 0
|
|
45
|
+
? { label: `route directories: ${found.join(", ")}`, ok: true }
|
|
46
|
+
: {
|
|
47
|
+
detail: "create app/ or pages/, then run `paramour generate`",
|
|
48
|
+
label: "no route directory found yet",
|
|
49
|
+
ok: false,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
checks.push({
|
|
54
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
55
|
+
label: "route-directory discovery failed",
|
|
56
|
+
ok: false,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
checks.push(dependenciesCheck(projectRoot));
|
|
60
|
+
checks.push(tsconfigCheck(projectRoot, artifactPath));
|
|
61
|
+
return checks;
|
|
62
|
+
}
|
|
63
|
+
/** The starter `paramour.config.ts` — every field commented-out defaults. */
|
|
64
|
+
export function paramourConfigTemplate() {
|
|
65
|
+
return [
|
|
66
|
+
`import type { ParamourConfig } from "@paramour-js/next";`,
|
|
67
|
+
"",
|
|
68
|
+
"/**",
|
|
69
|
+
" * Paramour CLI configuration. Every field is optional and every value",
|
|
70
|
+
" * below is the default — deleting this file changes nothing.",
|
|
71
|
+
" */",
|
|
72
|
+
"export default {",
|
|
73
|
+
` // appDir: "app",`,
|
|
74
|
+
` // outFile: "paramour-env.d.ts",`,
|
|
75
|
+
` // pageExtensions: ["tsx", "ts", "jsx", "js"],`,
|
|
76
|
+
` // pagesDir: "pages",`,
|
|
77
|
+
` // routeFiles: ["src/routes/**/*.ts"], // pin \`paramour list\`'s definition scan`,
|
|
78
|
+
"} satisfies ParamourConfig;",
|
|
79
|
+
"",
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Tolerant-enough JSONC → JSON for tsconfig reads: strips line and block
|
|
84
|
+
* comments outside strings, then trailing commas. Heuristic by design — the
|
|
85
|
+
* one consumer is a warn-level check.
|
|
86
|
+
*/
|
|
87
|
+
export function stripJsonComments(text) {
|
|
88
|
+
let out = "";
|
|
89
|
+
let inString = false;
|
|
90
|
+
for (let i = 0; i < text.length; i++) {
|
|
91
|
+
// charAt, not indexing: "" past the end instead of undefined, so the
|
|
92
|
+
// lookahead needs no noUncheckedIndexedAccess dance.
|
|
93
|
+
const ch = text.charAt(i);
|
|
94
|
+
const next = text.charAt(i + 1);
|
|
95
|
+
if (inString) {
|
|
96
|
+
out += ch;
|
|
97
|
+
if (ch === "\\") {
|
|
98
|
+
out += next;
|
|
99
|
+
i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ch === '"')
|
|
103
|
+
inString = false;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ch === '"') {
|
|
107
|
+
inString = true;
|
|
108
|
+
out += ch;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ch === "/" && next === "/") {
|
|
112
|
+
while (i < text.length && text.charAt(i) !== "\n")
|
|
113
|
+
i++;
|
|
114
|
+
out += "\n";
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (ch === "/" && next === "*") {
|
|
118
|
+
i += 2;
|
|
119
|
+
while (i < text.length &&
|
|
120
|
+
!(text.charAt(i) === "*" && text.charAt(i + 1) === "/")) {
|
|
121
|
+
i++;
|
|
122
|
+
}
|
|
123
|
+
i++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
out += ch;
|
|
127
|
+
}
|
|
128
|
+
return stripTrailingCommas(out);
|
|
129
|
+
}
|
|
130
|
+
/** Exported for `doctor`, which reports the same heuristic as its own check. */
|
|
131
|
+
export function tsconfigCheck(projectRoot, artifactPath) {
|
|
132
|
+
const tsconfigPath = join(projectRoot, "tsconfig.json");
|
|
133
|
+
const artifactRel = relative(projectRoot, artifactPath).replaceAll("\\", "/");
|
|
134
|
+
if (!existsSync(tsconfigPath)) {
|
|
135
|
+
return {
|
|
136
|
+
label: "no tsconfig.json — artifact-coverage check skipped",
|
|
137
|
+
ok: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
let include;
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(stripJsonComments(readFileSync(tsconfigPath, "utf8")));
|
|
143
|
+
include = parsed.include;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return {
|
|
147
|
+
detail: `could not parse it; make sure "${artifactRel}" is covered by \`include\``,
|
|
148
|
+
label: "tsconfig.json unreadable — artifact coverage unverified",
|
|
149
|
+
ok: false,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (include === undefined) {
|
|
153
|
+
return {
|
|
154
|
+
label: `tsconfig.json covers ${artifactRel} (no include list)`,
|
|
155
|
+
ok: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(include) &&
|
|
159
|
+
include.some((pattern) => typeof pattern === "string" &&
|
|
160
|
+
tsconfigPatternCovers(pattern, artifactRel))) {
|
|
161
|
+
return { label: `tsconfig.json includes ${artifactRel}`, ok: true };
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
detail: `add "${artifactRel}" to \`include\` so the registry augmentation loads`,
|
|
165
|
+
label: `tsconfig include may not cover ${artifactRel}`,
|
|
166
|
+
ok: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Does a tsconfig `include` pattern cover the artifact? NOT a glob engine:
|
|
171
|
+
* exact match, directory prefix, or a `**` pattern whose extension can
|
|
172
|
+
* match a `.d.ts` (Next's default globstar-`.ts` include is the target case).
|
|
173
|
+
*/
|
|
174
|
+
export function tsconfigPatternCovers(pattern, artifactRel) {
|
|
175
|
+
const normalized = pattern.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
176
|
+
if (normalized === artifactRel)
|
|
177
|
+
return true;
|
|
178
|
+
if (!normalized.includes("*")) {
|
|
179
|
+
return artifactRel.startsWith(`${normalized.replace(/\/$/, "")}/`);
|
|
180
|
+
}
|
|
181
|
+
// The literal directory prefix before the first wildcard still restricts
|
|
182
|
+
// the match: `src/**/*.ts` cannot cover a root-level artifact.
|
|
183
|
+
const prefix = normalized
|
|
184
|
+
.slice(0, normalized.indexOf("*"))
|
|
185
|
+
.replace(/[^/]*$/, "");
|
|
186
|
+
return (artifactRel.startsWith(prefix) &&
|
|
187
|
+
normalized.includes("**") &&
|
|
188
|
+
(normalized.endsWith(".ts") || normalized.endsWith("*")));
|
|
189
|
+
}
|
|
190
|
+
function dependenciesCheck(projectRoot) {
|
|
191
|
+
let declared = {};
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8"));
|
|
194
|
+
declared = { ...parsed.dependencies, ...parsed.devDependencies };
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Unreadable package.json is reported by init's own prerequisite check.
|
|
198
|
+
}
|
|
199
|
+
const missing = ["@paramour-js/next", "paramour"].filter((name) => declared[name] === undefined);
|
|
200
|
+
return missing.length === 0
|
|
201
|
+
? { label: "dependencies declared: paramour, @paramour-js/next", ok: true }
|
|
202
|
+
: {
|
|
203
|
+
detail: "add them to package.json and install",
|
|
204
|
+
label: `missing dependencies: ${missing.join(", ")}`,
|
|
205
|
+
ok: false,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* String-aware trailing-comma removal over comment-free JSONC — a flat
|
|
210
|
+
* regex would also rewrite `,}`/`,]` inside string literals (glob patterns).
|
|
211
|
+
*/
|
|
212
|
+
function stripTrailingCommas(text) {
|
|
213
|
+
let out = "";
|
|
214
|
+
let inString = false;
|
|
215
|
+
for (let i = 0; i < text.length; i++) {
|
|
216
|
+
const ch = text.charAt(i);
|
|
217
|
+
if (inString) {
|
|
218
|
+
out += ch;
|
|
219
|
+
if (ch === "\\") {
|
|
220
|
+
out += text.charAt(i + 1);
|
|
221
|
+
i++;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (ch === '"')
|
|
225
|
+
inString = false;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (ch === '"') {
|
|
229
|
+
inString = true;
|
|
230
|
+
out += ch;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (ch === ",") {
|
|
234
|
+
let j = i + 1;
|
|
235
|
+
while (j < text.length && /\s/.test(text.charAt(j)))
|
|
236
|
+
j++;
|
|
237
|
+
const sig = text.charAt(j);
|
|
238
|
+
if (sig === "}" || sig === "]")
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
out += ch;
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `paramour init` next.config codemod (magicast: recast printing +
|
|
3
|
+
* babel-ts parsing, so TS/ESM configs transform format-preservingly).
|
|
4
|
+
* Anything the transform can't handle CONFIDENTLY degrades to a `manual`
|
|
5
|
+
* result carrying the exact snippet — init prints it and still exits 0; a
|
|
6
|
+
* printed instruction beats a mangled config.
|
|
7
|
+
*/
|
|
8
|
+
/** A next.config file found at the project root. */
|
|
9
|
+
export interface FoundNextConfig {
|
|
10
|
+
lang: "js" | "mjs" | "ts";
|
|
11
|
+
path: string;
|
|
12
|
+
}
|
|
13
|
+
export type WrapResult = {
|
|
14
|
+
code: string;
|
|
15
|
+
status: "wrapped";
|
|
16
|
+
} | {
|
|
17
|
+
snippet: string;
|
|
18
|
+
status: "manual";
|
|
19
|
+
} | {
|
|
20
|
+
status: "already-wrapped";
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Wrap-state probe for `doctor`: same import/callee detection the codemod
|
|
24
|
+
* uses for idempotence, without mutating anything.
|
|
25
|
+
*/
|
|
26
|
+
export declare function detectWrapState(source: string): Promise<"not-wrapped" | "unparseable" | "wrapped">;
|
|
27
|
+
/** Probe order mirrors init's scaffolding preference: ts, mjs, js. */
|
|
28
|
+
export declare function findNextConfig(projectRoot: string): FoundNextConfig | undefined;
|
|
29
|
+
/** The `manual` fallback text — also printed when no config file exists. */
|
|
30
|
+
export declare function manualSnippet(): string;
|
|
31
|
+
/**
|
|
32
|
+
* The transform: add the named import (unless present under any alias) and
|
|
33
|
+
* rewrap the default export — identifier, object literal, existing wrapper
|
|
34
|
+
* call (`withBundleAnalyzer(...)` → wrapped outermost), or function/arrow
|
|
35
|
+
* form (withTypedRoutes accepts the config-function shape) all take the
|
|
36
|
+
* same path. Manual fallbacks: parse failure, no default export (includes
|
|
37
|
+
* CJS `module.exports` — this package is ESM-only, so a generated
|
|
38
|
+
* `require()` would be a trap), or any shape magicast refuses to rebuild.
|
|
39
|
+
* Idempotent: an already-wrapped export is detected, never double-wrapped.
|
|
40
|
+
*/
|
|
41
|
+
export declare function wrapNextConfigSource(source: string): Promise<WrapResult>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const IMPORT_LINE = `import { withTypedRoutes } from "@paramour-js/next";`;
|
|
4
|
+
/**
|
|
5
|
+
* Wrap-state probe for `doctor`: same import/callee detection the codemod
|
|
6
|
+
* uses for idempotence, without mutating anything.
|
|
7
|
+
*/
|
|
8
|
+
export async function detectWrapState(source) {
|
|
9
|
+
const { parseModule } = await import("magicast");
|
|
10
|
+
try {
|
|
11
|
+
const mod = parseModule(source);
|
|
12
|
+
const local = withTypedRoutesLocal(mod.imports.$items);
|
|
13
|
+
return isWrappedCall(mod.exports.default, local)
|
|
14
|
+
? "wrapped"
|
|
15
|
+
: "not-wrapped";
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return "unparseable";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Probe order mirrors init's scaffolding preference: ts, mjs, js. */
|
|
22
|
+
export function findNextConfig(projectRoot) {
|
|
23
|
+
const probes = [
|
|
24
|
+
["next.config.ts", "ts"],
|
|
25
|
+
["next.config.mjs", "mjs"],
|
|
26
|
+
["next.config.js", "js"],
|
|
27
|
+
];
|
|
28
|
+
for (const [name, lang] of probes) {
|
|
29
|
+
const path = join(projectRoot, name);
|
|
30
|
+
if (existsSync(path))
|
|
31
|
+
return { lang, path };
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
/** The `manual` fallback text — also printed when no config file exists. */
|
|
36
|
+
export function manualSnippet() {
|
|
37
|
+
return [
|
|
38
|
+
IMPORT_LINE,
|
|
39
|
+
"",
|
|
40
|
+
"// wrap your existing config export:",
|
|
41
|
+
"export default withTypedRoutes(nextConfig);",
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The transform: add the named import (unless present under any alias) and
|
|
46
|
+
* rewrap the default export — identifier, object literal, existing wrapper
|
|
47
|
+
* call (`withBundleAnalyzer(...)` → wrapped outermost), or function/arrow
|
|
48
|
+
* form (withTypedRoutes accepts the config-function shape) all take the
|
|
49
|
+
* same path. Manual fallbacks: parse failure, no default export (includes
|
|
50
|
+
* CJS `module.exports` — this package is ESM-only, so a generated
|
|
51
|
+
* `require()` would be a trap), or any shape magicast refuses to rebuild.
|
|
52
|
+
* Idempotent: an already-wrapped export is detected, never double-wrapped.
|
|
53
|
+
*/
|
|
54
|
+
export async function wrapNextConfigSource(source) {
|
|
55
|
+
const { builders, generateCode, parseModule } = await import("magicast");
|
|
56
|
+
let mod;
|
|
57
|
+
try {
|
|
58
|
+
mod = parseModule(source);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { snippet: manualSnippet(), status: "manual" };
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const local = withTypedRoutesLocal(mod.imports.$items);
|
|
65
|
+
const current = mod.exports.default;
|
|
66
|
+
if (current === undefined) {
|
|
67
|
+
return { snippet: manualSnippet(), status: "manual" };
|
|
68
|
+
}
|
|
69
|
+
if (isWrappedCall(current, local)) {
|
|
70
|
+
return { status: "already-wrapped" };
|
|
71
|
+
}
|
|
72
|
+
if (local === undefined) {
|
|
73
|
+
mod.imports.$prepend({
|
|
74
|
+
from: "@paramour-js/next",
|
|
75
|
+
imported: "withTypedRoutes",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
mod.exports.default = builders.functionCall(local ?? "withTypedRoutes", current);
|
|
79
|
+
return { code: generateCode(mod).code, status: "wrapped" };
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return { snippet: manualSnippet(), status: "manual" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function isWrappedCall(value, local) {
|
|
86
|
+
if (typeof value !== "object" || value === null)
|
|
87
|
+
return false;
|
|
88
|
+
const node = value;
|
|
89
|
+
return (node.$type === "function-call" &&
|
|
90
|
+
typeof node.$callee === "string" &&
|
|
91
|
+
// Member-expression callees (`ptr.withTypedRoutes(...)`) count even
|
|
92
|
+
// without a named import — a namespace-wrapped config must not be
|
|
93
|
+
// double-wrapped (the prepended named import would be invalid ES).
|
|
94
|
+
((local !== undefined && node.$callee === local) ||
|
|
95
|
+
node.$callee.endsWith(".withTypedRoutes")));
|
|
96
|
+
}
|
|
97
|
+
function withTypedRoutesLocal(items) {
|
|
98
|
+
return items.find((item) => item.from === "@paramour-js/next" && item.imported === "withTypedRoutes")?.local;
|
|
99
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AnyRoute, RouterKind } from "paramour";
|
|
2
|
+
/**
|
|
3
|
+
* Route-definition discovery for `paramour list`/`doctor`: find the modules
|
|
4
|
+
* that call defineAppRoute/definePagesRoute, evaluate them, and collect the
|
|
5
|
+
* exported route objects. Generation never runs any of this — route shapes
|
|
6
|
+
* exist only on runtime route objects, so reading them means executing user
|
|
7
|
+
* modules; every failure degrades per-module rather than aborting the scan.
|
|
8
|
+
*/
|
|
9
|
+
export interface DiscoveryResult {
|
|
10
|
+
/** Deduped by `(router, path)`, in sorted-file order — first export wins. */
|
|
11
|
+
definitions: RouteDefinition[];
|
|
12
|
+
duplicates: DuplicateDefinition[];
|
|
13
|
+
/** Modules that matched the scan but threw when evaluated. */
|
|
14
|
+
loadFailures: LoadFailure[];
|
|
15
|
+
}
|
|
16
|
+
/** A later definition of a `(router, path)` already seen; first wins. */
|
|
17
|
+
export interface DuplicateDefinition {
|
|
18
|
+
file: string;
|
|
19
|
+
firstFile: string;
|
|
20
|
+
path: string;
|
|
21
|
+
router: RouterKind;
|
|
22
|
+
}
|
|
23
|
+
export interface LoadFailure {
|
|
24
|
+
file: string;
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
/** One discovered route object and where it came from. */
|
|
28
|
+
export interface RouteDefinition {
|
|
29
|
+
exportName: string;
|
|
30
|
+
/** Project-root-relative, `/`-separated — stable across platforms. */
|
|
31
|
+
file: string;
|
|
32
|
+
route: AnyRoute;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Discovery = glob → content pre-filter → evaluate. The default glob is
|
|
36
|
+
* every source file outside the usual output dirs; a file is loaded only if
|
|
37
|
+
* its text mentions a define constructor (grep-then-load). `routeFiles`
|
|
38
|
+
* config globs replace the default patterns when the heuristic misfires.
|
|
39
|
+
*
|
|
40
|
+
* jiti evaluates matched files (the §7.2 loader carry-over). Known limits,
|
|
41
|
+
* both handled by the per-module degrade: tsconfig `paths` aliases are not
|
|
42
|
+
* resolved (a future improvement could feed them into jiti's `alias`
|
|
43
|
+
* option), and `server-only`-style imports throw outside Next.
|
|
44
|
+
*/
|
|
45
|
+
export declare function discoverRouteDefinitions(projectRoot: string, options?: {
|
|
46
|
+
routeFiles?: readonly string[] | undefined;
|
|
47
|
+
}): Promise<DiscoveryResult>;
|
|
48
|
+
/**
|
|
49
|
+
* The `(router, path)` identity key — discovery's dedupe, `list`'s
|
|
50
|
+
* definition overlay, and doctor's coverage count all build lookups with it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function routeKey(router: RouterKind, path: string): string;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { message } from "../cli-io.js";
|
|
4
|
+
// No .jsx/.tsx: jiti does not transform JSX, so a matched component file
|
|
5
|
+
// could never evaluate — it would only add load-failure noise (a page
|
|
6
|
+
// merely MENTIONING defineAppRoute in prose already trips the content
|
|
7
|
+
// pre-filter). Definitions living in JSX files need `routeFiles` globs.
|
|
8
|
+
const DEFAULT_PATTERNS = ["**/*.{js,mjs,mts,ts}"];
|
|
9
|
+
const IGNORE_PATTERNS = [
|
|
10
|
+
"**/.git/**",
|
|
11
|
+
"**/.next/**",
|
|
12
|
+
"**/build/**",
|
|
13
|
+
"**/coverage/**",
|
|
14
|
+
"**/dist/**",
|
|
15
|
+
"**/node_modules/**",
|
|
16
|
+
"**/out/**",
|
|
17
|
+
];
|
|
18
|
+
/** Files past this size skip the content pre-filter (bundles, lockfiles). */
|
|
19
|
+
const MAX_PREFILTER_BYTES = 512 * 1024;
|
|
20
|
+
/**
|
|
21
|
+
* Discovery = glob → content pre-filter → evaluate. The default glob is
|
|
22
|
+
* every source file outside the usual output dirs; a file is loaded only if
|
|
23
|
+
* its text mentions a define constructor (grep-then-load). `routeFiles`
|
|
24
|
+
* config globs replace the default patterns when the heuristic misfires.
|
|
25
|
+
*
|
|
26
|
+
* jiti evaluates matched files (the §7.2 loader carry-over). Known limits,
|
|
27
|
+
* both handled by the per-module degrade: tsconfig `paths` aliases are not
|
|
28
|
+
* resolved (a future improvement could feed them into jiti's `alias`
|
|
29
|
+
* option), and `server-only`-style imports throw outside Next.
|
|
30
|
+
*/
|
|
31
|
+
export async function discoverRouteDefinitions(projectRoot, options = {}) {
|
|
32
|
+
// Dynamic imports, same stance as config.ts: only commands that actually
|
|
33
|
+
// discover definitions pay for tinyglobby/jiti.
|
|
34
|
+
const { glob } = await import("tinyglobby");
|
|
35
|
+
const files = await glob(options.routeFiles === undefined
|
|
36
|
+
? DEFAULT_PATTERNS
|
|
37
|
+
: [...options.routeFiles], { absolute: true, cwd: projectRoot, ignore: IGNORE_PATTERNS });
|
|
38
|
+
// Deterministic load order — dedupe's "first wins" must not depend on
|
|
39
|
+
// filesystem enumeration order.
|
|
40
|
+
files.sort();
|
|
41
|
+
const candidates = files.filter(mentionsDefineCall);
|
|
42
|
+
const { createJiti } = await import("jiti");
|
|
43
|
+
const jiti = createJiti(import.meta.url, {
|
|
44
|
+
fsCache: false,
|
|
45
|
+
interopDefault: true,
|
|
46
|
+
});
|
|
47
|
+
const definitions = [];
|
|
48
|
+
const duplicates = [];
|
|
49
|
+
const loadFailures = [];
|
|
50
|
+
const seen = new Map();
|
|
51
|
+
for (const file of candidates) {
|
|
52
|
+
const relFile = relative(projectRoot, file).replaceAll("\\", "/");
|
|
53
|
+
let mod;
|
|
54
|
+
try {
|
|
55
|
+
mod = await jiti.import(file);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
loadFailures.push({ file: relFile, message: message(error) });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (typeof mod !== "object" || mod === null)
|
|
62
|
+
continue;
|
|
63
|
+
for (const [exportName, value] of Object.entries(mod)) {
|
|
64
|
+
if (!isRouteLike(value))
|
|
65
|
+
continue;
|
|
66
|
+
const key = routeKey(value["~router"], value.path);
|
|
67
|
+
const first = seen.get(key);
|
|
68
|
+
if (first !== undefined) {
|
|
69
|
+
duplicates.push({
|
|
70
|
+
file: relFile,
|
|
71
|
+
firstFile: first.file,
|
|
72
|
+
path: value.path,
|
|
73
|
+
router: value["~router"],
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const definition = {
|
|
78
|
+
exportName,
|
|
79
|
+
file: relFile,
|
|
80
|
+
route: value,
|
|
81
|
+
};
|
|
82
|
+
seen.set(key, definition);
|
|
83
|
+
definitions.push(definition);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { definitions, duplicates, loadFailures };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The `(router, path)` identity key — discovery's dedupe, `list`'s
|
|
90
|
+
* definition overlay, and doctor's coverage count all build lookups with it.
|
|
91
|
+
*/
|
|
92
|
+
export function routeKey(router, path) {
|
|
93
|
+
return `${router}\0${path}`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Structural, not `instanceof` or brand-equality: the user's route objects
|
|
97
|
+
* come from THEIR `paramour` instance — a different module realm than the
|
|
98
|
+
* CLI's own dependency.
|
|
99
|
+
*/
|
|
100
|
+
function isRouteLike(value) {
|
|
101
|
+
if (typeof value !== "object" || value === null)
|
|
102
|
+
return false;
|
|
103
|
+
const route = value;
|
|
104
|
+
return (typeof route.path === "string" &&
|
|
105
|
+
(route["~router"] === "app" || route["~router"] === "pages") &&
|
|
106
|
+
typeof route["~params"] === "object" &&
|
|
107
|
+
route["~params"] !== null &&
|
|
108
|
+
Array.isArray(route["~segments"]));
|
|
109
|
+
}
|
|
110
|
+
function mentionsDefineCall(file) {
|
|
111
|
+
try {
|
|
112
|
+
const size = statSync(file, { throwIfNoEntry: false })?.size ?? 0;
|
|
113
|
+
if (size > MAX_PREFILTER_BYTES)
|
|
114
|
+
return false;
|
|
115
|
+
const text = readFileSync(file, "utf8");
|
|
116
|
+
return text.includes("defineAppRoute") || text.includes("definePagesRoute");
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CodecDescription, RouteDescription } from "paramour";
|
|
2
|
+
import type { DuplicateDefinition, LoadFailure } from "./discover-route-defs.js";
|
|
3
|
+
/** A discovered definition with its reflection attached. */
|
|
4
|
+
export interface DescribedDefinition {
|
|
5
|
+
description: RouteDescription;
|
|
6
|
+
exportName: string;
|
|
7
|
+
file: string;
|
|
8
|
+
}
|
|
9
|
+
/** One filesystem route, with its definition overlay when one was found. */
|
|
10
|
+
export interface ListedRoute {
|
|
11
|
+
definition: DescribedDefinition | null;
|
|
12
|
+
path: string;
|
|
13
|
+
}
|
|
14
|
+
/** Everything `paramour list` renders, human or `--json`. */
|
|
15
|
+
export interface ListReport {
|
|
16
|
+
appRoutes: ListedRoute[];
|
|
17
|
+
duplicates: DuplicateDefinition[];
|
|
18
|
+
loadFailures: LoadFailure[];
|
|
19
|
+
/** Definitions whose `(router, path)` matched no filesystem route. */
|
|
20
|
+
orphans: DescribedDefinition[];
|
|
21
|
+
pagesRoutes: ListedRoute[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `--json` payload. Keys are alphabetical; a route with no definition
|
|
25
|
+
* carries `definition: null` rather than an absent member — friendlier to
|
|
26
|
+
* both `jq` and exactOptionalPropertyTypes consumers.
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildListJson(report: ListReport): unknown;
|
|
29
|
+
/**
|
|
30
|
+
* `integer`, `enum(a, b)`, `string[]`, with annotations in fixed order:
|
|
31
|
+
* presence, default, catch.
|
|
32
|
+
*/
|
|
33
|
+
export declare function formatCodec(description: CodecDescription): string;
|
|
34
|
+
/** Human report; one string per output line. */
|
|
35
|
+
export declare function renderListReport(report: ListReport): string[];
|