UNPKG

dinou

Version:

Dinou is a modern React 19 framework with React Server Components, Server Functions, and streaming SSR.

364 lines (331 loc) 12 kB
const { readFileSync, writeFileSync, mkdirSync, existsSync } = require("fs"); const path = require("path"); const { dirname } = require("path"); const glob = require("fast-glob"); const { pathToFileURL } = require("url"); const parser = require("@babel/parser"); const traverse = require("@babel/traverse").default; const { regex } = require("../../core/asset-extensions.js"); const createScopedName = require("../../core/createScopedName.js"); const { getAbsPathWithExt } = require("../../core/get-abs-path-with-ext.js"); function reactClientManifestPlugin({ srcDir = path.resolve("src"), manifestPath = "public/react-client-manifest.json", assetInclude = regex, } = {}) { const manifest = {}; const clientModules = new Set(); const serverModules = new Set(); function parseExports(code) { const ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"], }); const exports = new Set(); traverse(ast, { ExportDefaultDeclaration(path) { exports.add("default"); }, ExportNamedDeclaration(path) { if (path.node.declaration) { if ( path.node.declaration.type === "FunctionDeclaration" || path.node.declaration.type === "ClassDeclaration" ) { exports.add(path.node.declaration.id.name); } else if (path.node.declaration.type === "VariableDeclaration") { path.node.declaration.declarations.forEach((decl) => { if (decl.id.type === "Identifier") { exports.add(decl.id.name); } }); } } else if (path.node.specifiers) { path.node.specifiers.forEach((spec) => { if (spec.type === "ExportSpecifier") { exports.add(spec.exported.name); } }); } }, }); return exports; } function updateManifestForModule(absPath, code, isClientModule) { const fileUrl = pathToFileURL(absPath).href; const relPath = "./" + path.relative(process.cwd(), absPath).replace(/\\/g, "/"); for (const key in manifest) { if (key.startsWith(fileUrl)) { delete manifest[key]; } } if (isClientModule) { const exports = parseExports(code); for (const expName of exports) { const manifestKey = expName === "default" ? fileUrl : `${fileUrl}#${expName}`; manifest[manifestKey] = { id: relPath, chunks: expName, name: expName, }; } } } // Updated to async, accepts pluginContext (Rollup's 'this'), resolves aliases/paths via this.resolve async function getImportsAndAssetsAndCsss( code, baseFilePath, visited = new Set(), pluginContext ) { if (visited.has(baseFilePath)) { return { imports: [], assets: [], csss: [] }; } visited.add(baseFilePath); const ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"], }); const imports = new Set(); const assets = new Set(); const csss = new Set(); // Collect all ImportDeclarations first (to await resolves in batch if needed, but sequential is fine) const importNodes = []; traverse(ast, { ImportDeclaration(nodePath) { importNodes.push(nodePath); }, }); for (const nodePath of importNodes) { const source = nodePath.node.source.value; // console.log("source", source); // Resolve the import const absImportPathWithExt = getAbsPathWithExt(source, { parentURL: pathToFileURL(baseFilePath).href, }); if (!absImportPathWithExt) { // console.warn(`[resolve failed] ${source} from ${baseFilePath}`); continue; } // console.log("absImportPath", absImportPath); if ( absImportPathWithExt.endsWith(".css") || absImportPathWithExt.endsWith(".scss") || absImportPathWithExt.endsWith(".less") ) { csss.add(absImportPathWithExt); // Track for watch continue; // Don’t recurse into styles } // Check if it's an asset if (assetInclude.test(absImportPathWithExt)) { assets.add(absImportPathWithExt); continue; // Don't recurse for assets } // Otherwise, it's a code import imports.add(absImportPathWithExt); try { const importCode = readFileSync(absImportPathWithExt, "utf8"); const nested = await getImportsAndAssetsAndCsss( importCode, absImportPathWithExt, visited, pluginContext ); nested.imports.forEach((nestedPath) => imports.add(nestedPath)); nested.assets.forEach((nestedPath) => assets.add(nestedPath)); nested.csss.forEach((nestedPath) => csss.add(nestedPath)); } catch (err) { console.warn( `[react-client-manifest] Could not read import: ${absImportPathWithExt}`, err.message ); } } return { imports: Array.from(imports), assets: Array.from(assets), csss: Array.from(csss), }; } // New helper to emit a single asset (used in buildStart and watchChange) function emitAsset(absAssetPath, pluginContext) { const source = readFileSync(absAssetPath); const base = path.basename(absAssetPath, path.extname(absAssetPath)); const scoped = createScopedName(base, absAssetPath); const ext = path.extname(absAssetPath); const fileName = `assets/${scoped}${ext}`; pluginContext.emitFile({ type: "asset", fileName, source, }); } function isPageOrLayout(absPath) { const fileName = path.basename(absPath); return fileName.startsWith("page.") || fileName.startsWith("layout."); } function isAsyncDefaultExport(code) { const ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"], }); let isAsync = false; traverse(ast, { ExportDefaultDeclaration(path) { let decl = path.node.declaration; if (decl.type === "Identifier") { const binding = path.scope.getBinding(decl.name); if (binding && binding.path) { decl = binding.path.node; if (decl.type === "VariableDeclarator") { decl = decl.init; } } } if ( decl && (decl.type === "FunctionDeclaration" || decl.type === "ArrowFunctionExpression" || decl.type === "FunctionExpression") ) { isAsync = decl.async; } }, }); return isAsync; } return { name: "react-client-manifest", async buildStart() { const files = await glob(["**/*.{js,jsx,ts,tsx}"], { cwd: srcDir, absolute: true, }); for (const absPath of files) { const code = readFileSync(absPath, "utf8"); const normalizedPath = absPath.split(path.sep).join(path.posix.sep); const isClientModule = /^(['"])use client\1/.test(code.trim()); if (isClientModule) { clientModules.add(normalizedPath); updateManifestForModule(absPath, code, true); this.emitFile({ type: "chunk", id: absPath, name: path.basename(absPath, path.extname(absPath)), }); } else if (isPageOrLayout(absPath)) { if (!isAsyncDefaultExport(code)) { this.warn( `[react-client-manifest] The file ${normalizedPath} is a page or layout without "use client" directive, but its default export is not an async function. Add "use client" if it's a client component, or make the default export async if it's a server component.` ); } serverModules.add(normalizedPath); this.addWatchFile(absPath); const { imports, assets, csss } = await getImportsAndAssetsAndCsss( code, absPath, new Set(), this ); // console.log("assets", assets); for (const importPath of imports) { this.addWatchFile(importPath); } // Emit assets for server components (replicate dinouAssetPlugin logic) for (const assetPath of assets) { this.addWatchFile(assetPath); emitAsset(assetPath, this); // Emit assets } for (const cssPath of csss) { this.addWatchFile(cssPath); // Emit CSS as a Rollup asset so postcss() processes it this.emitFile({ type: "chunk", id: cssPath, name: path.basename(cssPath, path.extname(cssPath)), }); } } } }, async watchChange(id) { if ( !id.endsWith(".tsx") && !id.endsWith(".jsx") && !id.endsWith(".js") && !id.endsWith(".ts") ) return; const normalizedId = id.split(path.sep).join(path.posix.sep); if (!existsSync(id)) { const fileUrl = pathToFileURL(id).href; for (const key in manifest) { if (key.startsWith(fileUrl)) { delete manifest[key]; } } clientModules.delete(normalizedId); serverModules.delete(normalizedId); return; } const code = readFileSync(id, "utf8"); const isClientModule = /^(['"])use client\1/.test(code.trim()); updateManifestForModule(id, code, isClientModule); if (isClientModule) { clientModules.add(normalizedId); serverModules.delete(normalizedId); this.addWatchFile(id); } else { clientModules.delete(normalizedId); if (isPageOrLayout(id)) { if (!isAsyncDefaultExport(code)) { this.warn( `[react-client-manifest] The file ${normalizedId} is a page or layout without "use client" directive, but its default export is not an async function. Add "use client" if it's a client component, or make the default export async if it's a server component.` ); } serverModules.add(normalizedId); this.addWatchFile(id); const { imports, assets, csss } = await getImportsAndAssetsAndCsss( code, id, new Set(), this ); for (const importPath of imports) { this.addWatchFile(importPath); } // console.log("assets", assets); for (const assetPath of assets) { this.addWatchFile(assetPath); // emitAsset(assetPath, this); // Re-emit assets on server file change } for (const cssPath of csss) { this.addWatchFile(cssPath); } } else { serverModules.delete(normalizedId); } } }, generateBundle(outputOptions, bundle) { for (const [fileName, chunk] of Object.entries(bundle)) { if (chunk.type !== "chunk") continue; for (const modulePath of Object.keys(chunk.modules)) { const absModulePath = path.resolve(modulePath); const baseFileUrl = pathToFileURL(absModulePath).href; for (const manifestKey in manifest) { if (manifestKey.startsWith(baseFileUrl)) { manifest[manifestKey].id = "/" + fileName; } } } } const serialized = JSON.stringify(manifest, null, 2); mkdirSync(dirname(manifestPath), { recursive: true }); writeFileSync(manifestPath, serialized); }, }; } module.exports = reactClientManifestPlugin;