@msom/xbuild
Version:
@msom/xbuild
782 lines (769 loc) • 26.6 kB
JavaScript
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/,
"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 {
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;
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