nextjs-paths
Version:
Generate path helpers for Next.js App Router
269 lines (268 loc) • 12.9 kB
JavaScript
;
/*
* generatePaths.ts – library edition (stable)
* -------------------------------------------------------------
* `generatePaths()` scans a Next 15 App‑Router tree and writes a typed
* `paths.ts` helper. Call it from your build/dev pipeline — no CLI side‑effects.
*
* ▸ Static, dynamic (`[id]`), optional dynamic (`[[id]]`, `[[...slug]]`)
* ▸ Invisible route groups `(group)` + parallel slots `@slot`
* ▸ Deep‑merge when `page.*` and `route.*` coexist
* ▸ `page.*` exposes `{ path, url, URL }`
* ▸ Each exported HTTP handler in `route.*` exposes the same trio
* (GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS)
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.generatePaths = generatePaths;
const ts = __importStar(require("typescript"));
const fs_1 = require("fs");
const path_1 = __importDefault(require("path"));
const f = ts.factory;
function generatePaths(options = {}) {
const caseStyle = options.caseStyle ?? "camelCase";
const appDir = options.appDir
? path_1.default.resolve(options.appDir)
: path_1.default.join(process.cwd(), "src", "app");
const envKey = options.envKey ?? "NEXT_PUBLIC_APP_BASE_URL";
const fileName = options.fileName?.endsWith(".ts")
? options.fileName
: "paths.ts";
const outFile = path_1.default.join(options.outputDir ?? appDir, fileName);
const rawSegments = scanDir(appDir, caseStyle);
const tree = mergeSegments(rawSegments);
const source = buildSourceFile(tree, envKey, caseStyle);
const code = ts
.createPrinter({ newLine: ts.NewLineKind.LineFeed })
.printFile(source);
(0, fs_1.writeFileSync)(outFile, code);
console.log("✅ paths.ts generated →", path_1.default.relative(process.cwd(), outFile));
}
const METHODS = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
];
// ---------------------------------------------------------------------------
// Filesystem traversal → raw segments
// ---------------------------------------------------------------------------
function scanDir(dir, style) {
const out = [];
for (const name of (0, fs_1.readdirSync)(dir)) {
if (name === "api")
continue;
const full = path_1.default.join(dir, name);
const stats = (0, fs_1.statSync)(full);
// route groups or parallel slots are URL‑invisible
if (stats.isDirectory() &&
(/^\(.*\)$/.test(name) || name.startsWith("@"))) {
out.push(...scanDir(full, style));
continue;
}
if (stats.isDirectory()) {
const dyn = name.match(/^\[\[?(?:\.\.\.)?([^\]]+)\]\]?$/); // [id] | [[id]] | [...slug] | [[...slug]]
const key = toCase(dyn ? dyn[1] : name, style);
const segment = {
key,
pathPart: dyn ? "" : name,
hasPage: hasFile(full, "page"),
methods: extractRouteMethods(full),
children: scanDir(full, style),
};
if (dyn)
segment.dynamic = { param: dyn[1], optional: name.startsWith("[[") };
out.push(segment);
}
}
return out;
}
function hasFile(dir, base) {
return ["tsx", "ts", "jsx", "js"].some((ext) => (0, fs_1.existsSync)(path_1.default.join(dir, `${base}.${ext}`)));
}
function extractRouteMethods(dir) {
const routeFile = ["route.tsx", "route.ts", "route.jsx", "route.js"].find((f) => (0, fs_1.existsSync)(path_1.default.join(dir, f)));
if (!routeFile)
return [];
const src = (0, fs_1.readFileSync)(path_1.default.join(dir, routeFile), "utf8");
return METHODS.filter((m) => {
// ES / TS export patterns
const esRe = new RegExp(`export\\s+(?:async\\s+)?(?:function|const|let|var)\\s+${m}\\b`);
// CommonJS compiled pattern: exports.METHOD = or module.exports.METHOD =
const cjsRe = new RegExp(`(?:exports|module\\.exports)\\.${m}\\s*=`);
return esRe.test(src) || cjsRe.test(src);
});
}
// ---------------------------------------------------------------------------
// Deep‑merge to prevent duplicate segment keys
// ---------------------------------------------------------------------------
function mergeSegments(list) {
const map = new Map();
for (const seg of list) {
const existing = map.get(seg.key);
if (!existing) {
map.set(seg.key, { ...seg, children: mergeSegments(seg.children) });
continue;
}
// page first, then route ➜ drop GET from route
if (existing.hasPage && seg.methods.includes("GET")) {
seg.methods = seg.methods.filter((m) => m !== "GET");
}
// route (with GET) first, then page ➜ drop page helpers
if (!existing.hasPage && existing.methods.includes("GET") && seg.hasPage) {
seg.hasPage = false;
}
existing.hasPage = existing.hasPage || seg.hasPage;
existing.methods = Array.from(new Set([...existing.methods, ...seg.methods]));
existing.children = mergeSegments([...existing.children, ...seg.children]);
}
return Array.from(map.values());
}
// ---------------------------------------------------------------------------
// TS factory helpers
// ---------------------------------------------------------------------------
const id = (n) => f.createIdentifier(n);
const str = (s) => f.createStringLiteral(s);
const concat = (a, b) => f.createBinaryExpression(a, ts.SyntaxKind.PlusToken, b);
const paramDecl = (name, optional = false) => f.createParameterDeclaration(undefined, undefined, id(name), optional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), undefined);
const makeCall = (expr) => f.createCallExpression(id("make"), undefined, [expr]);
// ---------------------------------------------------------------------------
// Source‑file builder
// ---------------------------------------------------------------------------
function buildSourceFile(tree, envKey, style) {
// url helper
const urlDecl = f.createVariableStatement(undefined, f.createVariableDeclarationList([
f.createVariableDeclaration("url", undefined, undefined, f.createArrowFunction(undefined, undefined, [paramDecl("p")], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), concat(f.createElementAccessExpression(f.createPropertyAccessExpression(id("process"), "env"), str(envKey)), id("p")))),
], ts.NodeFlags.Const));
// make helper
const makeDecl = f.createVariableStatement(undefined, f.createVariableDeclarationList([
f.createVariableDeclaration("make", undefined, undefined, f.createArrowFunction(undefined, undefined, [paramDecl("p")], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), f.createParenthesizedExpression(f.createObjectLiteralExpression([
f.createPropertyAssignment("path", id("p")),
f.createPropertyAssignment("url", f.createCallExpression(id("url"), undefined, [id("p")])),
f.createPropertyAssignment("URL", buildURLHelper()),
], true)))),
], ts.NodeFlags.Const));
// Build object tree
const pathsObj = buildRoot(tree);
const exportDecl = f.createVariableStatement([f.createModifier(ts.SyntaxKind.ExportKeyword)], f.createVariableDeclarationList([f.createVariableDeclaration("paths", undefined, undefined, pathsObj)], ts.NodeFlags.Const));
return f.createSourceFile([urlDecl, makeDecl, exportDecl], f.createToken(ts.SyntaxKind.EndOfFileToken), ts.NodeFlags.None);
// ───── local helpers ────────────────────────────────────────────────
function buildURLHelper() {
// () => new URL(url(p)) – no args allowed
return f.createArrowFunction(undefined, undefined, [], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), f.createNewExpression(id("URL"), undefined, [
f.createCallExpression(id("url"), undefined, [id("p")]),
]));
}
function buildRoot(children) {
const props = [
f.createSpreadAssignment(makeCall(str(""))),
];
props.push(...children.map((c) => buildProp(c, str(""))));
return f.createObjectLiteralExpression(props, true);
}
function buildProp(seg, base) {
if (seg.dynamic)
return f.createPropertyAssignment(seg.key, buildDynamic(seg, base));
const nextBase = concat(base, str("/" + seg.pathPart));
return f.createPropertyAssignment(seg.key, buildSegment(seg, nextBase));
}
function buildDynamic(seg, base) {
const argName = toCase(seg.dynamic.param, style);
const argId = id(argName);
const nextBase = seg.dynamic.optional
? f.createConditionalExpression(argId, f.createToken(ts.SyntaxKind.QuestionToken), concat(base, concat(str("/"), argId)), f.createToken(ts.SyntaxKind.ColonToken), base)
: concat(base, concat(str("/"), argId));
return f.createArrowFunction(undefined, undefined, [paramDecl(argName, seg.dynamic.optional)], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), buildSegment(seg, nextBase));
}
function buildSegment(seg, base) {
const props = [];
if (seg.hasPage)
props.push(f.createSpreadAssignment(makeCall(base)));
const methodList = seg.hasPage
? seg.methods.filter((m) => m !== "GET")
: seg.methods;
for (const m of methodList)
props.push(f.createPropertyAssignment(m, makeCall(base)));
for (const child of seg.children)
props.push(buildProp(child, base));
return props.length
? f.createObjectLiteralExpression(props, true)
: makeCall(base);
}
}
// -----------------------------------------------------------------------------
// Utilities
// -----------------------------------------------------------------------------
function toCase(str, caseStyle) {
// First, clean up the string by removing any non-alphanumeric characters
// and splitting by hyphens, underscores, or camelCase boundaries
const words = str
.replace(/[^a-zA-Z0-9]/g, "-")
// Split by hyphens and underscores
.split(/[-_]/)
// Split camelCase boundaries
.flatMap((word) => word
// Insert space before capital letters
.replace(/([A-Z])/g, " $1")
// Split by spaces
.split(" ")
// Filter out empty strings
.filter(Boolean))
.filter(Boolean);
switch (caseStyle) {
case "camelCase":
return words
.map((word, index) => index === 0
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("");
case "lowerSnake":
return words.map((word) => word.toLowerCase()).join("_");
case "upperSnake":
return words.map((word) => word.toUpperCase()).join("_");
case "pascalCase":
return words
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("");
default:
return str;
}
}