UNPKG

@msom/xbuild

Version:

@msom/xbuild

781 lines (769 loc) 26.6 kB
#!/usr/bin/env node import { assert, isPromiseLike, nil } from "@msom/common"; import { createServer, printAlignedProxyServerInfo, staticMiddle } from "@msom/http"; import * as fs$1 from "fs"; import fs from "fs"; import { JSDOM } from "jsdom"; import path from "path"; import { rolldown } from "rolldown"; import postcss from "rollup-plugin-postcss"; import { pathToFileURL } from "url"; import chalk from "chalk"; import { types } from "@babel/core"; import generate from "@babel/generator"; import { parse } from "@babel/parser"; import traverse from "@babel/traverse"; import { program } from "commander"; //#region src/utils/common.ts function toFileUrl(filePath) { return pathToFileURL(path.resolve(filePath)).href; } function getModuleName(path$1) { return path$1.split("/").pop() || ""; } //#endregion //#region src/utils/logger.ts const colorMap = { info: { prefix: "blue", message: "gray" }, warn: { prefix: "yellow", message: "yellow" }, error: { prefix: "red", message: "red" }, success: { prefix: "green", message: "green" } }; var Logger = class { prefix; constructor(prefix) { this.prefix = prefix; Object.defineProperties(this, Object.keys(colorMap).reduce((map, key) => { map[key] = { value: function (message, ...args) { this.log(key, message, ...args); } }; return map; }, {})); } log(level, message, ...args) { const timestamp = (/* @__PURE__ */new Date()).toLocaleTimeString(); let prefix = `[${timestamp}] ${this.prefix}:`; prefix = chalk[colorMap[level].prefix](prefix); message = chalk[colorMap[level].message](message); console.log(`${prefix} ${message}`, ...args); } info; warn; error; success; progress(message, current, total) { const percent = Math.round(current / total * 100); const progressBar = `[${"=".repeat(percent / 5)}${" ".repeat(20 - percent / 5)}]`; this.info(`${message} ${progressBar} ${percent}% (${current}/${total})`); } }; //#endregion //#region src/core/plugin.ts var PluginError = class extends Error { constructor(pluginName, message, errorOption) { super(`[xbuild-plugin:${pluginName}]: ${message}`, errorOption); } }; var PluginManager = class PluginManager { constructor(plugins) { if (plugins instanceof PluginManager) return plugins;else this.plugins = plugins || []; this.plugins = this.plugins.filter(Boolean); this.pluginMap = /* @__PURE__ */new Map(); this.checkPluginName(); } addPlugins(plugins = []) { const n = this.plugins.push(...plugins); this.checkPluginName(); return n; } addPlugin(...plugins) { return this.addPlugins(plugins); } get sortPlugins() { const notOrder = this.plugins.filter(({ order }) => typeof order === "undefined"); const sorted = this.plugins.filter(({ order }) => typeof order !== "undefined").sort((a, b) => a.order - b.order); return [...sorted, ...notOrder]; } checkPluginName() { this.pluginMap.clear(); try { for (const plugin of this.plugins) { if (this.pluginMap.has(plugin.name)) throw new PluginError(plugin.name, "the name of plugin is exist."); this.pluginMap.set(plugin.name, plugin); } } catch (e) { this.pluginMap.clear(); this.plugins.length = 0; throw e; } } transform(code, id, source) { let pluginName = ""; let _source = source; try { for (const plugin of this.sortPlugins) { pluginName = plugin.name; if (plugin.transform) { let recode = plugin.transform(code, id, _source); recode = typeof recode === "string" ? { code: recode, map: null } : recode; code = recode.code; _source = recode.map || _source; } } return { code, map: _source || source }; } catch (e) { throw new PluginError(pluginName, e instanceof Error ? e.message : String(e)); } } apply(hook, ...args) { return this[hook].apply(this, args); } }; //#endregion //#region src/core/htmlEntryPlugin.ts const htmlEntryPlugin = () => { let htmlContent = ""; const processedAssets = /* @__PURE__ */new Set(); return { name: "html-entry-plugin", async buildStart(options) { const input = [options.input].flat()[0]; if (typeof input === "object" || !input.endsWith(".html")) return; const htmlPath = path.resolve(process.cwd(), input); console.log(htmlPath); htmlContent = fs.readFileSync(htmlPath, "utf-8"); const scriptRegex = /<script\s+[^>]*src\s*=\s*['"]([^'"]+\.(js|ts|jsx|tsx))['"][^>]*>/gi; const scripts = []; let match; while ((match = scriptRegex.exec(htmlContent)) !== null) { const relativePath = match[1]; const scriptPath = path.resolve(path.dirname(htmlPath), relativePath); scripts.push(scriptPath); } if (scripts.length > 0) { this.emitFile({ type: "chunk", id: scripts[0], fileName: path.basename(scripts[0]) }); for (let i = 1; i < scripts.length; i++) this.emitFile({ type: "chunk", id: scripts[i] }); } }, async generateBundle(outputOptions, bundle) { if (!htmlContent) return; const headInjection = []; const cssLinks = []; const jsScripts = []; const assetTypeMap = { ".css": "style", ".woff2": "font", ".woff": "font", ".ttf": "font", ".eot": "font", ".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image" }; for (const [fileName, assetInfo] of Object.entries(bundle)) { if (processedAssets.has(fileName)) continue; processedAssets.add(fileName); const ext = path.extname(fileName).toLowerCase(); if (ext === ".css") cssLinks.push(`<link rel="stylesheet" href="${fileName}">`);else if (ext === ".js" && assetInfo.type === "chunk" && !assetInfo.isEntry) jsScripts.push(`<script src="${fileName}" defer><\/script>`);else if (assetTypeMap[ext]) headInjection.push(`<link rel="preload" href="${fileName}" as="${assetTypeMap[ext]}" crossorigin>`); } let updatedHtml = htmlContent; updatedHtml = updatedHtml.replace(/<script\s+[^>]*src\s*=\s*['"]([^'"]+\.(js|ts|jsx|tsx))['"][^>]*>/gi, (match, src) => { const outputFile = Object.keys(bundle).find((name) => name.startsWith(path.basename(src, path.extname(src)))); return outputFile ? match.replace(src, outputFile).replace(/(type\s*=\s*['"])[^'"]*['"]/i, "type=\"module\"") : match; }); updatedHtml = updatedHtml.replace(/<\/head>/i, `${cssLinks.join("\n")}\n${headInjection.join("\n")}\n</head>`); updatedHtml = updatedHtml.replace(/<\/body>/i, `${jsScripts.join("\n")}\n</body>`); this.emitFile({ type: "asset", fileName: "index.html", source: updatedHtml }); } }; }; //#endregion //#region src/core/builder.ts const defaultRolldownPlugins = [postcss({ extract: true, sourceMap: true }), htmlEntryPlugin()]; /** * 打包工具相关依赖 */ const defaultRolldownExternal = [ /^@rolldown\//, /^rolldown/, /^@babel\//, /^@rollup\//, /rollup/, /^http/, /^@web\//, "net", "autoprefixer", "chalk", "commander", "less", "postcss", "rolldown", "typescript", "jsdom", "fs", "path", "url", "rollup-plugin-postcss"]; var FileLikeType; (function (FileLikeType$1) { FileLikeType$1["File"] = "file"; FileLikeType$1["Directory"] = "directory"; })(FileLikeType || (FileLikeType = {})); var XBuilder = class { config; logger = new Logger("Builder"); constructor(config) { this.config = config; } get pluginManager() { return new PluginManager(this.config?.pluginManager || []); } buildHtml(filePath) { const fileContent = fs$1.readFileSync(path.resolve(filePath), "utf-8"); const html = new JSDOM(fileContent); const script = html.window.document.querySelector("script#script-main[type=module]"); if (!script) return filePath;else { const src = script.src; const changeString = (str, splitor, replacer) => { 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; } } async generate(bundle, output) { 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() { const { config } = this; if (!config.build) return { input: "./index.html", plugins: [...defaultRolldownPlugins], output: [{ dir: "./dist", format: "esm" }] };else { const options = {}; Object.assign(options, config.build); options.output = [options.output].flat().filter(Boolean).map((out) => { 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()]; return options; } } write(filePath, data, options) { let _options = options; if (typeof options === "string") _options = { encoding: options };else if (options === null) _options = {}; const dirpath = path.dirname(filePath); if (!fs$1.existsSync(dirpath)) fs$1.mkdirSync(dirpath, { recursive: true }); return new Promise((resolve, reject) => { fs$1.writeFile(filePath, data, { encoding: "utf-8", ..._options }, (error) => { if (!error) resolve();else reject(error); }); if (_options.signal) _options.signal.addEventListener("abort", () => { if (fs$1.existsSync(dirpath)) fs$1.rmSync(dirpath, { recursive: true }); }); }); } async runBuild() { try { const { output, ...options } = this.rolldownOptions; if (options.input) { const inputs$1 = []; for (const input of [options.input].flat()) if (!input.endsWith(".html")) inputs$1.push(input);else inputs$1.push(this.buildHtml(input)); options.input = inputs$1; if (inputs$1.length === 0) { this.logger.success("Build completed successfully"); return true; } } const inputs = options.input; 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; } } async buildOne(input) { const { input: _, output, ...options } = this.rolldownOptions; const bundle = await rolldown({ ...options, input }); const promiseResults = [output].flat().filter(Boolean).map(async (output$1) => { const bundled = await this.generate(bundle, output$1); return { output: output$1, chunks: bundled.filter((v) => v.type === "chunk"), assets: bundled.filter((v) => v.type === "asset") }; }); const result = await Promise.all(promiseResults); const writes = []; const abortController = new AbortController(); const write = (writePath, content) => { writes.push(this.write(writePath, content, { encoding: "utf-8", signal: abortController.signal })); }; for (const { output: output$1, chunks, assets } of result) { const { dir = "./", chunkFileNames } = output$1; abortController.signal.addEventListener("abort", () => { if (dir === "./") return; const dirPath = path.resolve(dir); if (fs$1.existsSync(dirPath)) fs$1.rmSync(dirPath, { recursive: true }); }); 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; 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" }; } getDevOptions(option) { 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) { let promiseHandle = []; const option = this.getDevOptions(options); try { console.log(path.resolve("./")); console.log(path.relative(path.resolve("./"), path.resolve("../../dist"))); createServer(option.port, { middles: [staticMiddle(path.resolve(process.cwd(), option.public)), staticMiddle(path.resolve("../dist"))], routes: [{ path: "/demo", method: "get", handlers: [async (request, response) => { const { modulePath } = request.query; if (!modulePath) { response.sendStatus(404); return; } const { output, ...options$1 } = this.rolldownOptions; options$1.input = path.resolve("src", modulePath); if (!fs$1.existsSync(options$1.input)) { response.sendStatus(404); return; } const bundle = await rolldown(options$1); 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) => { const src = path.resolve(process.cwd(), "src"); /** * 构建符合 Tree 类型的文件结构 * @param rootDir 扫描根目录 * @returns 符合 Tree 类型的文件结构 */ function buildFileTree(rootPath) { if (!fs$1.existsSync(rootPath)) return []; function buildTree(currentDir, relativePath) { const items = fs$1.readdirSync(currentDir, { withFileTypes: true }); const dirs = []; const files = []; 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((...args) => { promiseHandle = args; }); } }; //#endregion //#region src/utils/env.ts const XBUILD_ENV = "XBUILD_ENV"; var XBuildENV = class { constructor(envKey) { this.envKey = envKey; } get env() { return Reflect.get(process.env, this.envKey, process.env); } to(mode) { Object.assign(process.env, { [this.envKey]: mode }); } reset() { Reflect.deleteProperty(process.env, this.envKey); } }; let inst; const _XbuildEnv = new new Proxy(XBuildENV, { construct(target, argArray, newTarget) { if (!inst) inst = new XBuildENV(argArray[0]); return inst; } })(XBUILD_ENV); //#endregion //#region src/utils/config.ts function getDefault(d) { const _default = Reflect.get(d, "default", d); if (_default) return _default;else return d; } function __decoratorHandle() { return { name: "class-decorator-first", transform(code, id) { try { const ast = parse(code, { sourceType: "module", plugins: ["decorators-legacy", "typescript"] }); getDefault(traverse)(ast, { Program(path$1) { const programBody = path$1.node.body; if (!Array.isArray(programBody)) return; const decorators = []; let changed = false; const newBody = []; const cleanDecorators = () => { if (decorators.length) { newBody.push(...decorators); decorators.length = 0; } }; programBody.forEach((node) => { if (types.isExpressionStatement(node)) { const expression = node.expression; if (types.isCallExpression(expression)) { const callee = expression.callee; if (types.isIdentifier(callee) && callee.name === "__decorate") { decorators.push(node); changed = true; return; } } if (types.isAssignmentExpression(expression)) { const right = expression.right; if (types.isCallExpression(right)) { const callee = right.callee; if (types.isIdentifier(callee) && callee.name === "__decorate") { newBody.push(node, ...decorators); decorators.length = 0; changed = true; return; } } } } cleanDecorators(); newBody.push(node); }); cleanDecorators(); changed && (path$1.node.body = newBody); } }); const output = getDefault(generate)(ast, { retainLines: true }, code); return { code: output.code, map: output.map }; } catch (err) { console.error(`[class-decorator-first] Error processing ${id}:`, err); return code; } } }; } const logger = new Logger("Config"); async function loadConfig(userConfigPath, options) { const configPath = findConfigFile(userConfigPath); const finalConfig = { pluginManager: new PluginManager() }; if (!configPath) { logger.warn("No config file found, using default configuration"); return finalConfig; } try { logger.info(`Loading configuration from ${toFileUrl(configPath)}`); const tmpConfigPath = ".xbuild.config.mjs"; await (await rolldown({ input: configPath, external(id, parentId, isResolved) { return true; } })).write({ file: tmpConfigPath, format: "esm" }); const userConfigModule = await import(toFileUrl(path.resolve(tmpConfigPath))).finally(() => { fs.rmSync(path.resolve(tmpConfigPath), { recursive: true }); }); const userConfig = userConfigModule.default || userConfigModule; const resolvedConfig = await Promise.resolve(typeof userConfig === "function" ? userConfig({ mode: _XbuildEnv.env || "production" }) : userConfig); Object.assign(finalConfig, resolvedConfig); finalConfig.pluginManager.addPlugins(resolvedConfig.plugins?.filter(({ name }) => { if (!options?.compile) return true;else return name !== "typescript"; })); finalConfig.pluginManager.addPlugin(__decoratorHandle()); if (finalConfig.build?.plugins) { let plugins = finalConfig.build.plugins; if (isPromiseLike(plugins)) plugins = await plugins; const pluginsHandle = (plugins$1) => { if (!plugins$1) return plugins$1;else if (Array.isArray(plugins$1)) return plugins$1.filter(pluginsHandle);else if (typeof plugins$1 === "object" && plugins$1 !== null) if (options?.compile) return plugins$1["name"] === "typescript" ? false : plugins$1;else return plugins$1; }; finalConfig.build.plugins = pluginsHandle(plugins); } return finalConfig; } catch (error) { logger.error(`Failed to load config file: ${toFileUrl(configPath)}`, error); throw error; } } function findConfigFile(userPath) { const possiblePaths = [ userPath, "xbuild.config.ts", "xbuild.config.js", "xbuild.config.mjs", "xbuild.config.cjs", path.join("config", "xbuild.config.ts")]. filter(Boolean); for (const configPath of possiblePaths) { const absolutePath = path.resolve(process.cwd(), configPath); if (fs.existsSync(absolutePath)) return absolutePath; const withTsExt = absolutePath.endsWith(".ts") ? absolutePath : `${absolutePath}.ts`; if (fs.existsSync(withTsExt)) return withTsExt; const withJsExt = absolutePath.endsWith(".js") ? absolutePath : `${absolutePath}.js`; if (fs.existsSync(withJsExt)) return withJsExt; } return null; } function defineConfig(config) { return config; } //#endregion //#region src/commands/build.ts async function buildCommand(options) { const logger$1 = new Logger("Build"); _XbuildEnv.to("production"); try { const isCompile = options.compile === true; let config = await loadConfig(options.config, { compile: isCompile }); const _config = { ...config, mode: "production" }; logger$1.info(`Starting ${isCompile ? "compile" : "build"} of full build process...`); const builder = new XBuilder(_config); const timeFlag = `time of ${isCompile ? "compile" : "build"}`; console.time(timeFlag); const buildSuccess = await builder.runBuild(); console.timeEnd(timeFlag); if (buildSuccess) { logger$1.success("Full build completed successfully"); process.exit(0); } else { logger$1.error("Build failed during bundling"); process.exit(1); } } catch (error) { logger$1.error("Error during build process:", error); process.exit(1); } finally { _XbuildEnv.reset(); } } //#endregion //#region src/commands/dev.ts async function devCommand(options) { const logger$1 = new Logger("Dev"); _XbuildEnv.to("development"); try { let config = await loadConfig(options.config, { compile: true }); const _config = { ...config, mode: "development" }; logger$1.info("Starting development server..."); const builder = new XBuilder(_config); builder.runDev(options).then(({ port, proxy }) => { logger$1.success(`Development server running at http://localhost:${port}`); proxy && printAlignedProxyServerInfo(port, proxy, logger$1.success.bind(logger$1)); }).catch((error) => { logger$1.error("Error starting development server:", error); }); } catch (error) { logger$1.error("Error starting development server:", error); process.exit(1); } finally { _XbuildEnv.reset(); } } //#endregion //#region src/index.ts program.name("xbuild").description("Extensible Rollup-based TypeScript build tool").version("0.1.0"); program.command("dev").description("Start development server").option("-s, --static <path>", "Path to public dir").option("-c, --config <path>", "Path to config file").option("-p, --port <number>", "Path to Port number, default 9999").action(async (options) => { await devCommand({ ...options, public: options.static }); }); program.command("build").description("Full build process with type checking and declaration files").option("-c, --config <path>", "Path to config file").option("-C, --compile", "Path to compile mode").action(async (options) => { await buildCommand(options); }); program.parse(process.argv).on("exit", (...args) => { console.log(...args); }); //#endregion export { Logger, PluginManager, XBuilder, buildCommand, defineConfig, devCommand, loadConfig }; //# sourceMappingURL=index.js.map