next-typesafe-path
Version:
Type-safe path for Next.js
274 lines (271 loc) • 8.82 kB
JavaScript
import { existsSync as d } from "node:fs";
import m from "path";
import R from "chokidar";
import { program as w } from "commander";
import b, { readdir as S, stat as j, writeFile as W } from "fs/promises";
const T = (t) => `import("${t}").SearchParams`, $ = (t) => t === "_app.tsx" || t === "_document.tsx" || t === "api" || t === "layout.tsx", f = (t) => t === "page.tsx" || t === "page.ts", x = (t) => t.startsWith("(") && t.endsWith(")"), E = (t) => t.replace(/[-_]([a-z])/g, (e, r) => r.toUpperCase()), D = (t) => t.startsWith("[[...") && t.endsWith("]]") ? "optional-catch-all" : t.startsWith("[...") && t.endsWith("]") ? "catch-all" : t.startsWith("[") && t.endsWith("]") ? "dynamic" : !1, I = (t) => {
switch (D(t)) {
case "catch-all":
return t.replace(/\[\.\.\.(.+)\]/, "$1");
case "optional-catch-all":
return t.replace(/\[\[\.\.\.(.+)\]\]/, "$1");
case "dynamic":
return t.replace(/\[(.+)\]/, "$1");
default:
return t;
}
}, y = ({
segment: t,
parentSegment: e
}) => {
if (x(t))
return null;
const r = D(t);
if (!r)
return {
rawParamName: t,
paramName: "",
isDynamic: !1,
dynamicType: !1,
isPage: f(t),
parentSegment: e
};
const a = r ? I(t) : t;
return {
rawParamName: t,
paramName: E(a),
isDynamic: !0,
dynamicType: r,
isPage: f(t),
parentSegment: e
};
};
function C(t) {
return t.filter((e) => !e.isDynamic).map((e) => e.rawParamName).join("/");
}
function k({ inputDir: t }) {
if (t)
return async function e({
currentPath: r = t,
parentSegments: a = [],
accumulatedRoutes: o = []
} = {}) {
const i = await S(r), n = [];
for (const s of i) {
const p = m.join(r, s), c = m.relative(process.cwd(), p);
if (!$(s))
if (f(s)) {
const l = a.filter(Boolean), u = T(c);
n.push({
routeSegments: l,
searchParamsType: u
});
} else {
if (!(await b.stat(p)).isDirectory())
continue;
x(s) && n.push(
...await e({
currentPath: p,
parentSegments: [],
accumulatedRoutes: o
})
);
const u = C(a), h = y({
segment: s,
parentSegment: u
});
if (!h)
continue;
n.push(
...await e({
currentPath: p,
parentSegments: [...a, h],
accumulatedRoutes: o
})
);
}
}
return n;
};
}
function g(t) {
return t.filter((e) => !e.isDynamic).map((e) => e.rawParamName).join("/");
}
function F({ inputDir: t }) {
if (t)
return async function e({
currentPath: r = t,
parentSegments: a = []
} = {}) {
const o = await S(r), i = [];
for (const n of o) {
if ($(n))
continue;
const s = m.join(r, n);
if ((await j(s)).isDirectory()) {
const c = y({
segment: n,
parentSegment: g(a)
});
c && i.push(
...await e({
currentPath: s,
parentSegments: [...a, c]
})
);
} else if (n.endsWith(".tsx") || n.endsWith(".ts")) {
const c = n === "index.tsx" || n === "index.ts", l = y({
segment: n.replace(/\.tsx?$/, ""),
parentSegment: g(a)
});
if (!l)
continue;
if (c || l) {
const u = m.relative(process.cwd(), s), h = c ? a : [...a, l], v = T(u);
i.push({
routeSegments: h,
searchParamsType: v
});
}
}
}
return i;
};
}
const N = (t) => t.length === 0 ? "" : t.map((e) => (e.isDynamic, e.rawParamName)).join("/"), _ = ({ routes: t, options: e }) => `${t.map((r) => {
const a = r.routeSegments.length === 0 ? "/" : `/${N(r.routeSegments)}${e.trailingSlash ? "/" : ""}`;
if (r.routeSegments.some((o) => o.dynamicType === "optional-catch-all")) {
const o = `/${r.routeSegments.filter((i) => i.dynamicType !== "optional-catch-all").map((i) => i.rawParamName).join("/")}${e.trailingSlash ? "/" : ""}`;
return `"${a}" | "${o}"`;
}
return `"${a}"`;
}).join(" | ")};`, A = ({
route: t,
options: e
}) => {
const r = t.routeSegments.length === 0 ? '"/"' : `"/${N(t.routeSegments)}${e.trailingSlash ? "/" : ""}"`, a = t.routeSegments.some(
(s) => s.dynamicType === "optional-catch-all"
), o = t.searchParamsType, i = P(t);
return a ? [
{
path: `"/${t.routeSegments.filter((s) => s.dynamicType !== "optional-catch-all").map((s) => s.rawParamName).join("/")}${e.trailingSlash ? "/" : ""}"`.replace(/"/g, ""),
definition: `{
params: Record<string, never>,
searchParams: ExportedQuery<${o}>
}`
},
{
path: r.replace(/"/g, ""),
definition: `{
params: ${P(t, !0)},
searchParams: ExportedQuery<${o}>
}`
}
] : {
path: r.replace(/"/g, ""),
definition: `{
params: ${i},
searchParams: ExportedQuery<${o}>
}`
};
}, P = (t, e = !1) => t.routeSegments.some((a) => a.isDynamic) ? `{ ${t.routeSegments.filter((a) => a.isDynamic).map((a) => {
switch (a.dynamicType) {
case "catch-all":
return `${a.paramName}: string[] | number[]`;
case "optional-catch-all":
return e ? `${a.paramName}: string[] | number[]` : `${a.paramName}?: string[] | number[]`;
default:
return `${a.paramName}: string | number`;
}
}).join(", ")} }` : "Record<string, never>", G = ({
routes: t,
options: e
}) => {
const r = _({ routes: t, options: e }), a = [];
for (const i of t) {
const n = A({ route: i, options: e });
Array.isArray(n) ? a.push(...n) : n && a.push(n);
}
return `// This file is auto-generated from next-typesafe-path
// DO NOT EDIT DIRECTLY
declare module "@@@next-typesafe-path" {
type IsEmpty<T> = T extends Record<string, never> ? true : false;
type IsSearchParams<T> = symbol extends keyof T ? false : IsEmpty<T> extends true ? false : true;
type SearchParamsConfig = import("${e.configDir}/next-typesafe-path.config").SearchParams;
type SearchParams = IsSearchParams<SearchParamsConfig> extends true ? SearchParamsConfig : never;
type ExportedQuery<T> = IsSearchParams<T> extends true
? SearchParams extends never ? { [K in keyof T]: T[K] } : SearchParams & { [K in keyof T]: T[K] }
: SearchParams;
type RoutePath = ${r}
interface RouteList {
${a.map(({ path: i, definition: n }) => ` "${i}": ${n}`).join(`,
`)}
}
}
`;
}, K = async (t, e) => {
try {
await W(t, e);
} catch (r) {
throw console.error(`Error writing to file ${t}:`, r), r;
}
}, Q = async ({
appDir: t,
pagesDir: e,
options: r
}) => {
const a = k({ inputDir: t }), o = F({ inputDir: e }), i = a ? await a() : [], n = o ? await o() : [], s = [...i, ...n], p = m.join(process.cwd(), "_next-typesafe-path.d.ts"), c = G({
routes: s,
options: r
});
await K(p, c);
};
w.name("next-typesafe-path").description("Generate type-safe path for Next.js").option("-w, --watch", "Watch for file changes and regenerate types").option(
"--trailing-slash <boolean>",
"Enable trailing slash in generated routes",
"true"
).option(
"-c, --config-dir <path>",
"Directory to the config file",
"."
).action(
async (t) => {
const e = (c) => {
const l = m.resolve(process.cwd(), c), u = m.resolve(process.cwd(), "src", c);
return d(l) ? l : d(u) ? u : null;
}, r = e("app") || "", a = e("pages") || "", o = t.trailingSlash === "true", i = t.configDir || ".";
!r && !a && (console.error(
"Error: Neither 'app' nor 'pages' directory found in root or src directory"
), process.exit(1));
const n = {
appDir: r,
pagesDir: a,
options: {
trailingSlash: o,
configDir: i
}
};
let s = null;
const p = () => {
s && clearTimeout(s), s = setTimeout(async () => {
try {
await Q(n);
} catch {
t.watch || process.exit(1);
}
}, 1e3);
};
if (p(), console.log("=================================="), console.log("✨ Generating routes types..."), console.log("🚀 by next-typesafe-path"), console.log("=================================="), t.watch) {
const c = [r, a].filter((u) => u);
R.watch(c, {
ignored: /(^|[\/\\])\../,
persistent: !0,
ignoreInitial: !0,
usePolling: !1,
awaitWriteFinish: !0
}).on("add", () => p()).on("unlink", () => p()).on("unlinkDir", () => p()).on("change", () => p());
}
}
);
w.parse();