UNPKG

sxo

Version:

SXO — Minimal server-side JSX framework and CLI. Directory-based routing, hot reload, dual esbuild build outputs (client/server) and a Rust/WASM JSX transformer.

270 lines (237 loc) 8.51 kB
import fs from "node:fs"; import path from "node:path"; import { CLIENT_DIR, OUTPUT_DIR_SERVER, PAGES_DIR, PAGES_RELATIVE_DIR } from "../constants.js"; /** * Build route configs for esbuild htmlPlugin. * - Single-pass traversal (iterative) * - Optional reuse of routes.json if valid * - Only index.* pages (excluding any found in the configured client subdirectory) become routes * - Optional <clientDir>/index.* (precedence: .ts, .tsx, .js, .jsx) prepended to entryPoints * - global.css appended if present * - No configured client subdirectory prefix in output filenames */ export function entryPointsConfig() { const htmlTemplate = readHtmlTemplate(); const globalCss = resolveGlobalCss(); const cachePath = resolveRoutesCachePath(); const cwd = process.cwd(); if (cachePath && fsExists(cachePath)) { const reused = tryReuseCachedRoutes(cachePath, htmlTemplate, globalCss); if (reused) return reused; } const metaMap = scanPagesTree(); const routes = assembleRoutes(metaMap, { htmlTemplate, globalCss, cwd, }); return routes; } /* -------------------------------- Constants ------------------------------- */ const PAGE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"]; const CLIENT_EXT_PRECEDENCE = [".ts", ".tsx", ".js", ".jsx"]; const ROUTES_CACHE_FILENAME = "routes.json"; /* ------------------------------ Core Helpers ------------------------------ */ function readHtmlTemplate() { const rootHtmlPath = path.join(PAGES_DIR, "index.html"); try { return fs.readFileSync(rootHtmlPath, "utf8"); } catch { throw new Error(`The index.html template not found in ${PAGES_DIR}`); } } function resolveGlobalCss() { const rel = `${PAGES_RELATIVE_DIR}/global.css`; const abs = path.join(process.cwd(), rel); return fsExists(abs) ? rel : null; } /** * Attempt to reuse cached routes. * Valid if: * - Each route.jsx still exists * - No new page index.* (excluding configured client subdirectory indexes) appeared */ function tryReuseCachedRoutes(cachePath, htmlTemplate, globalCss) { let cached; try { cached = JSON.parse(fs.readFileSync(cachePath, "utf8")); } catch { return null; } if (!Array.isArray(cached)) return null; const jsxSet = new Set(); for (const r of cached) { if (!r || typeof r !== "object" || typeof r.jsx !== "string") return null; const abs = path.join(process.cwd(), r.jsx); if (!fsExists(abs)) return null; jsxSet.add(normalizePath(r.jsx)); } if (detectNewPagesNotInSet(jsxSet)) return null; // Rehydrate template + global.css presence for (const r of cached) { r.htmlTemplate = htmlTemplate; if (Array.isArray(r.entryPoints)) { const filtered = r.entryPoints.filter((ep) => !/[/\\]global\.css$/.test(ep)); if (globalCss) filtered.push(globalCss); r.entryPoints = filtered; } else { r.entryPoints = globalCss ? [globalCss] : []; } r.scriptLoading = "module"; // AIDEV-NOTE: Refresh hash flag when reusing cached routes so dev/build mode reflects current process.env r.hash = process.env.DEV === "true"; } return cached; } /** * Scan PAGES_DIR for new page index.* not in jsxSet. * Ignore configured client subdirectory indexes. */ function detectNewPagesNotInSet(jsxSet) { const stack = [PAGES_DIR]; while (stack.length) { const dir = stack.pop(); let dirents; try { dirents = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const d of dirents) { if (d.isDirectory()) { if (d.name === CLIENT_DIR) continue; // ignore client subtree stack.push(path.join(dir, d.name)); } else if (d.isFile()) { const ext = path.extname(d.name); if (PAGE_EXTENSIONS.includes(ext) && isIndexFile(d.name)) { const full = path.join(dir, d.name); const rel = normalizePath(path.relative(process.cwd(), full)); if (!jsxSet.has(rel)) { return true; } } } } } return false; } function isIndexFile(name) { return /^index\.(tsx|ts|jsx|js)$/.test(name); } /** * Single-pass scan: * - Do NOT traverse into the configured client subdirectory (it never produces routes). * - For each non-client directory with index.* => page * - Look for <clientDir>/index.* (precedence order) in the sibling configured subdirectory */ function scanPagesTree() { const metaMap = new Map(); // Map<absDir, { pageIndex: string, clientEntry: string|null }> const stack = [PAGES_DIR]; while (stack.length) { const dir = stack.pop(); let dirents; try { dirents = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } // Skip if the directory itself is a client subdir if (path.basename(dir) === CLIENT_DIR) { continue; } // Find page index.* (if any) let pageIndex = null; for (const d of dirents) { if (d.isFile()) { const ext = path.extname(d.name); if (!pageIndex && PAGE_EXTENSIONS.includes(ext) && isIndexFile(d.name)) { pageIndex = path.join(dir, d.name); } } } // Queue subdirectories (excluding client/) for (const d of dirents) { if (d.isDirectory()) { if (d.name === CLIENT_DIR) continue; // we'll inspect it separately for client entry if (d.isSymbolicLink()) continue; // skip symlinks stack.push(path.join(dir, d.name)); } } if (pageIndex) { const clientEntry = findClientEntry(dir); metaMap.set(dir, { pageIndex, clientEntry }); } } return metaMap; } /** * Locate first matching <clientDir>/index.* according to CLIENT_EXT_PRECEDENCE. * Returns absolute path or null. */ function findClientEntry(parentDir) { const clientDir = path.join(parentDir, CLIENT_DIR); try { const stat = fs.statSync(clientDir); if (!stat.isDirectory()) return null; } catch { return null; } // Equivalent to original loop you referenced (adapted precedence) for (const ext of CLIENT_EXT_PRECEDENCE) { const candidate = path.join(clientDir, `index${ext}`); if (fsExists(candidate)) return candidate; } return null; } /** * Turn metadata into final route objects. */ function assembleRoutes(metaMap, { htmlTemplate, globalCss, cwd }) { const routes = []; for (const [absDir, { pageIndex, clientEntry }] of metaMap.entries()) { const relDir = path.relative(PAGES_DIR, absDir); const isRoot = relDir === "" || relDir === "."; const pageBase = `${path.basename(pageIndex).replace(/\.(tsx|ts|jsx|js)$/, "")}.html`; const filename = isRoot ? pageBase : path.join(relDir, pageBase); const entryPoints = []; if (clientEntry) { entryPoints.push(normalizePath(path.relative(cwd, clientEntry))); } if (globalCss) { entryPoints.push(globalCss); } const jsxRel = normalizePath(path.relative(cwd, pageIndex)); const route = { filename: normalizePath(filename), entryPoints, jsx: jsxRel, htmlTemplate, scriptLoading: "module", hash: process.env.DEV === "true", // AIDEV-NOTE: boolean flag (true in dev) enabling htmlPlugin cache-busting hash }; if (!isRoot) { route.path = normalizePath(relDir); } routes.push(route); } return routes; } /** * Resolve routes.json path if OUTPUT_DIR_SERVER defined. */ function resolveRoutesCachePath() { if (!OUTPUT_DIR_SERVER) return null; return path.join(OUTPUT_DIR_SERVER, ROUTES_CACHE_FILENAME); } /* ------------------------------ Small Helpers ----------------------------- */ function fsExists(p) { try { fs.accessSync(p, fs.constants.F_OK); return true; } catch { return false; } } function normalizePath(p) { return p.replace(/\\/g, "/"); }