UNPKG

@msom/xbuild

Version:

@msom/xbuild

521 lines (492 loc) 15.7 kB
import { assert, nil } from "@msom/common"; import { ProxyRules, createServer, staticMiddle } from "@msom/http"; import * as fs from "fs"; import { JSDOM } from "jsdom"; import path from "path"; import { ExternalOption, OutputAsset, OutputChunk, OutputOptions, RolldownBuild, RolldownOptions, RolldownPluginOption, rolldown, } from "rolldown"; import postcss from "rollup-plugin-postcss"; import { Only, StringOrRegExp, getModuleName } from "../utils/common"; import { Logger } from "../utils/logger"; import { PluginManager } from "./plugin"; import { LoadedXbuildConfig, XBuildContext, XBuildOutputOptions, XbuildDevOptions, } from "./types"; import { htmlEntryPlugin } from "./htmlEntryPlugin"; const defaultRolldownPlugins: RolldownPluginOption<unknown>[] = [ postcss({ extract: true, sourceMap: true, }), htmlEntryPlugin(), ]; /** * 打包工具相关依赖 */ const defaultRolldownExternal = [ /^@rolldown\//, /^rolldown/, /^@babel\//, /^@rollup\//, /rollup/, /^http/, "net", "autoprefixer", "chalk", "commander", "less", "postcss", "rolldown", "typescript", "jsdom", "fs", "path", "url", "rollup-plugin-postcss", ]; enum FileLikeType { File = "file", Directory = "directory", } interface IFile { name: string; path: string; type: FileLikeType.File; } interface IDirectory extends Omit<IFile, "type"> { type: FileLikeType.Directory; children: Tree; } type Tree = (IFile | IDirectory)[]; export class XBuilder { private config: XBuildContext; private logger: Logger = new Logger("Builder"); constructor(config: XBuildContext) { this.config = config; } get pluginManager() { return new PluginManager(this.config?.pluginManager || []); } private buildHtml(filePath: string) { const fileContent = fs.readFileSync(path.resolve(filePath), "utf-8"); const html = new JSDOM(fileContent); const script = html.window.document.querySelector( "script#script-main[type=module]" ) as HTMLScriptElement; if (!script) { return filePath; } else { const src = script.src; const changeString = ( str: string, splitor: string, replacer: ((old: string) => string) | string ) => { const _replacer = typeof replacer === "function" ? replacer : () => replacer; return str .split(splitor) .map((v, i, a) => (i === a.length - 1 ? _replacer(v) : v)) .join(splitor); }; const fileName = changeString(src.split("/").pop()!, "/", (v) => changeString(v, ".", "js") ); script.src = "./" + fileName; const { output } = this.rolldownOptions; const dir = [output].flat().reduce((a, b) => b?.dir || a, "./dist"); const htmlName = filePath.split("/").pop(); assert(htmlName); this.write(path.resolve(dir, htmlName), html.serialize(), "utf-8"); return src; } } private async generate(bundle: RolldownBuild, output: OutputOptions) { const chunk = await bundle.generate(output).then((v) => v.output); const res = this.pluginManager.apply( "transform", chunk[0].code, chunk[0].fileName, chunk[0].map ); Object.assign(chunk[0], res); return chunk; } get rolldownOptions(): RolldownOptions { const { config } = this; if (!config.build) { return { input: "./index.html", plugins: [...defaultRolldownPlugins], output: [ { dir: "./dist", format: "esm", }, ], }; } else { const options = {} as RolldownOptions; Object.assign(options, config.build); options.output = [ options.output as Exclude< Exclude<LoadedXbuildConfig["build"], undefined>["output"], undefined >, ] .flat() .filter(Boolean) .map((out: XBuildOutputOptions) => { return { ...out, chunkFileNames: out.chunkFileNames && ((info) => { const name = typeof out.chunkFileNames === "function" ? out.chunkFileNames(info, out.format || "esm") : out.chunkFileNames; return name || info.name; }), }; }); options.plugins = [...defaultRolldownPlugins].concat( [config.build?.plugins].flat().filter(Boolean) ); options.external = [ ...defaultRolldownExternal, ...([config.build.external].flat() as ( | StringOrRegExp | Only<ExternalOption, Function> )[]), ]; return options; } } private write( filePath: string, data: string | NodeJS.ArrayBufferView, options: fs.WriteFileOptions ) { let _options = options as Exclude<typeof options, string | null>; if (typeof options === "string") { _options = { encoding: options, }; } else if (options === null) { _options = {}; } const dirpath = path.dirname(filePath); if (!fs.existsSync(dirpath)) { fs.mkdirSync(dirpath, { recursive: true }); } return new Promise<void>((resolve, reject) => { fs.writeFile( filePath, data, { encoding: "utf-8", ..._options, }, (error) => { if (!error) { resolve(); } else { reject(error); } } ); if (_options.signal) { _options.signal.addEventListener("abort", () => { if (fs.existsSync(dirpath)) { fs.rmSync(dirpath, { recursive: true }); } }); } }); } async runBuild() { try { const { output, ...options } = this.rolldownOptions; if (options.input) { const inputs: string[] = []; for (const input of [options.input as string | string[]].flat()) { if (!input.endsWith(".html")) { inputs.push(input); } else { inputs.push(this.buildHtml(input)); } } options.input = inputs; if (inputs.length === 0) { this.logger.success("Build completed successfully"); return true; } } const inputs = options.input as string[]; for (const input of inputs) { await this.buildOne(input); } this.logger.success("Build completed successfully"); return true; } catch (error) { this.logger.error("Build failed", error); return false; } } private async buildOne(input: string) { const { input: _, output, ...options } = this.rolldownOptions; const bundle = await rolldown({ ...options, input }); // 每个output都打包一次 const promiseResults = [output] .flat() .filter(Boolean) .map(async (output: OutputOptions) => { const bundled = await this.generate(bundle, output); return { output, chunks: bundled.filter((v) => v.type === "chunk") as OutputChunk[], assets: bundled.filter((v) => v.type === "asset") as OutputAsset[], }; }); // 开始写入文件 // 等待打包完成 const result = await Promise.all(promiseResults); const writes: Promise<unknown>[] = []; const abortController = new AbortController(); const write = (writePath: string, content: string) => { writes.push( this.write(writePath, content, { encoding: "utf-8", signal: abortController.signal, }) ); }; for (const { output, chunks, assets } of result) { const { dir = "./", chunkFileNames } = output; // 当有文件写入失败后停止写入其他文件 abortController.signal.addEventListener("abort", () => { if (dir === "./") return; const dirPath = path.resolve(dir); if (fs.existsSync(dirPath)) { fs.rmSync(dirPath, { recursive: true }); } }); // 写chunk chunks.forEach( ({ fileName, facadeModuleId, code, sourcemapFileName, map, ...option }) => { fileName = nil( typeof chunkFileNames === "function" ? chunkFileNames({ ...option, facadeModuleId: facadeModuleId || "", name: fileName, }) : chunkFileNames, fileName ); write(path.resolve(dir, fileName), code); if (map && sourcemapFileName) { sourcemapFileName = path.resolve( path.dirname(path.resolve(dir, sourcemapFileName)), fileName + ".map" ); write(sourcemapFileName, map.toString()); } } ); // 写静态资源 assets.forEach(({ source, fileName }) => { source && fileName && write(path.resolve(dir, fileName), source.toString()); }); } let error: any; await Promise.all(writes) .catch((e) => { error = e; abortController.abort(); }) .finally(() => { return bundle.close(); }); if (error) { throw error; } } get defaultDevOption() { return { port: 9999, public: "public" } as const; } private getDevOptions( option: XbuildDevOptions ): Required<Omit<XbuildDevOptions, "proxy">> & Pick<XbuildDevOptions, "proxy"> { const dev = this.config.dev || {}; const options = { ...this.defaultDevOption, ...dev, ...option, }; Object.keys(this.defaultDevOption).forEach((key) => { options[key] = nil(options[key], this.defaultDevOption[key]); }); return options; } async runDev(options: XbuildDevOptions) { // 启动服务器 let promiseHandle: ((data: any) => void)[] = []; const option = this.getDevOptions(options); try { createServer(option.port, { middles: [ staticMiddle(path.resolve(process.cwd(), option.public)), staticMiddle(path.resolve("../../template/dist")), staticMiddle(path.resolve("../../template")), ], routes: [ { path: "/demo", method: "get", handlers: [ async (request, response) => { const { modulePath } = request.query as { modulePath?: string }; if (!modulePath) { response.sendStatus(404); return; } const { output, ...options } = this.rolldownOptions; options.input = path.resolve("src", modulePath); if (!fs.existsSync(options.input)) { response.sendStatus(404); return; } const bundle = await rolldown(options); const bundleCode = ( await bundle.generate({ format: "esm", file: "index.js", sourcemap: false, }) ).output[0].code; response.send(` <!DOCTYPE html> <html> <head> <title>Component Preview - ${getModuleName( modulePath )}</title> </head> <body> <div id="root"></div> <script type="module" src=""> ${bundleCode} <\/script> </body> </html> `); }, ], children: [ { path: "/file-tree", method: "get", handlers: [ (request, response) => { // 在什么地方运行,root就是什么路径 const src = path.resolve(process.cwd(), "src"); /** * 构建符合 Tree 类型的文件结构 * @param rootDir 扫描根目录 * @returns 符合 Tree 类型的文件结构 */ function buildFileTree(rootPath: string): Tree { if (!fs.existsSync(rootPath)) return []; function buildTree( currentDir: string, relativePath: string ): Tree { const items = fs.readdirSync(currentDir, { withFileTypes: true, }); const dirs: Tree = []; const files: Tree = []; for (const item of items) { const itemPath = path.join(currentDir, item.name); const itemRelativePath = relativePath ? `${relativePath}/${item.name}` : item.name; if (item.isDirectory()) { const children = buildTree( itemPath, itemRelativePath ); if (children.length > 0) { dirs.push({ name: item.name, path: itemRelativePath, type: FileLikeType.Directory, children, }); } } else if ( item.isFile() && /.*\.(demo|dev)\.tsx?$/.test(item.name) ) { files.push({ name: item.name, path: itemRelativePath, type: FileLikeType.File, }); } } // 对目录和文件分别按名称排序 dirs.sort((a, b) => a.name.localeCompare(b.name)); files.sort((a, b) => a.name.localeCompare(b.name)); // 目录在前,文件在后 return [...dirs, ...files]; } return buildTree(rootPath, ""); } const res = buildFileTree(src); response.status(200).json(res); }, ], }, ], }, ], proxy: option.proxy, printProxy: false, createHandle: ({ port }) => { promiseHandle[0]({ port, proxy: option.proxy }); }, }); } catch (e) { console.log(e); } return new Promise<{ port: number; proxy: ProxyRules | undefined | null }>( (...args) => { promiseHandle = args; } ); } }