Skip to main content
Module

x/udibo_react_app/build.ts

A React Framework for Deno that makes it easy to create highly interactive apps that have server side rendering with file based routing for both your UI and API.
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
import { walk } from "std/fs/walk.ts";import { ensureDir } from "std/fs/ensure_dir.ts";import * as path from "std/path/mod.ts";import * as esbuild from "x/esbuild/mod.js";import { denoPlugin } from "x/esbuild_deno_loader/mod.ts";
import { isProduction, isTest } from "./env.ts";
interface Route { name: string; parent?: Route; react?: boolean; file?: { react?: string; oak?: string; }; main?: { react?: string; oak?: string; }; index?: { react?: string; oak?: string; }; children?: Record<string, Route>;}
const TEST_PATH = /(\.|_)test(\.(?:js|jsx|ts|tsx))$/;const IGNORE_PATH = /(\/|\\)_[^\/\\]*(\.(?:js|jsx|ts|tsx))$/;const ROUTE_PATH = /(\.(?:js|jsx|ts|tsx))$/;const REACT_EXT = /(\.(?:jsx|tsx))$/;
function addFileToDir(route: Route, name: string, ext: string) { const isReactFile = REACT_EXT.test(ext); if (name === "main" || name === "index") { if (!route[name]) { route[name] = {}; } if (isReactFile) { route[name]!.react = `${name}${ext}`; } else { route[name]!.oak = `${name}${ext}`; } } else { if (!route.children) route.children = {}; if (!route.children[name]) { route.children[name] = { name, parent: route, file: {} }; } const childRoute = route.children[name];
if (!childRoute.file) childRoute.file = {}; if (isReactFile) { childRoute.react = true; childRoute.file.react = `${name}${ext}`; } else { childRoute.file.oak = `${name}${ext}`; } }
if (isReactFile) { let currentRoute: Route | undefined = route; while (currentRoute && !currentRoute.react) { currentRoute.react = true; currentRoute = route.parent as Route; } }}
async function generateRoutes(routesUrl: string): Promise<Route> { const rootRoute = { name: "", children: {} } as Route;
for await ( const entry of walk(routesUrl, { includeDirs: false, match: [ROUTE_PATH], skip: [TEST_PATH, IGNORE_PATH], }) ) { const parsedPath = path.parse(entry.path); const { name, ext, dir } = parsedPath; const relativePath = path.relative(routesUrl, dir.replaceAll("\\", "/")); const layers = relativePath.length ? relativePath.split("/") : [];
let parentRoute = rootRoute; for (const layer of layers) { if (!parentRoute.children) parentRoute.children = {}; if (!parentRoute.children[layer]) { parentRoute.children[layer] = { name: layer, children: {}, parent: parentRoute, }; } parentRoute = parentRoute.children[layer]; }
addFileToDir(parentRoute, name, ext); }
return rootRoute;}
const ROUTE_PARAM = /^\[(.+)]$/;const ROUTE_WILDCARD = /^\[...\]$/;function routePathFromName(name: string, forServer = false) { if (!name) return "/"; return name .replace(ROUTE_WILDCARD, forServer ? "(.*)" : "*") .replace(ROUTE_PARAM, ":$1");}function routerPathFromName(name: string) { return routePathFromName(name, true);}
function lazyImportLine(routeId: number, routePath: string, filePath: string) { return `const $${routeId} = lazy(${ routePath ? `"/${routePath}", ` : "" }() => import("./${filePath}"));`;}
function routeFileData(routeId: number, relativePath: string, route: Route) { const importLines: string[] = []; const name = routePathFromName(route.name); let routeText = `{ path: "${name}"`;
const { file, main, index, children } = route; if (file?.react) { importLines.push( lazyImportLine( routeId, relativePath, path.posix.join(relativePath, routeId === 0 ? "" : "../", file.react), ), ); routeText += `, element: <$${routeId} /> }`; routeId++; } else { if (main?.react) { if (relativePath) { importLines.push( lazyImportLine( routeId, relativePath, path.posix.join(relativePath, main.react), ), ); } else { importLines.push( `import * as $${routeId++} from "./${ path.posix.join(relativePath, main.react) }";`, `const $${routeId} = withAppErrorBoundary($${ routeId - 1 }.default, { FallbackComponent: ($${ routeId - 1 } as RouteFile).ErrorFallback ?? DefaultErrorFallback });`, ); } routeText += `, element: <$${routeId} />`; routeId++; } else if (!relativePath) { importLines.push( `import { Outlet } from "npm/react-router-dom";`, `const $${routeId} = withAppErrorBoundary(() => <Outlet />, { FallbackComponent: DefaultErrorFallback });`, ); routeText += `, element: <$${routeId} />`; routeId++; }
const childRouteTexts: string[] = []; if (index?.react) { importLines.push( lazyImportLine( routeId, path.posix.join(relativePath, "index"), path.posix.join(relativePath, index.react), ), ); childRouteTexts.push(`{ index: true, element: <$${routeId} /> }`); routeId++; }
let notFoundRoute: Route | undefined = undefined; for (const childRoute of Object.values(children ?? {})) { if (!childRoute.react) continue; if (childRoute.name === "[...]") { notFoundRoute = childRoute; continue; } const { importLines: childImportLines, routeText: childRouteText, nextRouteId, } = routeFileData( routeId, path.posix.join(relativePath, childRoute.name), childRoute, ); importLines.push(...childImportLines); childRouteTexts.push(childRouteText); routeId = nextRouteId; }
if (notFoundRoute) { const { importLines: childImportLines, routeText: childRouteText, nextRouteId, } = routeFileData( routeId, path.posix.join(relativePath, notFoundRoute.name), notFoundRoute, ); importLines.push(...childImportLines); childRouteTexts.push(childRouteText); routeId = nextRouteId; } else if (relativePath === "") { childRouteTexts.push(`{ path: "*", element: <NotFound /> }`); }
if (childRouteTexts.length) { routeText += `, children: [${childRouteTexts.join(", ")}]`; } routeText += "}"; }
return { importLines, routeText, nextRouteId: routeId, };}
function routeImportLines(routeId: number, relativePath: string) { return [ `import "./${relativePath}";`, `import * as $${routeId} from "./${relativePath}";`, ];}
function routerImportLine(routeId: number, relativePath: string) { return `import $${routeId} from "./${relativePath}";`;}
function routerFileData( parentRouteId: number, routeId: number, relativePath: string, route: Route,) { const importLines: string[] = []; const routerLines: string[] = [];
const { name, file, main, index, react, children, parent } = route; if (file) { if (file.react) { importLines.push( ...routeImportLines( routeId, path.posix.join(relativePath, routeId > 0 ? "../" : "", file.react), ), ); routerLines.push( `if (($${routeId} as RouteFile).ErrorFallback) {`, ` $${parentRouteId}.use("/${ routerPathFromName(name) }", errorBoundary("/${relativePath}"));`, `}`, ); routeId++; }
if (file.oak) { importLines.push( routerImportLine( routeId, path.posix.join(relativePath, routeId > 0 ? "../" : "", file.oak), ), ); if (relativePath !== "") { routerLines.push( `$${parentRouteId}.use("/${ routerPathFromName(name) }", $${routeId}.routes(), $${routeId}.allowedMethods());`, ); } routeId++; } else if (file.react) { routerLines.push( `$${parentRouteId}.use("/${ routerPathFromName(name) }", defaultRouter.routes(), defaultRouter.allowedMethods())`, ); } } else { const mainRouteId = routeId++; if (main) { if (main.oak) { importLines.push( routerImportLine( mainRouteId, path.posix.join(relativePath, main.oak), ), ); } else { routerLines.push(`const $${mainRouteId} = new Router();`); }
if (main.react) { importLines.push( ...routeImportLines( routeId, path.posix.join(relativePath, main.react), ), ); routerLines.push( `if (($${routeId} as RouteFile).ErrorFallback) {`, ` $${mainRouteId}.use(errorBoundary(${ relativePath ? `"/${relativePath}"` : "" }));`, `}`, ); routeId++; } } else { routerLines.push(`const $${mainRouteId} = new Router();`); if (!relativePath && react) { routerLines.push(`$${mainRouteId}.use(errorBoundary());`); } }
if (index) { if (index.react) { importLines.push( ...routeImportLines( routeId, path.posix.join(relativePath, index.react), ), ); routerLines.push( `if (($${routeId} as RouteFile).ErrorFallback) {`, ` $${mainRouteId}.use("/", errorBoundary("/${ path.join(relativePath, "index") }"));`, `}`, ); routeId++; }
if (index.oak) { importLines.push( routerImportLine( routeId, path.posix.join(relativePath, index.oak), ), );
routerLines.push( `$${mainRouteId}.use("/", $${routeId}.routes(), $${routeId}.allowedMethods());`, ); routeId++; } else if (react) { routerLines.push( `$${mainRouteId}.use("/", defaultRouter.routes(), defaultRouter.allowedMethods())`, ); } }
let notFoundRoute: Route | undefined = undefined; for (const childRoute of Object.values(children ?? {})) { if (childRoute.name === "[...]") { notFoundRoute = childRoute; continue; } const { importLines: childImportLines, routerLines: childRouterLines, nextRouteId, } = routerFileData( mainRouteId, routeId, path.posix.join(relativePath, childRoute.name), childRoute, ); importLines.push(...childImportLines); routerLines.push(...childRouterLines); routeId = nextRouteId; } notFoundRoute;
if (notFoundRoute) { const { importLines: childImportLines, routerLines: childRouterLines, nextRouteId, } = routerFileData( mainRouteId, routeId, path.posix.join(relativePath, notFoundRoute.name), notFoundRoute, ); importLines.push(...childImportLines); routerLines.push(...childRouterLines); routeId = nextRouteId; }
if (relativePath === "") { routerLines.push("", `export default $0;`); } else { routerLines.push( `const $${mainRouteId}Main = ${ parent?.react && !react ? `createApiRouter($${mainRouteId})` : `$${mainRouteId}` };`, ); routerLines.push( `$${parentRouteId}.use("/${ routerPathFromName(name) }", $${mainRouteId}Main.routes(), $${mainRouteId}Main.allowedMethods());`, ); } }
return { importLines, routerLines, nextRouteId: routeId, };}
async function writeRoutes(path: string, text: string) { const fmt = Deno.run({ cmd: ["deno", "fmt", "-"], stdin: "piped", stdout: "piped", }); const encoder = new TextEncoder(); await fmt.stdin.write(encoder.encode(text)); fmt.stdin.close(); const [status, rawOutput] = await Promise.all([fmt.status(), fmt.output()]); if (status.success) { Deno.writeFile(path, rawOutput); } else { console.log("fmt routes failed", { path, ...status }); }}
async function updateRoutes(routesUrl: string, rootRoute: Route) { if (rootRoute.react) { const lines = [ `import { lazy, withAppErrorBoundary, DefaultErrorFallback, NotFound, RouteFile } from "x/udibo_react_app/mod.tsx";`, `import { RouteObject } from "npm/react-router-dom";`, "", ]; const { importLines, routeText } = routeFileData(0, "", rootRoute); lines.push(...importLines, ""); lines.push(`export default ${routeText} as RouteObject;`, "");
await writeRoutes(path.join(routesUrl, "_main.tsx"), lines.join("\n")); }
const lines = [ `import { Router } from "x/oak/mod.ts";`, `import { defaultRouter, createApiRouter, errorBoundary } from "x/udibo_react_app/server.tsx";`, `import { RouteFile } from "x/udibo_react_app/mod.tsx";`, "", ]; const { importLines, routerLines } = routerFileData(-1, 0, "", rootRoute); lines.push(...importLines, "", ...routerLines);
await writeRoutes(path.join(routesUrl, "_main.ts"), lines.join("\n"));}
async function buildRoutes(routesUrl: string) { const appRoute = await generateRoutes(routesUrl); await updateRoutes(routesUrl, appRoute);}
export interface BuildOptions { /** The absolute path to the working directory for your application. */ workingDirectory: string; /** The client entry point for your application relative to the workingDirectory. */ entryPoint: string; /** * Urls for all routes directories that your app uses. * Each routes directory will have 2 files generated in them. * - `_main.ts`: Contains the oak router for the routes. * - `_main.tsx`: Contains the react router routes for controlling navigation in the app. * Your server entrypoint should import and use both of the generated files for each routes directory. * Your client entry point should only import the react router routes. * In most cases, you will only need a single routesUrl. */ routesUrls: string[]; /** * The application will serve all files from this directory. * The build for your client entry point will be stored in public/build. * Test builds will be stored in public/test-build. */ publicUrl: string; /** File url for your import map. */ importMapUrl: string; /** * ESBuild plugins to use when building your application. * These plugins will be added after the deno plugin. */ esbuildPlugins?: esbuild.Plugin[]; /** * Called before building the application. * This can be used to add additional steps before the build starts. */ preBuild?: (() => Promise<void>) | (() => void); /** * Called after building the application. * This can be used to add additional steps after the build is completed. */ postBuild?: (() => Promise<void>) | (() => void);}
/** Builds the application and all of it's routes. */export async function build(options: BuildOptions) { const { preBuild, postBuild } = options;
if (preBuild) await preBuild();
console.log("Building app"); performance.mark("buildStart"); let success = false; try { const { entryPoint, routesUrls, publicUrl, importMapUrl, workingDirectory, } = options; const outdir = path.join( publicUrl, `${isTest() ? "test-" : ""}build`, ); await ensureDir(outdir);
const importMapURL = path.toFileUrl(importMapUrl);
const buildOptions: esbuild.BuildOptions = isProduction() ? { minify: true } : { minifyIdentifiers: false, minifySyntax: true, minifyWhitespace: true, jsxDev: true, sourcemap: "linked", };
for (const routesUrl of routesUrls) { await buildRoutes(routesUrl); }
const esbuildPlugins = options.esbuildPlugins ?? []; await esbuild.build({ plugins: [ denoPlugin({ importMapURL }), ...esbuildPlugins, ], absWorkingDir: workingDirectory, entryPoints: [entryPoint], outdir, bundle: true, splitting: true, treeShaking: true, platform: "neutral", format: "esm", jsx: "automatic", jsxImportSource: "npm/react", ...buildOptions, }); esbuild.stop(); success = true; } catch { // Ignore error, esbuild already logs it } finally { performance.mark("buildEnd"); const measure = performance.measure("build", "buildStart", "buildEnd"); console.log( `Build ${success ? "completed" : "failed"} in ${ Math.round(measure.duration) } ms`, ); if (!success) Deno.exit(1); }
if (postBuild) await postBuild();
return success;}
if (import.meta.main) { const cwd = Deno.cwd(); const entryPoint = "app.tsx"; const publicUrl = path.join(cwd, "public"); const routesUrl = path.join(cwd, "routes"); const importMapUrl = path.join(cwd, "import_map.json");
const success = await build({ workingDirectory: cwd, entryPoint, publicUrl, routesUrls: [routesUrl], importMapUrl, }); if (!success) Deno.exit(1);}