next-modal-router 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/dist/cli.js ADDED
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import path6 from "path";
5
+ import { pathToFileURL } from "url";
6
+ import { createInterface } from "readline/promises";
7
+ import { stdin, stdout } from "process";
8
+
9
+ // src/config/types.ts
10
+ var OVERLAY_TYPES = ["modal", "drawer", "sheet", "panel", "custom"];
11
+
12
+ // src/routing/segments.ts
13
+ var UNSAFE = /(?:^|\/)\.\.?($|\/)|[\\\0]/;
14
+ function normalizeRoute(route) {
15
+ if (!route.startsWith("/")) throw new Error(`Route "${route}" must begin with "/".`);
16
+ if (UNSAFE.test(route)) throw new Error(`Route "${route}" contains an unsafe filesystem segment.`);
17
+ const normalized = `/${route.split("/").filter(Boolean).join("/")}`;
18
+ return normalized === "/" ? normalized : normalized.replace(/\/$/, "");
19
+ }
20
+ function parseSegment(raw) {
21
+ if (/^\[\[\.\.\.[^\]/]+\]\]$/.test(raw)) return { raw, kind: "optional-catch-all", name: raw.slice(5, -2), contributesToUrl: true };
22
+ if (/^\[\.\.\.[^\]/]+\]$/.test(raw)) return { raw, kind: "catch-all", name: raw.slice(4, -1), contributesToUrl: true };
23
+ if (/^\[[^\]/]+\]$/.test(raw)) return { raw, kind: "dynamic", name: raw.slice(1, -1), contributesToUrl: true };
24
+ if (/^\([^/]+\)$/.test(raw)) return { raw, kind: "group", name: raw.slice(1, -1), contributesToUrl: false };
25
+ if (/^@[A-Za-z][\w-]*$/.test(raw)) return { raw, kind: "slot", name: raw.slice(1), contributesToUrl: false };
26
+ if (!raw || raw.includes("[") || raw.includes("]")) throw new Error(`Invalid route segment "${raw}".`);
27
+ return { raw, kind: "static", name: raw, contributesToUrl: true };
28
+ }
29
+ function routeSegments(route) {
30
+ return normalizeRoute(route).split("/").filter(Boolean).map(parseSegment);
31
+ }
32
+ function urlSegments(route) {
33
+ return routeSegments(route).filter((segment) => segment.contributesToUrl).map((segment) => segment.raw);
34
+ }
35
+ function routeToFileSegments(route) {
36
+ return routeSegments(route).map((segment) => segment.raw);
37
+ }
38
+
39
+ // src/config/validate.ts
40
+ function validateConfig(value) {
41
+ const problems = [];
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
43
+ return [{ path: "config", message: "Configuration must be an object." }];
44
+ }
45
+ const config = value;
46
+ if (!config.overlays || typeof config.overlays !== "object" || Array.isArray(config.overlays)) {
47
+ return [{ path: "overlays", message: "An overlays object is required." }];
48
+ }
49
+ const targets = /* @__PURE__ */ new Map();
50
+ for (const [name, raw] of Object.entries(config.overlays)) {
51
+ const path7 = `overlays.${name}`;
52
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
53
+ problems.push({ path: path7, message: "Overlay must be an object." });
54
+ continue;
55
+ }
56
+ const overlay = raw;
57
+ for (const key of ["route", "source", "closeFallback"]) {
58
+ if (typeof overlay[key] !== "string" || overlay[key].length === 0) {
59
+ problems.push({ path: `${path7}.${key}`, message: `${key} is required.` });
60
+ } else {
61
+ try {
62
+ normalizeRoute(overlay[key]);
63
+ } catch (error) {
64
+ problems.push({ path: `${path7}.${key}`, message: error.message });
65
+ }
66
+ }
67
+ }
68
+ if (!overlay.type || !OVERLAY_TYPES.includes(overlay.type)) {
69
+ problems.push({ path: `${path7}.type`, message: `Expected one of: ${OVERLAY_TYPES.join(", ")}.` });
70
+ }
71
+ if (overlay.slot !== void 0 && !/^[A-Za-z][\w-]*$/.test(overlay.slot)) {
72
+ problems.push({ path: `${path7}.slot`, message: "Slot must start with a letter and contain only letters, numbers, _ or -." });
73
+ }
74
+ if (typeof overlay.route === "string" && typeof overlay.slot !== "object") {
75
+ const key = `${overlay.slot ?? config.defaultSlot ?? "modal"}:${overlay.route}`;
76
+ const existing = targets.get(key);
77
+ if (existing) problems.push({ path: path7, message: `Duplicates intercepted target used by ${existing}.` });
78
+ else targets.set(key, name);
79
+ }
80
+ }
81
+ return problems;
82
+ }
83
+
84
+ // src/routing/interception.ts
85
+ function calculateInterception(slotOwnerRoute, targetRoute) {
86
+ const owner = urlSegments(slotOwnerRoute);
87
+ const target = urlSegments(targetRoute);
88
+ let common = 0;
89
+ while (common < owner.length && common < target.length && owner[common] === target[common]) common += 1;
90
+ const levelsUp = owner.length - common;
91
+ const relativeTarget = target.slice(common).join("/");
92
+ if (!relativeTarget) throw new Error(`Target ${normalizeRoute(targetRoute)} cannot equal the slot owner route.`);
93
+ const marker = levelsUp === 0 ? "(.)" : common === 0 ? "(...)" : "(..)".repeat(levelsUp);
94
+ return { marker, target: `/${target.join("/")}`, filesystemPath: `${marker}${relativeTarget}`, levelsUp };
95
+ }
96
+ function parseInterceptor(value) {
97
+ const match = /^(\(\.\)|(?:\(\.\.\))+|\(\.\.\.\))(.+)$/.exec(value.replaceAll("\\", "/"));
98
+ return match?.[1] && match[2] ? { marker: match[1], target: match[2].replace(/^\//, "") } : null;
99
+ }
100
+
101
+ // src/cli/analyze.ts
102
+ import path3 from "path";
103
+
104
+ // src/routing/tree.ts
105
+ import { readdir } from "fs/promises";
106
+ import path from "path";
107
+ var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".next", ".git", "dist", "coverage"]);
108
+ var ROUTE_FILES = /^(?:page|default|layout)\.(?:js|jsx|ts|tsx)$/;
109
+ async function buildRouteTree(root) {
110
+ const entries = await readdir(root, { withFileTypes: true });
111
+ const files = entries.filter((entry) => entry.isFile() && ROUTE_FILES.test(entry.name)).map((entry) => entry.name).sort();
112
+ const directories = entries.filter((entry) => entry.isDirectory() && !IGNORED.has(entry.name));
113
+ const children = await Promise.all(directories.map((entry) => buildRouteTree(path.join(root, entry.name))));
114
+ return { name: path.basename(root), absolutePath: root, children: children.sort((a, b) => a.name.localeCompare(b.name)), files };
115
+ }
116
+ function discoverInterceptors(tree) {
117
+ const results = [];
118
+ const visit = (node, slot, relative2) => {
119
+ const activeSlot = node.name.startsWith("@") ? node.name.slice(1) : slot;
120
+ const next = node === tree ? [] : [...relative2, node.name];
121
+ const withinSlot = activeSlot ? next.slice(next.findIndex((part) => part === `@${activeSlot}`) + 1) : [];
122
+ const interceptorIndex = withinSlot.findIndex((part) => parseInterceptor(part));
123
+ if (activeSlot && interceptorIndex >= 0 && node.files.some((file) => file.startsWith("page."))) {
124
+ const routeParts = withinSlot.slice(interceptorIndex);
125
+ const first = parseInterceptor(routeParts[0] ?? "");
126
+ if (first) results.push({ slot: activeSlot, filesystemPath: routeParts.join("/"), pagePath: node.absolutePath, marker: first.marker, target: [first.target, ...routeParts.slice(1)].join("/") });
127
+ }
128
+ for (const child of node.children) visit(child, activeSlot, next);
129
+ };
130
+ visit(tree, void 0, []);
131
+ return results;
132
+ }
133
+
134
+ // src/cli/project.ts
135
+ import { access, readFile } from "fs/promises";
136
+ import path2 from "path";
137
+ async function exists(file) {
138
+ try {
139
+ await access(file);
140
+ return true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+ async function findProject(inputCwd) {
146
+ let cwd = path2.resolve(inputCwd);
147
+ while (true) {
148
+ const packageFile = path2.join(cwd, "package.json");
149
+ if (await exists(packageFile)) {
150
+ const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
151
+ const candidates = ["src/app", "app"];
152
+ for (const candidate of candidates) {
153
+ const appDir = path2.join(cwd, candidate);
154
+ if (await exists(appDir)) return { cwd, appDir, appRelative: candidate, packageJson, typescript: await exists(path2.join(cwd, "tsconfig.json")) };
155
+ }
156
+ throw new Error(`Found package.json in ${cwd}, but no app/ or src/app/ directory. next-modal-router requires the App Router.`);
157
+ }
158
+ const parent = path2.dirname(cwd);
159
+ if (parent === cwd) break;
160
+ cwd = parent;
161
+ }
162
+ throw new Error(`No Next.js project found from ${path2.resolve(inputCwd)}. Run the command inside an application or pass --cwd.`);
163
+ }
164
+ async function fileExists(file) {
165
+ return exists(file);
166
+ }
167
+ function dependencyVersion(project, name) {
168
+ for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
169
+ const record = project.packageJson[key];
170
+ if (record && typeof record === "object" && !Array.isArray(record)) {
171
+ const value = record[name];
172
+ if (typeof value === "string") return value;
173
+ }
174
+ }
175
+ return void 0;
176
+ }
177
+
178
+ // src/cli/analyze.ts
179
+ var PAGE_EXTENSIONS = ["tsx", "ts", "jsx", "js"];
180
+ async function hasRouteFile(directory, base) {
181
+ return (await Promise.all(PAGE_EXTENSIONS.map((extension) => fileExists(path3.join(directory, `${base}.${extension}`))))).some(Boolean);
182
+ }
183
+ async function analyze(project, config) {
184
+ const tree = await buildRouteTree(project.appDir);
185
+ const discovered = discoverInterceptors(tree);
186
+ const issues = [];
187
+ const definitions = Object.entries(config?.overlays ?? {});
188
+ for (const [name, overlay] of definitions) {
189
+ const slot = overlay.slot ?? config?.defaultSlot ?? "modal";
190
+ const expected = calculateInterception("/", overlay.route).filesystemPath;
191
+ const slotDir = path3.join(project.appDir, `@${slot}`);
192
+ if (!await hasRouteFile(slotDir, "default")) issues.push({ code: "NMR001", severity: "error", overlay: name, path: slotDir, message: `Missing @${slot}/default.tsx.`, suggestion: "Run next-modal-router init or create a default component that returns null." });
193
+ const found = discovered.find((item) => item.slot === slot && item.target === overlay.route.replace(/^\//, ""));
194
+ if (!found) issues.push({ code: "NMR002", severity: "error", overlay: name, message: `Missing interceptor @${slot}/${expected}.`, suggestion: `Run next-modal-router add ${name} with the configured route.` });
195
+ else if (found.filesystemPath !== expected) issues.push({ code: "NMR003", severity: "error", overlay: name, path: found.pagePath, message: `Invalid interception path. Expected ${expected}; found ${found.filesystemPath}.`, suggestion: "Move the interceptor to the expected route-segment depth." });
196
+ const fullPage = path3.join(project.appDir, ...routeToFileSegments(overlay.route));
197
+ if (!await hasRouteFile(fullPage, "page")) issues.push({ code: "NMR004", severity: "error", overlay: name, path: fullPage, message: `Missing full-page counterpart for ${overlay.route}.`, suggestion: "Create page.tsx so hard navigation and refresh render a normal page." });
198
+ const fallback = path3.join(project.appDir, ...routeToFileSegments(overlay.closeFallback));
199
+ if (!await hasRouteFile(fallback, "page")) issues.push({ code: "NMR005", severity: "warning", overlay: name, path: fallback, message: `Close fallback ${overlay.closeFallback} has no page.`, suggestion: "Create the fallback page or correct closeFallback." });
200
+ }
201
+ if (!config) for (const item of discovered) issues.push({ code: "NMR006", severity: "info", path: item.pagePath, message: `Discovered unconfigured overlay @${item.slot}/${item.filesystemPath}.`, suggestion: "Add it to next-modal-router.config.ts for richer validation." });
202
+ return { valid: !issues.some((issue) => issue.severity === "error"), issues, discovered, checked: definitions.length || discovered.length };
203
+ }
204
+
205
+ // src/cli/config-loader.ts
206
+ import { access as access2 } from "fs/promises";
207
+ import path4 from "path";
208
+ import { fileURLToPath } from "url";
209
+ import { createJiti } from "jiti";
210
+ var NAMES = ["next-modal-router.config.ts", "next-modal-router.config.mts", "next-modal-router.config.js", "next-modal-router.config.mjs"];
211
+ async function findConfig(cwd) {
212
+ for (const name of NAMES) {
213
+ const file = path4.join(cwd, name);
214
+ try {
215
+ await access2(file);
216
+ return file;
217
+ } catch {
218
+ }
219
+ }
220
+ return void 0;
221
+ }
222
+ async function loadConfig(cwd) {
223
+ const configPath = await findConfig(cwd);
224
+ if (!configPath) return {};
225
+ const builtConfig = fileURLToPath(new URL("./config.js", import.meta.url));
226
+ const sourceConfig = fileURLToPath(new URL("../config/index.ts", import.meta.url));
227
+ let configEntry = builtConfig;
228
+ try {
229
+ await access2(builtConfig);
230
+ } catch {
231
+ configEntry = sourceConfig;
232
+ }
233
+ const jiti = createJiti(import.meta.url, { interopDefault: true, alias: { "next-modal-router/config": configEntry } });
234
+ const loaded = await jiti.import(configPath, { default: true });
235
+ const problems = validateConfig(loaded);
236
+ if (problems.length) throw new Error(`Invalid ${path4.basename(configPath)}:
237
+ ${problems.map((problem) => ` ${problem.path}: ${problem.message}`).join("\n")}`);
238
+ return { path: configPath, config: loaded };
239
+ }
240
+
241
+ // src/cli/files.ts
242
+ import { mkdir, open, rename, rm, writeFile } from "fs/promises";
243
+ import path5 from "path";
244
+ async function applyWrites(writes, options) {
245
+ const changed = [];
246
+ for (const write of writes) {
247
+ await mkdir(path5.dirname(write.path), { recursive: true });
248
+ let existing = false;
249
+ try {
250
+ const handle = await open(write.path, "r");
251
+ await handle.close();
252
+ existing = true;
253
+ } catch {
254
+ }
255
+ if (existing && write.skipIfExists && !options.force) continue;
256
+ if (existing && !options.force && !write.overwrite) throw new Error(`Refusing to overwrite ${write.path}. Re-run with --force only if replacing this file is intentional.`);
257
+ changed.push(write.path);
258
+ if (options.dryRun) continue;
259
+ const temporary = `${write.path}.nmr-${process.pid}.tmp`;
260
+ await writeFile(temporary, write.content, "utf8");
261
+ try {
262
+ await rename(temporary, write.path);
263
+ } catch (error) {
264
+ await rm(temporary, { force: true });
265
+ throw error;
266
+ }
267
+ }
268
+ return changed;
269
+ }
270
+
271
+ // src/cli/templates.ts
272
+ var DEFAULT_TEMPLATE = `export default function Default() {
273
+ return null;
274
+ }
275
+ `;
276
+ function pageTemplate(name, type, intercepted) {
277
+ const title = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ");
278
+ if (!intercepted) return `export default function Page() {
279
+ return (
280
+ <main>
281
+ <h1>${title}</h1>
282
+ <p>This is the full-page route for ${name}.</p>
283
+ </main>
284
+ );
285
+ }
286
+ `;
287
+ return `"use client";
288
+
289
+ import { Suspense } from "react";
290
+ import { useOverlayRouter } from "next-modal-router";
291
+
292
+ function ${name.replace(/[^A-Za-z0-9]/g, "")}Overlay() {
293
+ const overlay = useOverlayRouter();
294
+
295
+ return (
296
+ <section role="dialog" aria-modal="true" aria-labelledby="${name}-title">
297
+ <h2 id="${name}-title">${title}</h2>
298
+ <p>Replace this headless ${type} route with your application content.</p>
299
+ <button type="button" onClick={() => overlay.close()}>Close</button>
300
+ </section>
301
+ );
302
+ }
303
+
304
+ export default function Page() {
305
+ return <Suspense fallback={null}><${name.replace(/[^A-Za-z0-9]/g, "")}Overlay /></Suspense>;
306
+ }
307
+ `;
308
+ }
309
+ function configTemplate(config) {
310
+ const entries = Object.entries(config.overlays).map(([name, item]) => ` ${JSON.stringify(name)}: ${formatObject(item, 4)},`).join("\n\n");
311
+ return `import { defineConfig } from "next-modal-router/config";
312
+
313
+ export default defineConfig({
314
+ defaultSlot: ${JSON.stringify(config.defaultSlot ?? "modal")},
315
+ overlays: {${entries ? `
316
+ ${entries}
317
+ ` : ""}},
318
+ });
319
+ `;
320
+ }
321
+ function formatObject(value, spaces) {
322
+ const indent = " ".repeat(spaces);
323
+ return `{
324
+ ${Object.entries(value).map(([key, item]) => `${indent} ${key}: ${JSON.stringify(item)},`).join("\n")}
325
+ ${indent}}`;
326
+ }
327
+
328
+ // src/cli/index.ts
329
+ function parseArguments(argv) {
330
+ const result = { positionals: [], flags: {} };
331
+ for (let index = 0; index < argv.length; index += 1) {
332
+ const item = argv[index];
333
+ if (!result.command && !item.startsWith("-")) {
334
+ result.command = item;
335
+ continue;
336
+ }
337
+ if (item.startsWith("--")) {
338
+ const [rawKey, inline] = item.slice(2).split("=", 2);
339
+ const key = rawKey;
340
+ const next = argv[index + 1];
341
+ if (inline !== void 0) result.flags[key] = inline;
342
+ else if (next && !next.startsWith("-")) {
343
+ result.flags[key] = next;
344
+ index += 1;
345
+ } else result.flags[key] = true;
346
+ } else result.positionals.push(item);
347
+ }
348
+ return result;
349
+ }
350
+ function flag(args, key) {
351
+ const value = args.flags[key];
352
+ return typeof value === "string" ? value : void 0;
353
+ }
354
+ function enabled(args, key) {
355
+ return args.flags[key] === true || args.flags[key] === "true";
356
+ }
357
+ function relative(cwd, file) {
358
+ return path6.relative(cwd, file) || ".";
359
+ }
360
+ function outputJson(value) {
361
+ stdout.write(`${JSON.stringify(value, null, 2)}
362
+ `);
363
+ }
364
+ async function commandInit(args) {
365
+ const project = await findProject(flag(args, "cwd") ?? process.cwd());
366
+ if (!dependencyVersion(project, "next")) throw new Error("This package does not declare Next.js. Install next before initializing next-modal-router.");
367
+ const configPath = path6.join(project.cwd, "next-modal-router.config.ts");
368
+ const defaultSlot = flag(args, "slot") ?? "modal";
369
+ const writes = [];
370
+ if (!await fileExists(configPath)) writes.push({ path: configPath, content: configTemplate({ defaultSlot, overlays: {} }) });
371
+ else if (!enabled(args, "force")) throw new Error(`${relative(project.cwd, configPath)} already exists. Nothing was overwritten.`);
372
+ else writes.push({ path: configPath, content: configTemplate({ defaultSlot, overlays: {} }) });
373
+ writes.push({ path: path6.join(project.appDir, `@${defaultSlot}`, "default.tsx"), content: DEFAULT_TEMPLATE, skipIfExists: true });
374
+ const changed = await applyWrites(writes, { dryRun: enabled(args, "dry-run"), force: enabled(args, "force") });
375
+ for (const file of changed) stdout.write(`${enabled(args, "dry-run") ? "WOULD CREATE" : "CREATE"} ${relative(project.cwd, file)}
376
+ `);
377
+ stdout.write(`
378
+ Initialized next-modal-router in ${project.cwd}. Add the @${defaultSlot} slot prop to the layout that owns it.
379
+ `);
380
+ return 0;
381
+ }
382
+ async function askMissing(args) {
383
+ const values = {};
384
+ if (enabled(args, "yes") || enabled(args, "ci") || !stdin.isTTY) return values;
385
+ const prompt = createInterface({ input: stdin, output: stdout });
386
+ try {
387
+ if (!args.positionals[0]) values.name = await prompt.question("Overlay name: ");
388
+ if (!flag(args, "route")) values.route = await prompt.question("Route: ");
389
+ if (!flag(args, "source")) values.source = await prompt.question("Source route: ");
390
+ if (!flag(args, "type")) values.type = await prompt.question("Type (modal/drawer/sheet/panel/custom) [modal]: ") || "modal";
391
+ if (!flag(args, "slot")) values.slot = await prompt.question("Slot [modal]: ") || "modal";
392
+ if (!flag(args, "fallback")) values.fallback = await prompt.question("Close fallback: ");
393
+ } finally {
394
+ prompt.close();
395
+ }
396
+ return values;
397
+ }
398
+ async function commandAdd(args) {
399
+ const prompted = await askMissing(args);
400
+ const project = await findProject(flag(args, "cwd") ?? process.cwd());
401
+ const loaded = await loadConfig(project.cwd);
402
+ const name = args.positionals[0] ?? prompted.name;
403
+ const route = flag(args, "route") ?? prompted.route;
404
+ const source = flag(args, "source") ?? prompted.source;
405
+ const type = flag(args, "type") ?? prompted.type ?? "modal";
406
+ const slot = flag(args, "slot") ?? prompted.slot ?? loaded.config?.defaultSlot ?? "modal";
407
+ const closeFallback = flag(args, "fallback") ?? prompted.fallback ?? source;
408
+ if (!name || !route || !source || !closeFallback) throw new Error("add requires a name, --route, --source, and --fallback (or an interactive terminal).");
409
+ if (!/^[A-Za-z][\w-]*$/.test(name)) throw new Error(`Invalid overlay name "${name}". Use letters, numbers, _ or -, beginning with a letter.`);
410
+ if (!OVERLAY_TYPES.includes(type)) throw new Error(`Unknown overlay type "${type}". Expected: ${OVERLAY_TYPES.join(", ")}.`);
411
+ const overlay = { route, source, type, slot, closeFallback };
412
+ const config = loaded.config ?? { defaultSlot: slot, overlays: {} };
413
+ if (config.overlays[name] && !enabled(args, "force")) throw new Error(`Overlay "${name}" already exists. Use --force to replace its generated files and definition.`);
414
+ const nextConfig = { ...config, overlays: { ...config.overlays, [name]: overlay } };
415
+ const problems = validateConfig(nextConfig);
416
+ if (problems.length) throw new Error(problems.map((problem) => `${problem.path}: ${problem.message}`).join("\n"));
417
+ const interception = calculateInterception("/", route);
418
+ const configPath = loaded.path ?? path6.join(project.cwd, "next-modal-router.config.ts");
419
+ const writes = [
420
+ { path: path6.join(project.appDir, `@${slot}`, "default.tsx"), content: DEFAULT_TEMPLATE, skipIfExists: true },
421
+ { path: path6.join(project.appDir, `@${slot}`, ...interception.filesystemPath.split("/"), "page.tsx"), content: pageTemplate(name, type, true) },
422
+ { path: path6.join(project.appDir, ...routeToFileSegments(route), "page.tsx"), content: pageTemplate(name, type, false), skipIfExists: true },
423
+ { path: configPath, content: configTemplate(nextConfig), overwrite: Boolean(loaded.path) }
424
+ ];
425
+ const changed = await applyWrites(writes, { dryRun: enabled(args, "dry-run"), force: enabled(args, "force") });
426
+ for (const file of changed) stdout.write(`${enabled(args, "dry-run") ? "WOULD WRITE" : "WRITE"} ${relative(project.cwd, file)}
427
+ `);
428
+ stdout.write(`
429
+ ${name}: ${source} \u2192 ${route} via @${slot}/${interception.filesystemPath}
430
+ `);
431
+ return 0;
432
+ }
433
+ async function commandCheck(args) {
434
+ const project = await findProject(flag(args, "cwd") ?? process.cwd());
435
+ const loaded = await loadConfig(project.cwd);
436
+ const result = await analyze(project, loaded.config);
437
+ if (flag(args, "format") === "json") outputJson(result);
438
+ else {
439
+ stdout.write(`next-modal-router
440
+
441
+ Checking ${result.checked} overlay${result.checked === 1 ? "" : "s"}...
442
+ `);
443
+ if (!result.issues.length) stdout.write("\n\u2713 Everything looks good.\n");
444
+ for (const issue of result.issues) {
445
+ const icon = issue.severity === "error" ? "\u2715" : issue.severity === "warning" ? "\u26A0" : "\u2139";
446
+ stdout.write(`
447
+ ${icon} ${issue.code}${issue.overlay ? ` ${issue.overlay}` : ""}
448
+ ${issue.message}
449
+ `);
450
+ if (issue.suggestion) stdout.write(` ${issue.suggestion}
451
+ `);
452
+ if (enabled(args, "verbose") && issue.path) stdout.write(` path: ${issue.path}
453
+ `);
454
+ }
455
+ }
456
+ return result.valid ? 0 : 1;
457
+ }
458
+ async function commandDoctor(args) {
459
+ const project = await findProject(flag(args, "cwd") ?? process.cwd());
460
+ const loaded = await loadConfig(project.cwd);
461
+ const result = await analyze(project, loaded.config);
462
+ const report = { environment: { node: process.version, next: dependencyVersion(project, "next") ?? null, react: dependencyVersion(project, "react") ?? null, appRouter: true, typescript: project.typescript, appDirectory: project.appRelative }, configuration: { path: loaded.path ? relative(project.cwd, loaded.path) : null, overlays: result.checked, valid: result.valid, warnings: result.issues.filter((issue) => issue.severity === "warning").length, errors: result.issues.filter((issue) => issue.severity === "error").length } };
463
+ if (flag(args, "format") === "json") outputJson(report);
464
+ else stdout.write(`Environment
465
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
466
+ Node ${report.environment.node}
467
+ Next.js ${report.environment.next ?? "not declared"}
468
+ React ${report.environment.react ?? "not declared"}
469
+ App Router \u2713 (${project.appRelative})
470
+ TypeScript ${project.typescript ? "\u2713" : "\u2013"}
471
+
472
+ Configuration
473
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
474
+ Config ${report.configuration.path ?? "not found (discovery mode)"}
475
+ Overlays ${report.configuration.overlays}
476
+ Warnings ${report.configuration.warnings}
477
+ Errors ${report.configuration.errors}
478
+
479
+ ${result.valid ? "Everything looks good." : "Run `next-modal-router check --verbose` for actionable diagnostics."}
480
+ `);
481
+ return result.valid ? 0 : 1;
482
+ }
483
+ async function commandList(args) {
484
+ const project = await findProject(flag(args, "cwd") ?? process.cwd());
485
+ const loaded = await loadConfig(project.cwd);
486
+ const analysis = await analyze(project, loaded.config);
487
+ const configured = Object.entries(loaded.config?.overlays ?? {}).map(([name, overlay]) => ({ name, route: overlay.route, source: overlay.source, type: overlay.type, slot: overlay.slot ?? loaded.config?.defaultSlot ?? "modal", origin: "config" }));
488
+ const known = new Set(configured.map((item) => `${item.slot}:${item.route.replace(/^\//, "")}`));
489
+ const discovered = analysis.discovered.filter((item) => !known.has(`${item.slot}:${item.target}`)).map((item) => ({ name: item.target, route: `/${item.target}`, source: "unknown", type: "custom", slot: item.slot, origin: "discovered" }));
490
+ const items = [...configured, ...discovered];
491
+ if (flag(args, "format") === "json") outputJson(items);
492
+ else {
493
+ stdout.write("NAME ROUTE TYPE SLOT ORIGIN\n");
494
+ for (const item of items) stdout.write(`${item.name.padEnd(21)}${item.route.padEnd(35)}${item.type.padEnd(10)}${item.slot.padEnd(11)}${item.origin}
495
+ `);
496
+ if (!items.length) stdout.write("No overlays found.\n");
497
+ }
498
+ return 0;
499
+ }
500
+ function printHelp() {
501
+ stdout.write(`next-modal-router
502
+ URL-native overlays for Next.js App Router
503
+
504
+ Usage:
505
+ next-modal-router <command> [options]
506
+
507
+ Commands:
508
+ init Create a typed config and default parallel slot
509
+ add Generate an overlay interceptor and full-page route
510
+ check Validate configured or discovered overlay routes
511
+ doctor Inspect project compatibility and configuration
512
+ list List configured and discovered overlays
513
+
514
+ Global options:
515
+ --cwd <path> Next.js application directory
516
+ --format json Machine-readable output (check, doctor, list)
517
+ --dry-run Report writes without changing files (init, add)
518
+ --force Replace files owned by the requested operation
519
+ --ci Disable prompts and ANSI output
520
+ --verbose Include filesystem paths in diagnostics
521
+ `);
522
+ }
523
+ async function run(argv = process.argv.slice(2)) {
524
+ const args = parseArguments(argv);
525
+ if (!args.command || args.command === "help" || enabled(args, "help")) {
526
+ printHelp();
527
+ return 0;
528
+ }
529
+ if (args.command === "init") return commandInit(args);
530
+ if (args.command === "add") return commandAdd(args);
531
+ if (args.command === "check") return commandCheck(args);
532
+ if (args.command === "doctor") return commandDoctor(args);
533
+ if (args.command === "list") return commandList(args);
534
+ throw new Error(`Unknown command "${args.command}". Run next-modal-router help.`);
535
+ }
536
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
537
+ run().then((code) => {
538
+ process.exitCode = code;
539
+ }).catch((error) => {
540
+ process.stderr.write(`next-modal-router: ${error instanceof Error ? error.message : String(error)}
541
+ `);
542
+ process.exitCode = 2;
543
+ });
544
+ }
545
+ export {
546
+ run
547
+ };
@@ -0,0 +1,22 @@
1
+ declare const OVERLAY_TYPES: readonly ["modal", "drawer", "sheet", "panel", "custom"];
2
+ type OverlayType = (typeof OVERLAY_TYPES)[number];
3
+ interface OverlayDefinition {
4
+ route: string;
5
+ source: string;
6
+ type: OverlayType;
7
+ slot?: string;
8
+ closeFallback: string;
9
+ }
10
+ interface OverlayConfig<T extends Record<string, OverlayDefinition> = Record<string, OverlayDefinition>> {
11
+ defaultSlot?: string;
12
+ overlays: T;
13
+ }
14
+ declare function defineConfig<const T extends Record<string, OverlayDefinition>>(config: OverlayConfig<T>): OverlayConfig<T>;
15
+
16
+ interface ConfigProblem {
17
+ path: string;
18
+ message: string;
19
+ }
20
+ declare function validateConfig(value: unknown): ConfigProblem[];
21
+
22
+ export { type ConfigProblem, OVERLAY_TYPES, type OverlayConfig, type OverlayDefinition, type OverlayType, defineConfig, validateConfig };
package/dist/config.js ADDED
@@ -0,0 +1,64 @@
1
+ // src/config/types.ts
2
+ var OVERLAY_TYPES = ["modal", "drawer", "sheet", "panel", "custom"];
3
+ function defineConfig(config) {
4
+ return config;
5
+ }
6
+
7
+ // src/routing/segments.ts
8
+ var UNSAFE = /(?:^|\/)\.\.?($|\/)|[\\\0]/;
9
+ function normalizeRoute(route) {
10
+ if (!route.startsWith("/")) throw new Error(`Route "${route}" must begin with "/".`);
11
+ if (UNSAFE.test(route)) throw new Error(`Route "${route}" contains an unsafe filesystem segment.`);
12
+ const normalized = `/${route.split("/").filter(Boolean).join("/")}`;
13
+ return normalized === "/" ? normalized : normalized.replace(/\/$/, "");
14
+ }
15
+
16
+ // src/config/validate.ts
17
+ function validateConfig(value) {
18
+ const problems = [];
19
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
20
+ return [{ path: "config", message: "Configuration must be an object." }];
21
+ }
22
+ const config = value;
23
+ if (!config.overlays || typeof config.overlays !== "object" || Array.isArray(config.overlays)) {
24
+ return [{ path: "overlays", message: "An overlays object is required." }];
25
+ }
26
+ const targets = /* @__PURE__ */ new Map();
27
+ for (const [name, raw] of Object.entries(config.overlays)) {
28
+ const path = `overlays.${name}`;
29
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
30
+ problems.push({ path, message: "Overlay must be an object." });
31
+ continue;
32
+ }
33
+ const overlay = raw;
34
+ for (const key of ["route", "source", "closeFallback"]) {
35
+ if (typeof overlay[key] !== "string" || overlay[key].length === 0) {
36
+ problems.push({ path: `${path}.${key}`, message: `${key} is required.` });
37
+ } else {
38
+ try {
39
+ normalizeRoute(overlay[key]);
40
+ } catch (error) {
41
+ problems.push({ path: `${path}.${key}`, message: error.message });
42
+ }
43
+ }
44
+ }
45
+ if (!overlay.type || !OVERLAY_TYPES.includes(overlay.type)) {
46
+ problems.push({ path: `${path}.type`, message: `Expected one of: ${OVERLAY_TYPES.join(", ")}.` });
47
+ }
48
+ if (overlay.slot !== void 0 && !/^[A-Za-z][\w-]*$/.test(overlay.slot)) {
49
+ problems.push({ path: `${path}.slot`, message: "Slot must start with a letter and contain only letters, numbers, _ or -." });
50
+ }
51
+ if (typeof overlay.route === "string" && typeof overlay.slot !== "object") {
52
+ const key = `${overlay.slot ?? config.defaultSlot ?? "modal"}:${overlay.route}`;
53
+ const existing = targets.get(key);
54
+ if (existing) problems.push({ path, message: `Duplicates intercepted target used by ${existing}.` });
55
+ else targets.set(key, name);
56
+ }
57
+ }
58
+ return problems;
59
+ }
60
+ export {
61
+ OVERLAY_TYPES,
62
+ defineConfig,
63
+ validateConfig
64
+ };