@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jason Paff
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin/paramour.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Committed bin stub: pnpm creates bin shims at install time and silently
|
|
3
|
+
// skips any whose target doesn't exist yet — dist/cli.js only exists after
|
|
4
|
+
// a build, so pointing `bin` at it left the examples' shims uncreated (and
|
|
5
|
+
// a second install won't retry: an up-to-date lockfile skips the link
|
|
6
|
+
// phase). This file always exists, so the shim is always created; it just
|
|
7
|
+
// delegates to the built CLI.
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
const cli = new URL("../dist/cli.js", import.meta.url);
|
|
11
|
+
|
|
12
|
+
if (!existsSync(cli)) {
|
|
13
|
+
console.error(
|
|
14
|
+
"paramour: @paramour-js/next is not built (dist/cli.js is missing). Run `pnpm build:packages` first.",
|
|
15
|
+
);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
await import(cli.href);
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type AnyAppRoute, type InferRouteParams, type SafeResult, type SearchOutputOf } from "paramour";
|
|
2
|
+
import { type SelectOptions } from "./select.js";
|
|
3
|
+
export type { SelectOptions } from "./select.js";
|
|
4
|
+
/**
|
|
5
|
+
* Client hooks (DESIGN §9, design-07). Each layers over Next's
|
|
6
|
+
* `useSearchParams()` / `useParams()` — App-Router params are synchronous on
|
|
7
|
+
* the client, so there is no loading state, no `useEffect`/`useState`, and
|
|
8
|
+
* the result is SSR-consistent. Two layers per hook (design-07):
|
|
9
|
+
*
|
|
10
|
+
* - Raw-slice stabilization (SEL4): the decode is keyed on the DECLARED
|
|
11
|
+
* slice of the raw source, not on Next's object reference — a URL change
|
|
12
|
+
* that only touches keys the route doesn't own (`?utm_source=` churn)
|
|
13
|
+
* returns the previous result by identity, without re-decoding. Next still
|
|
14
|
+
* re-renders every subscriber on any URL change (it owns the subscription
|
|
15
|
+
* — SEL7: selectors stabilize slices, they cannot skip renders); this
|
|
16
|
+
* layer makes that render cheap and downstream-invisible.
|
|
17
|
+
* - Selection (SEL1–SEL3): every hook takes an optional `{ select }` that
|
|
18
|
+
* projects the decoded value, with result-equality checking (`Object.is`,
|
|
19
|
+
* `equality: "shallow"` opt-in) so an unchanged selection keeps its
|
|
20
|
+
* previous reference when OTHER params change.
|
|
21
|
+
*
|
|
22
|
+
* Both layers are render-phase ref caches (SEL8) — the one sanctioned
|
|
23
|
+
* departure from the pure-`useMemo` discipline these hooks previously held.
|
|
24
|
+
*
|
|
25
|
+
* Two surfaces per half, mirroring core's server `parse` vs `safeParse`:
|
|
26
|
+
* - `useSearch` / `useRouteParams` return the `SafeResult` union
|
|
27
|
+
* (discriminated on `status`, PR12) — a user editing the URL never crashes
|
|
28
|
+
* the component. The selector runs on the success arm only (SEL2).
|
|
29
|
+
* - `useSearchOrThrow` / `useRouteParamsOrThrow` throw the decode error in
|
|
30
|
+
* render, to the nearest client error boundary.
|
|
31
|
+
*
|
|
32
|
+
* Both read the route's blessed-internal `~search` / `~params` via the core
|
|
33
|
+
* decoders (design-03 RL6 — `@paramour/next` is a sanctioned consumer).
|
|
34
|
+
*
|
|
35
|
+
* Every hook is gated to `AnyAppRoute` (design-06 PR3): a pages-branded route
|
|
36
|
+
* at one of these call sites is a compile error, not a runtime surprise —
|
|
37
|
+
* these hooks read Next's App-Router navigation hooks, whose pages twin has
|
|
38
|
+
* different state cardinality (`@paramour-js/next/pages`).
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Decoded route params as a `SafeResult` (discriminated on `status`, PR12),
|
|
42
|
+
* optionally projected through `options.select` (design-07 SEL1/SEL2).
|
|
43
|
+
*
|
|
44
|
+
* `useParams()` returns `null` outside an App-Router tree — including the
|
|
45
|
+
* initial render of every pages-router page in a hybrid app — so a `null`
|
|
46
|
+
* context degrades to an empty source (`{}`): required params surface as
|
|
47
|
+
* ordinary "missing" decode issues (a `SafeResult` error arm), never a crash.
|
|
48
|
+
* Core keeps its loud throw for genuinely non-object sources from plain-JS
|
|
49
|
+
* callers; the null tolerance lives here at the adapter.
|
|
50
|
+
*/
|
|
51
|
+
export declare function useRouteParams<R extends AnyAppRoute>(route: R): SafeResult<InferRouteParams<R>>;
|
|
52
|
+
export declare function useRouteParams<R extends AnyAppRoute, U>(route: R, options: SelectOptions<InferRouteParams<R>, U>): SafeResult<U>;
|
|
53
|
+
/**
|
|
54
|
+
* Decoded route params, or a thrown {@link ParamsDecodeError} (→ nearest
|
|
55
|
+
* client error boundary) on a malformed URL. Optionally projected through
|
|
56
|
+
* `options.select` (design-07 SEL1/SEL2).
|
|
57
|
+
*
|
|
58
|
+
* A `null` `useParams()` (outside an App-Router tree, e.g. a hybrid app's
|
|
59
|
+
* pages-router initial render) degrades to `{}` so required params throw the
|
|
60
|
+
* documented {@link ParamsDecodeError}, not an undocumented error class.
|
|
61
|
+
*/
|
|
62
|
+
export declare function useRouteParamsOrThrow<R extends AnyAppRoute>(route: R): InferRouteParams<R>;
|
|
63
|
+
export declare function useRouteParamsOrThrow<R extends AnyAppRoute, U>(route: R, options: SelectOptions<InferRouteParams<R>, U>): U;
|
|
64
|
+
/**
|
|
65
|
+
* Decoded search params as a `SafeResult` (discriminated on `status`, PR12),
|
|
66
|
+
* optionally projected through `options.select` (design-07 SEL1/SEL2).
|
|
67
|
+
*/
|
|
68
|
+
export declare function useSearch<R extends AnyAppRoute>(route: R): SafeResult<SearchOutputOf<R["~search"]>>;
|
|
69
|
+
export declare function useSearch<R extends AnyAppRoute, U>(route: R, options: SelectOptions<SearchOutputOf<R["~search"]>, U>): SafeResult<U>;
|
|
70
|
+
/**
|
|
71
|
+
* Decoded search params, or a thrown {@link SearchDecodeError} (→ nearest
|
|
72
|
+
* client error boundary) on a malformed URL. Optionally projected through
|
|
73
|
+
* `options.select` (design-07 SEL1/SEL2).
|
|
74
|
+
*/
|
|
75
|
+
export declare function useSearchOrThrow<R extends AnyAppRoute>(route: R): SearchOutputOf<R["~search"]>;
|
|
76
|
+
export declare function useSearchOrThrow<R extends AnyAppRoute, U>(route: R, options: SelectOptions<SearchOutputOf<R["~search"]>, U>): U;
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useParams, useSearchParams } from "next/navigation";
|
|
3
|
+
import { decodeParams, decodeSearch, safeDecodeParams, safeDecodeSearch, } from "paramour";
|
|
4
|
+
import { paramsFingerprint, searchParamsFingerprint, useSelectedResult, useSelectedValue, useStableResult, } from "./select.js";
|
|
5
|
+
export function useRouteParams(route, options) {
|
|
6
|
+
const params = useParams() ?? {};
|
|
7
|
+
const result = useStableResult(route, paramsFingerprint(route, params), () => safeDecodeParams(route, params));
|
|
8
|
+
return useSelectedResult(result, options);
|
|
9
|
+
}
|
|
10
|
+
export function useRouteParamsOrThrow(route, options) {
|
|
11
|
+
const params = useParams() ?? {};
|
|
12
|
+
const value = useStableResult(route, paramsFingerprint(route, params), () => decodeParams(route, params));
|
|
13
|
+
return useSelectedValue(value, options);
|
|
14
|
+
}
|
|
15
|
+
export function useSearch(route, options) {
|
|
16
|
+
const searchParams = useSearchParams();
|
|
17
|
+
const result = useStableResult(route, searchParamsFingerprint(route, searchParams), () => safeDecodeSearch(route, searchParams));
|
|
18
|
+
return useSelectedResult(result, options);
|
|
19
|
+
}
|
|
20
|
+
export function useSearchOrThrow(route, options) {
|
|
21
|
+
const searchParams = useSearchParams();
|
|
22
|
+
const value = useStableResult(route, searchParamsFingerprint(route, searchParams),
|
|
23
|
+
// decodeSearch is keyed on SearchOutputOf (design-04 SS6) — the correct
|
|
24
|
+
// public type — but AnyAppRoute erases its SC to `any`, so for a still-
|
|
25
|
+
// generic R the call's SearchOutputOf<R["~search"]> reduces to `unknown`
|
|
26
|
+
// on the value side while staying deferred on the annotation side. The
|
|
27
|
+
// cast bridges that inference gap to the SAME (correct) type, so a
|
|
28
|
+
// rawSearch route now infers its schema output here, not a garbage
|
|
29
|
+
// {~kind, ~schema} shape.
|
|
30
|
+
() => decodeSearch(route["~search"], searchParams));
|
|
31
|
+
return useSelectedValue(value, options);
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseArgs, type ParseArgsOptionsConfig } from "node:util";
|
|
2
|
+
import { type ResolvedIo } from "./cli-io.js";
|
|
3
|
+
/** Every command declares `--help` identically; the prologue relies on it. */
|
|
4
|
+
interface HelpOption {
|
|
5
|
+
help: {
|
|
6
|
+
default: false;
|
|
7
|
+
short: "h";
|
|
8
|
+
type: "boolean";
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** What `parseArgs` infers for `values` from an options table `T`. */
|
|
12
|
+
type ParsedValues<T extends ParseArgsOptionsConfig> = ReturnType<typeof parseArgs<{
|
|
13
|
+
allowPositionals: true;
|
|
14
|
+
args: string[];
|
|
15
|
+
options: T;
|
|
16
|
+
}>>["values"];
|
|
17
|
+
/**
|
|
18
|
+
* The shared command prologue (TR7): parse flags, print usage on a parse
|
|
19
|
+
* error (exit 2) or `--help` (exit 0), and reject positionals — no command
|
|
20
|
+
* takes one. Callers branch on `"exit" in result`; anything past the
|
|
21
|
+
* prologue (mode merging, flag exclusivity) stays per-command.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseCommandFlags<const T extends HelpOption & ParseArgsOptionsConfig>(argv: readonly string[], options: T, usage: string, { stderr, stdout }: ResolvedIo): {
|
|
24
|
+
exit: 0 | 2;
|
|
25
|
+
} | {
|
|
26
|
+
values: ParsedValues<T>;
|
|
27
|
+
};
|
|
28
|
+
export {};
|
package/dist/cli-args.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { message } from "./cli-io.js";
|
|
3
|
+
/**
|
|
4
|
+
* The shared command prologue (TR7): parse flags, print usage on a parse
|
|
5
|
+
* error (exit 2) or `--help` (exit 0), and reject positionals — no command
|
|
6
|
+
* takes one. Callers branch on `"exit" in result`; anything past the
|
|
7
|
+
* prologue (mode merging, flag exclusivity) stays per-command.
|
|
8
|
+
*/
|
|
9
|
+
export function parseCommandFlags(argv, options, usage, { stderr, stdout }) {
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = parseArgs({
|
|
13
|
+
allowPositionals: true,
|
|
14
|
+
args: [...argv],
|
|
15
|
+
options,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
stderr(`paramour: ${message(error)}`);
|
|
20
|
+
stderr(usage);
|
|
21
|
+
return { exit: 2 };
|
|
22
|
+
}
|
|
23
|
+
// parseArgs's values type stays opaque while T is unresolved; the
|
|
24
|
+
// HelpOption constraint guarantees the property exists.
|
|
25
|
+
if (parsed.values.help === true) {
|
|
26
|
+
stdout(usage);
|
|
27
|
+
return { exit: 0 };
|
|
28
|
+
}
|
|
29
|
+
if (parsed.positionals.length > 0) {
|
|
30
|
+
stderr(`paramour: unexpected argument "${parsed.positionals[0] ?? ""}"`);
|
|
31
|
+
stderr(usage);
|
|
32
|
+
return { exit: 2 };
|
|
33
|
+
}
|
|
34
|
+
return { values: parsed.values };
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ParamourConfig } from "./config.js";
|
|
2
|
+
import { type GenerateInputs } from "./generate.js";
|
|
3
|
+
/**
|
|
4
|
+
* The shared route-dir/artifact flags — every command that scans
|
|
5
|
+
* (`generate`, `check`, `list`) parses these and resolves them here.
|
|
6
|
+
*/
|
|
7
|
+
export interface InputFlags {
|
|
8
|
+
"app-dir"?: string | undefined;
|
|
9
|
+
"out-file"?: string | undefined;
|
|
10
|
+
"page-extensions"?: string | undefined;
|
|
11
|
+
"pages-dir"?: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The no-route-dirs case as a distinct class: `generate`/`check`/`list`
|
|
15
|
+
* treat it like any other exit-2 error, but `init` downgrades it to a
|
|
16
|
+
* warn-and-skip — a fresh project legitimately has no app/ or pages/ yet.
|
|
17
|
+
*/
|
|
18
|
+
export declare class NoRouteDirsError extends Error {
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Precedence lives in exactly this function (TR7 / §7.2): flags → config
|
|
22
|
+
* file → joint discovery (PR8). Paths resolve against the project root
|
|
23
|
+
* (= cwd, where `next` itself would run). Discovery only runs for dirs not
|
|
24
|
+
* explicitly given — passing both bypasses it (and its populated-ignored-dir
|
|
25
|
+
* config error) entirely, which is the documented escape hatch. Only when
|
|
26
|
+
* NEITHER dir exists is that an error (PR8): app-only and pages-only
|
|
27
|
+
* projects are both fine.
|
|
28
|
+
*
|
|
29
|
+
* Commands that already loaded the config file (for fields beyond these,
|
|
30
|
+
* e.g. `list`'s routeFiles) pass it as `preloaded` so jiti runs once.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveInputs(flags: InputFlags, projectRoot: string, preloaded?: ParamourConfig): Promise<GenerateInputs>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { loadConfigFile } from "./config.js";
|
|
4
|
+
import {} from "./generate.js";
|
|
5
|
+
import { DEFAULT_PAGE_EXTENSIONS } from "./scan-app.js";
|
|
6
|
+
import { resolveRouteDirs } from "./scan.js";
|
|
7
|
+
/**
|
|
8
|
+
* The no-route-dirs case as a distinct class: `generate`/`check`/`list`
|
|
9
|
+
* treat it like any other exit-2 error, but `init` downgrades it to a
|
|
10
|
+
* warn-and-skip — a fresh project legitimately has no app/ or pages/ yet.
|
|
11
|
+
*/
|
|
12
|
+
export class NoRouteDirsError extends Error {
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Precedence lives in exactly this function (TR7 / §7.2): flags → config
|
|
16
|
+
* file → joint discovery (PR8). Paths resolve against the project root
|
|
17
|
+
* (= cwd, where `next` itself would run). Discovery only runs for dirs not
|
|
18
|
+
* explicitly given — passing both bypasses it (and its populated-ignored-dir
|
|
19
|
+
* config error) entirely, which is the documented escape hatch. Only when
|
|
20
|
+
* NEITHER dir exists is that an error (PR8): app-only and pages-only
|
|
21
|
+
* projects are both fine.
|
|
22
|
+
*
|
|
23
|
+
* Commands that already loaded the config file (for fields beyond these,
|
|
24
|
+
* e.g. `list`'s routeFiles) pass it as `preloaded` so jiti runs once.
|
|
25
|
+
*/
|
|
26
|
+
export async function resolveInputs(flags, projectRoot, preloaded) {
|
|
27
|
+
const file = preloaded ?? (await loadConfigFile(projectRoot))?.config;
|
|
28
|
+
const pageExtensions = parsePageExtensions(flags["page-extensions"]) ??
|
|
29
|
+
file?.pageExtensions ??
|
|
30
|
+
DEFAULT_PAGE_EXTENSIONS;
|
|
31
|
+
const explicitAppDir = flags["app-dir"] ?? file?.appDir;
|
|
32
|
+
const explicitPagesDir = flags["pages-dir"] ?? file?.pagesDir;
|
|
33
|
+
let appDir = explicitAppDir === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: checkedDir(explicitAppDir, "app", projectRoot);
|
|
36
|
+
let pagesDir = explicitPagesDir === undefined
|
|
37
|
+
? undefined
|
|
38
|
+
: checkedDir(explicitPagesDir, "pages", projectRoot);
|
|
39
|
+
if (explicitAppDir === undefined || explicitPagesDir === undefined) {
|
|
40
|
+
const discovered = resolveRouteDirs(projectRoot, pageExtensions);
|
|
41
|
+
appDir ??= discovered.appDir;
|
|
42
|
+
pagesDir ??= discovered.pagesDir;
|
|
43
|
+
}
|
|
44
|
+
if (appDir === undefined && pagesDir === undefined) {
|
|
45
|
+
throw new NoRouteDirsError(`no route directory (app/, pages/, src/app/, or src/pages/) under ${projectRoot}; pass --app-dir/--pages-dir or set appDir/pagesDir in paramour.config`);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
appDir,
|
|
49
|
+
artifactPath: resolve(projectRoot, flags["out-file"] ?? file?.outFile ?? "paramour-env.d.ts"),
|
|
50
|
+
pageExtensions,
|
|
51
|
+
pagesDir,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Resolve an explicitly-given dir and require that it exists. */
|
|
55
|
+
function checkedDir(dir, router, projectRoot) {
|
|
56
|
+
const resolved = resolve(projectRoot, dir);
|
|
57
|
+
if (!statSync(resolved, { throwIfNoEntry: false })?.isDirectory()) {
|
|
58
|
+
throw new Error(`${router} directory not found: ${resolved}`);
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
/** `--page-extensions tsx,mdx` → `["tsx", "mdx"]`; empty list is an error. */
|
|
63
|
+
function parsePageExtensions(flag) {
|
|
64
|
+
if (flag === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
const list = flag
|
|
67
|
+
.split(",")
|
|
68
|
+
.map((ext) => ext.trim())
|
|
69
|
+
.filter((ext) => ext !== "");
|
|
70
|
+
if (list.length === 0) {
|
|
71
|
+
throw new Error("--page-extensions requires a comma-separated list");
|
|
72
|
+
}
|
|
73
|
+
// Mirrors the config-file validation: a leading dot silently matches
|
|
74
|
+
// nothing (`page..tsx` never exists on disk).
|
|
75
|
+
const dotted = list.find((ext) => ext.startsWith("."));
|
|
76
|
+
if (dotted !== undefined) {
|
|
77
|
+
throw new Error(`--page-extensions entries must not start with a dot: "${dotted}"`);
|
|
78
|
+
}
|
|
79
|
+
return list;
|
|
80
|
+
}
|
package/dist/cli-io.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** @internal I/O seams for tests; defaults write to the console. */
|
|
2
|
+
export interface CliIo {
|
|
3
|
+
/** Aborting stops `generate --watch` and resolves `runCli` with 0. */
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
stderr?: (line: string) => void;
|
|
6
|
+
stdout?: (line: string) => void;
|
|
7
|
+
}
|
|
8
|
+
/** {@link CliIo} with the write seams defaulted. */
|
|
9
|
+
export interface ResolvedIo {
|
|
10
|
+
stderr: (line: string) => void;
|
|
11
|
+
stdout: (line: string) => void;
|
|
12
|
+
}
|
|
13
|
+
/** Error message without the stack — CLI output, not a crash report. */
|
|
14
|
+
export declare function message(error: unknown): string;
|
|
15
|
+
export declare function resolveIo(io: CliIo): ResolvedIo;
|
package/dist/cli-io.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Error message without the stack — CLI output, not a crash report. */
|
|
2
|
+
export function message(error) {
|
|
3
|
+
return error instanceof Error ? error.message : String(error);
|
|
4
|
+
}
|
|
5
|
+
export function resolveIo(io) {
|
|
6
|
+
return {
|
|
7
|
+
stderr: io.stderr ??
|
|
8
|
+
((line) => {
|
|
9
|
+
console.error(line);
|
|
10
|
+
}),
|
|
11
|
+
stdout: io.stdout ??
|
|
12
|
+
((line) => {
|
|
13
|
+
console.log(line);
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
16
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runCli } from "./run-cli.js";
|
|
3
|
+
// The bin entry (TR7): all logic lives in run-cli.ts so tests never execute
|
|
4
|
+
// this statement. exitCode, not exit() — pending stdio writes must flush.
|
|
5
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** A scanned route path labeled with the router that produced it (PR9). */
|
|
2
|
+
export interface ScannedRoute {
|
|
3
|
+
path: string;
|
|
4
|
+
router: "app" | "pages";
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Route-collision failure mode (PR9): states Next itself refuses to build
|
|
8
|
+
* have no valid artifact, so the scanners throw instead of emitting one.
|
|
9
|
+
* Composition points map this error to their ruled exits — CLI exit 2,
|
|
10
|
+
* `withTypedRoutes` throw during config evaluation, and a non-fatal loud
|
|
11
|
+
* log under watch (the TR5 exception: a collision mid-`--watch` is usually
|
|
12
|
+
* a file mid-move, so the last good artifact stays on disk).
|
|
13
|
+
*/
|
|
14
|
+
export declare class RouteCollisionError extends Error {
|
|
15
|
+
name: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* PR9's structural collisions — same detection pass, non-equal strings. Two
|
|
19
|
+
* states Next also refuses to build that plain string equality misses:
|
|
20
|
+
*
|
|
21
|
+
* - **Different slug names at one level**: `/x/[id]` + `/x/[slug]` — Next:
|
|
22
|
+
* "You cannot use different slug names for the same dynamic path". Within
|
|
23
|
+
* one parent, at one segment position, at most one dynamic segment of each
|
|
24
|
+
* KIND may exist — across both scanners, and cross-router for a shared
|
|
25
|
+
* prefix, which is why the entries carry router labels. Kinds are compared
|
|
26
|
+
* separately because `[id]` beside `[...slug]` is Next's documented
|
|
27
|
+
* priority pattern (predefined > dynamic > catch-all), not an error; but
|
|
28
|
+
* `[...a]` beside `[[...a]]` IS one ("optional and required catch-all at
|
|
29
|
+
* the same level"), so the catch-all family compares whole segments.
|
|
30
|
+
* - **Optional-catch-all specificity**: `/docs` + `/docs/[[...slug]]` — an
|
|
31
|
+
* optional catch-all also matches its own base path, so the pair collides
|
|
32
|
+
* at `/docs` even though the strings differ.
|
|
33
|
+
*
|
|
34
|
+
* Callers: each scanner over its own output, then the orchestrator over the
|
|
35
|
+
* merged, labeled union (cross-router pairs only surface there).
|
|
36
|
+
*/
|
|
37
|
+
export declare function assertNoStructuralCollisions(routes: readonly ScannedRoute[]): void;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route-collision failure mode (PR9): states Next itself refuses to build
|
|
3
|
+
* have no valid artifact, so the scanners throw instead of emitting one.
|
|
4
|
+
* Composition points map this error to their ruled exits — CLI exit 2,
|
|
5
|
+
* `withTypedRoutes` throw during config evaluation, and a non-fatal loud
|
|
6
|
+
* log under watch (the TR5 exception: a collision mid-`--watch` is usually
|
|
7
|
+
* a file mid-move, so the last good artifact stays on disk).
|
|
8
|
+
*/
|
|
9
|
+
export class RouteCollisionError extends Error {
|
|
10
|
+
name = "RouteCollisionError";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* `[[...name]]` → optional catch-all; used for the specificity check below.
|
|
14
|
+
*/
|
|
15
|
+
const OPTIONAL_CATCH_ALL = /^\[\[\.\.\..+\]\]$/;
|
|
16
|
+
/**
|
|
17
|
+
* Classifies a path segment: `plain` for `[id]`, `catchAll` for `[...slug]`
|
|
18
|
+
* AND `[[...slug]]` (one family — mixing required and optional at one level
|
|
19
|
+
* is itself a Next error), `undefined` for a literal segment. Alternatives
|
|
20
|
+
* ordered most-wrapped first so `[[...x]]` is never misread as a `[name]`
|
|
21
|
+
* whose name is `[...x]`.
|
|
22
|
+
*/
|
|
23
|
+
const DYNAMIC_SEGMENT = /^(?:\[\[\.\.\.(?<optional>.+)\]\]|\[\.\.\.(?<catchAll>.+)\]|\[(?<plain>[^[\]]+)\])$/;
|
|
24
|
+
/**
|
|
25
|
+
* PR9's structural collisions — same detection pass, non-equal strings. Two
|
|
26
|
+
* states Next also refuses to build that plain string equality misses:
|
|
27
|
+
*
|
|
28
|
+
* - **Different slug names at one level**: `/x/[id]` + `/x/[slug]` — Next:
|
|
29
|
+
* "You cannot use different slug names for the same dynamic path". Within
|
|
30
|
+
* one parent, at one segment position, at most one dynamic segment of each
|
|
31
|
+
* KIND may exist — across both scanners, and cross-router for a shared
|
|
32
|
+
* prefix, which is why the entries carry router labels. Kinds are compared
|
|
33
|
+
* separately because `[id]` beside `[...slug]` is Next's documented
|
|
34
|
+
* priority pattern (predefined > dynamic > catch-all), not an error; but
|
|
35
|
+
* `[...a]` beside `[[...a]]` IS one ("optional and required catch-all at
|
|
36
|
+
* the same level"), so the catch-all family compares whole segments.
|
|
37
|
+
* - **Optional-catch-all specificity**: `/docs` + `/docs/[[...slug]]` — an
|
|
38
|
+
* optional catch-all also matches its own base path, so the pair collides
|
|
39
|
+
* at `/docs` even though the strings differ.
|
|
40
|
+
*
|
|
41
|
+
* Callers: each scanner over its own output, then the orchestrator over the
|
|
42
|
+
* merged, labeled union (cross-router pairs only surface there).
|
|
43
|
+
*/
|
|
44
|
+
export function assertNoStructuralCollisions(routes) {
|
|
45
|
+
// parent-prefix + position + kind → the first dynamic segment seen there.
|
|
46
|
+
const dynamicAt = new Map();
|
|
47
|
+
const byPath = new Map();
|
|
48
|
+
for (const route of routes)
|
|
49
|
+
byPath.set(route.path, route);
|
|
50
|
+
for (const route of routes) {
|
|
51
|
+
const segments = route.path === "/" ? [] : route.path.slice(1).split("/");
|
|
52
|
+
for (const [index, segment] of segments.entries()) {
|
|
53
|
+
const kind = dynamicKind(segment);
|
|
54
|
+
if (kind === undefined)
|
|
55
|
+
continue;
|
|
56
|
+
const key = `${segments.slice(0, index).join("/")}#${String(index)}#${kind}`;
|
|
57
|
+
const existing = dynamicAt.get(key);
|
|
58
|
+
if (existing !== undefined && existing.segment !== segment) {
|
|
59
|
+
throw new RouteCollisionError(`route collision: "${existing.path}" (${existing.router}) and "${route.path}" (${route.router}) declare conflicting dynamic segments (${existing.segment} vs ${segment}) at the same position — Next refuses different slug names for the same dynamic path (PR9)`);
|
|
60
|
+
}
|
|
61
|
+
if (existing === undefined) {
|
|
62
|
+
dynamicAt.set(key, { ...route, segment });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const last = segments.at(-1);
|
|
66
|
+
if (last !== undefined && OPTIONAL_CATCH_ALL.test(last)) {
|
|
67
|
+
const base = segments.length === 1 ? "/" : `/${segments.slice(0, -1).join("/")}`;
|
|
68
|
+
const baseRoute = byPath.get(base);
|
|
69
|
+
if (baseRoute !== undefined) {
|
|
70
|
+
throw new RouteCollisionError(`route collision: "${route.path}" (${route.router}) also matches "${base}" (${baseRoute.router}) — an optional catch-all has the same specificity as its base path, which Next refuses to build (PR9)`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function dynamicKind(segment) {
|
|
76
|
+
const groups = DYNAMIC_SEGMENT.exec(segment)?.groups;
|
|
77
|
+
if (groups === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
return groups.plain === undefined ? "catchAll" : "plain";
|
|
80
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type CliIo } from "../cli-io.js";
|
|
2
|
+
/**
|
|
3
|
+
* @internal `paramour doctor` — a verification, so its exit codes follow
|
|
4
|
+
* `check`'s class: 0 pass/warn, 1 any fail, 2 only when doctor itself
|
|
5
|
+
* cannot run.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runDoctor(argv: readonly string[], io: CliIo): Promise<number>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { parseCommandFlags } from "../cli-args.js";
|
|
2
|
+
import { message, resolveIo } from "../cli-io.js";
|
|
3
|
+
import { runDoctorChecks } from "../doctor/checks.js";
|
|
4
|
+
const MARKS = { fail: "✖", pass: "✔", warn: "⚠" };
|
|
5
|
+
const USAGE = [
|
|
6
|
+
"Usage: paramour doctor [options]",
|
|
7
|
+
"",
|
|
8
|
+
"Diagnose the project's paramour setup: config validity, artifact",
|
|
9
|
+
"freshness, next.config wrapping, version alignment, tsconfig coverage,",
|
|
10
|
+
"and route-definition discovery (which evaluates matched modules, like",
|
|
11
|
+
"`paramour list`).",
|
|
12
|
+
"",
|
|
13
|
+
"Exit codes: 0 all checks pass (warnings allowed), 1 any check fails.",
|
|
14
|
+
"",
|
|
15
|
+
"Options:",
|
|
16
|
+
" --help, -h show this help",
|
|
17
|
+
" --json machine-readable output",
|
|
18
|
+
].join("\n");
|
|
19
|
+
/**
|
|
20
|
+
* @internal `paramour doctor` — a verification, so its exit codes follow
|
|
21
|
+
* `check`'s class: 0 pass/warn, 1 any fail, 2 only when doctor itself
|
|
22
|
+
* cannot run.
|
|
23
|
+
*/
|
|
24
|
+
export async function runDoctor(argv, io) {
|
|
25
|
+
const { stderr, stdout } = resolveIo(io);
|
|
26
|
+
const parsed = parseCommandFlags(argv, {
|
|
27
|
+
help: { default: false, short: "h", type: "boolean" },
|
|
28
|
+
json: { default: false, type: "boolean" },
|
|
29
|
+
}, USAGE, { stderr, stdout });
|
|
30
|
+
if ("exit" in parsed)
|
|
31
|
+
return parsed.exit;
|
|
32
|
+
const flags = parsed.values;
|
|
33
|
+
let checks;
|
|
34
|
+
try {
|
|
35
|
+
checks = await runDoctorChecks(process.cwd());
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
stderr(`paramour: ${message(error)}`);
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
const failed = checks.filter((check) => check.status === "fail").length;
|
|
42
|
+
const warned = checks.filter((check) => check.status === "warn").length;
|
|
43
|
+
const status = failed > 0 ? "fail" : warned > 0 ? "warn" : "pass";
|
|
44
|
+
if (flags.json) {
|
|
45
|
+
stdout(JSON.stringify({ checks, status }, null, 2));
|
|
46
|
+
return failed > 0 ? 1 : 0;
|
|
47
|
+
}
|
|
48
|
+
for (const check of checks) {
|
|
49
|
+
stdout(` ${MARKS[check.status]} ${check.label}`);
|
|
50
|
+
for (const line of check.detail ?? [])
|
|
51
|
+
stdout(` ${line}`);
|
|
52
|
+
}
|
|
53
|
+
stdout("");
|
|
54
|
+
stdout(`doctor: ${String(checks.length)} checks — ${String(failed)} failed, ${String(warned)} warning${warned === 1 ? "" : "s"}`);
|
|
55
|
+
return failed > 0 ? 1 : 0;
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type CliIo } from "../cli-io.js";
|
|
2
|
+
/**
|
|
3
|
+
* @internal `paramour generate` and its `check` alias (TR7), in-process
|
|
4
|
+
* testable: returns the exit code instead of exiting. Codes are grep-style
|
|
5
|
+
* so CI can tell drift from breakage: 0 success, 1 check-drift ONLY, 2
|
|
6
|
+
* usage/config/operational errors — route collisions included (PR9: Next
|
|
7
|
+
* itself fails that build, so there is no artifact to emit). Unlike the
|
|
8
|
+
* wrapper's never-load-bearing stance (§7.3), the CLI fails loudly —
|
|
9
|
+
* running it is explicit user intent.
|
|
10
|
+
*/
|
|
11
|
+
export declare function runGenerate(argv: readonly string[], io: CliIo, mode: "check" | "generate"): Promise<number>;
|