UNPKG

next-typesafe-path

Version:
274 lines (271 loc) 8.82 kB
#!/usr/bin/env node 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();