import { promises as fs } from "fs"; import { relative, resolve, basename } from "path"; import _locreq from "locreq"; import { walkDir } from "./utils/walk.js"; import { importPath } from "./utils/import-path.js"; import { assertType, predicates } from "@sealcode/ts-predicates"; import { unescape_url_params } from "./utils/escape-url-params.js"; import { formatWithPrettier } from "./utils/prettier.js"; import { Templates } from "./templates/templates.js"; const target_locreq = _locreq(process.cwd()); async function extractActionName(full_file_path: string): Promise { const file_content = await fs.readFile(full_file_path, "utf-8"); const result = /export const actionName = "(\w+)"/.exec(file_content); if (result === null) { throw new Error("Missing 'export const actionName' statement?"); } return assertType( result[1], predicates.string, "Missing export default class?" ); } export function generateURL(actionName: string, url: string) { const parts = url.split("/"); const params = parts .filter((part) => part.startsWith(":")) .map((part) => part.slice(1)); if (params.length === 0) { return `export const ${actionName}URL = "${encodeURI(url)}";`; } return `export const ${actionName}URL = (${params .map((s) => s + ": string") .join(", ")})=>\`${parts .map((part) => part.startsWith(":") ? "${" + part.slice(1) + "}" : encodeURIComponent(part) ) .join("/")}\`; ${actionName}URL.params = [${params .map((param) => `"${param}"`) .join(",")}]; ${actionName}URL.rawURL = "${encodeURI(url)}";`; } export function sortRoutes(urls: T[]): T[] { return urls.sort(({ url: url1 }, { url: url2 }) => { const [segments1, segments2] = [url1, url2].map((url) => url.split("/") ); if (segments1.length < segments2.length) { return -1; } if (segments1.length > segments2.length) { return 1; } // if they are the same length, map each url to a string like 00010 // where "0" is for static (so should be sorted earlier, and "1" is for // dynamic parts const [encoded1, encoded2] = [url1, url2].map((url) => url.split("/").map((e) => (e.startsWith(":") ? "1" : "0")) ); return encoded1 < encoded2 ? -1 : 1; }); } export async function generateRoutes(): Promise { const files = await Promise.all( (await walkDir(target_locreq.resolve("src/back/routes"))) .filter((f) => Object.keys(Templates).some( (key) => f.endsWith(`.${key}.ts`) || f.endsWith(`.${key}.tsx`) ) ) .map(async (fullpath) => ({ fullpath, actionName: await extractActionName(fullpath), // trailing slash is important here, as it enables to use the entire path while building relative URLs. For example, while visiting /users/123, the path ./add-photo leads to /users/add-photo. While visiting /users/123/ (note the trailing slash), the path ./add-photo leads to /users/123/add-photo url: unescape_url_params( "/" + relative( target_locreq.resolve("src/back/routes"), resolve( fullpath, basename(fullpath).startsWith("index.") ? "../" : "" ) ).replace(/\..+/, "") + "/" ).replace(/\/\//g, "/"), })) ); type URLTree = { actionName?: string; children: { [key: string]: URLTree }; }; const url_tree: URLTree = { children: {} }; sortRoutes(files).forEach(({ actionName, url }) => { const elements = url.split("/").filter((e) => e != ""); let pointer: URLTree = url_tree; for (const [index, element] of elements.entries()) { if (!pointer.children[element]) { pointer.children[element] = { children: {} }; } pointer = pointer.children[element]; if (index == elements.length - 1) { (pointer as unknown as { actionName: string }).actionName = actionName; } } }); const routes_content = `// DO NOT EDIT! This file is generated automaticaly with npm run generate-routes export type URLTree = { actionName?: string; children: { [key: string]: URLTree }; }; export const url_tree = ${JSON.stringify(url_tree)}; import type Router from "@koa/router"; import { mount } from "@sealcode/sealgen"; import * as URLs from "./urls.js" ${sortRoutes(files) .map( ({ actionName, fullpath }) => `import { default as ${actionName} } from "${importPath( target_locreq.resolve("src/back/routes/routes.ts"), fullpath )}";` ) .join("\n")} export default function mountAutoRoutes(router: Router) { ${sortRoutes(files) .map( ({ actionName }) => ` mount(router, URLs.${actionName}URL, ${actionName});` ) .join("\n")} }`; const urls_content = `/* DO NOT EDIT! This file is generated automaticall with npm run generate-routes. */ ${sortRoutes(files) .map(({ actionName, url }) => generateURL(actionName, url)) .join("\n")} `; await fs.writeFile( target_locreq.resolve("src/back/routes/routes.ts"), await formatWithPrettier(routes_content) ); await fs.writeFile( target_locreq.resolve("src/back/routes/urls.ts"), await formatWithPrettier(urls_content) ); // eslint-disable-next-line no-console console.log( "Successfuly generated new src/back/routes/routes.ts and src/back/routes/urls.ts" ); }