tsdown
Version:
The Elegant Bundler for Libraries
645 lines (631 loc) • 20 kB
JavaScript
import { defineConfig } from "./config-CpIe1Ud_.js";
import { ExternalPlugin, ReportPlugin, ShebangPlugin, fsExists, fsRemove, getPackageType, lowestCommonAncestor, normalizeFormat, prettyFormat, readPackageJson } from "./plugins-DX6CtlR1.js";
import { debounce, logger, resolveComma, setSilent, toArray } from "./general-C06aMSSY.js";
import path from "node:path";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { blue, bold, dim, green, underline } from "ansis";
import Debug from "debug";
import { build as build$1 } from "rolldown";
import { transformPlugin } from "rolldown/experimental";
import { exec } from "tinyexec";
import { glob } from "tinyglobby";
import { stat } from "node:fs/promises";
import { createHooks } from "hookable";
import LightningCSS from "unplugin-lightningcss/rolldown";
import readline from "node:readline";
import { loadConfig } from "unconfig";
import { up } from "empathic/find";
//#region src/features/clean.ts
const debug$3 = Debug("tsdown:clean");
const RE_LAST_SLASH = /[/\\]$/;
async function cleanOutDir(configs) {
const removes = new Set();
for (const config of configs) {
if (!config.clean.length) continue;
const files = await glob(config.clean, {
cwd: config.cwd,
absolute: true,
onlyFiles: false
});
const normalizedOutDir = config.outDir.replace(RE_LAST_SLASH, "");
for (const file of files) {
const normalizedFile = file.replace(RE_LAST_SLASH, "");
if (normalizedFile !== normalizedOutDir) removes.add(file);
}
}
if (!removes.size) return;
logger.info("Cleaning %d files", removes.size);
await Promise.all([...removes].map(async (file) => {
debug$3("Removing", file);
await fsRemove(file);
}));
debug$3("Removed %d files", removes.size);
}
function resolveClean(clean, outDir) {
if (clean === true) clean = [outDir];
else if (!clean) clean = [];
return clean;
}
//#endregion
//#region src/features/hooks.ts
async function createHooks$1(options, pkg) {
const hooks = createHooks();
if (typeof options.hooks === "object") hooks.addHooks(options.hooks);
else if (typeof options.hooks === "function") await options.hooks(hooks);
const context = {
options,
pkg,
hooks
};
return {
hooks,
context
};
}
//#endregion
//#region src/utils/lightningcss.ts
/**
* Converts esbuild target [^1] (which is also used by Rolldown [^2]) to Lightning CSS targets [^3].
*
* [^1]: https://esbuild.github.io/api/#target
* [^2]: https://github.com/rolldown/rolldown/blob/v1.0.0-beta.8/packages/rolldown/src/binding.d.ts#L1429-L1431
* [^3]: https://lightningcss.dev/transpilation.html
*/
function esbuildTargetToLightningCSS(target) {
let targets;
const targetString = target.join(" ").toLowerCase();
const matches = [...targetString.matchAll(TARGET_REGEX)];
for (const match of matches) {
const name = match[1];
const browser = ESBUILD_LIGHTNINGCSS_MAPPING[name];
if (!browser) continue;
const version = match[2];
const versionInt = parseVersion(version);
if (versionInt == null) continue;
targets = targets || {};
targets[browser] = versionInt;
}
return targets;
}
const TARGET_REGEX = /([a-z]+)(\d+(?:\.\d+)*)/g;
const ESBUILD_LIGHTNINGCSS_MAPPING = {
chrome: "chrome",
edge: "edge",
firefox: "firefox",
ie: "ie",
ios: "ios_saf",
opera: "opera",
safari: "safari"
};
function parseVersion(version) {
const [major, minor = 0, patch = 0] = version.split("-")[0].split(".").map((v) => Number.parseInt(v, 10));
if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) return null;
return major << 16 | minor << 8 | patch;
}
//#endregion
//#region src/features/lightningcss.ts
function LightningCSSPlugin(options) {
const targets = options.target && esbuildTargetToLightningCSS(options.target);
if (!targets) return;
return LightningCSS({ options: { targets } });
}
//#endregion
//#region src/features/output.ts
function resolveJsOutputExtension(packageType, format, fixedExtension) {
switch (format) {
case "es": return !fixedExtension && packageType === "module" ? "js" : "mjs";
case "cjs": return fixedExtension || packageType === "module" ? "cjs" : "js";
default: return "js";
}
}
function resolveChunkFilename({ outExtensions, fixedExtension, pkg }, inputOptions, format) {
const packageType = getPackageType(pkg);
let jsExtension;
let dtsExtension;
if (outExtensions) {
const { js, dts } = outExtensions({
options: inputOptions,
format,
pkgType: packageType
}) || {};
jsExtension = js;
dtsExtension = dts;
}
jsExtension ||= `.${resolveJsOutputExtension(packageType, format, fixedExtension)}`;
const suffix = format === "iife" || format === "umd" ? `.${format}` : "";
return [createChunkFilename(`[name]${suffix}`, jsExtension, dtsExtension), createChunkFilename(`[name]${suffix}-[hash]`, jsExtension, dtsExtension)];
}
function createChunkFilename(basename, jsExtension, dtsExtension) {
if (!dtsExtension) return `${basename}${jsExtension}`;
return (chunk) => {
return `${basename}${chunk.name.endsWith(".d") ? dtsExtension : jsExtension}`;
};
}
//#endregion
//#region src/features/publint.ts
const debug$2 = Debug("tsdown:publint");
async function publint(pkg, options) {
debug$2("Running publint");
const { publint: publint$1 } = await import("publint");
const { formatMessage } = await import("publint/utils");
const { messages } = await publint$1(options);
debug$2("Found %d issues", messages.length);
if (!messages.length) logger.success("No publint issues found");
let hasError = false;
for (const message of messages) {
hasError ||= message.type === "error";
const formattedMessage = formatMessage(message, pkg);
const logType = {
error: "error",
warning: "warn",
suggestion: "info"
}[message.type];
logger[logType](formattedMessage);
}
if (hasError) {
debug$2("Found errors, setting exit code to 1");
process.exitCode = 1;
}
}
//#endregion
//#region src/features/shims.ts
function getShimsInject(format, platform) {
if (format === "es" && platform === "node") {
const shimFile = path.resolve(pkgRoot, "esm-shims.js");
return {
__dirname: [shimFile, "__dirname"],
__filename: [shimFile, "__filename"]
};
}
}
//#endregion
//#region src/features/shortcuts.ts
function shortcuts(restart) {
let actionRunning = false;
async function onInput(input) {
if (actionRunning) return;
const SHORTCUTS = [
{
key: "r",
description: "reload config and rebuild",
action() {
rl.close();
restart();
}
},
{
key: "c",
description: "clear console",
action() {
console.clear();
}
},
{
key: "q",
description: "quit",
action() {
process.exit(0);
}
}
];
if (input === "h") {
const loggedKeys = new Set();
logger.info(" Shortcuts");
for (const shortcut$1 of SHORTCUTS) {
if (loggedKeys.has(shortcut$1.key)) continue;
loggedKeys.add(shortcut$1.key);
if (shortcut$1.action == null) continue;
logger.info(dim` press ` + bold`${shortcut$1.key} + enter` + dim` to ${shortcut$1.description}`);
}
return;
}
const shortcut = SHORTCUTS.find((shortcut$1) => shortcut$1.key === input);
if (!shortcut) return;
actionRunning = true;
await shortcut.action();
actionRunning = false;
}
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", onInput);
}
//#endregion
//#region src/features/watch.ts
const endsWithPackageJson = /[\\/]package\.json$/;
async function watchBuild(options, configFile, rebuild, restart) {
const cwd = process.cwd();
if (typeof options.watch === "boolean" && options.outDir === cwd) throw new Error("Watch is enabled, but output directory is the same as the current working directory.Please specify a different watch directory using `watch` option,or set `outDir` to a different directory.");
const files = toArray(typeof options.watch === "boolean" ? cwd : options.watch);
logger.info(`Watching for changes in ${files.join(", ")}`);
if (configFile) files.push(configFile);
const { watch } = await import("chokidar");
const debouncedRebuild = debounce(rebuild, 100);
const watcher = watch(files, {
ignoreInitial: true,
ignorePermissionErrors: true,
ignored: [
/[\\/]\.git[\\/]/,
/[\\/]node_modules[\\/]/,
options.outDir
]
});
watcher.on("all", (type, file) => {
if (endsWithPackageJson.test(file) || configFile === file) {
logger.info(`Reload config: ${file}`);
restart();
return;
}
logger.info(`Change detected: ${type} ${file}`);
debouncedRebuild();
});
return watcher;
}
//#endregion
//#region src/features/entry.ts
async function resolveEntry(entry, cwd) {
if (!entry || Object.keys(entry).length === 0) throw new Error(`No input files, try "tsdown <your-file>" instead`);
const objectEntry = await toObjectEntry(entry, cwd);
const entries = Object.values(objectEntry);
if (entries.length === 0) throw new Error(`Cannot find entry: ${JSON.stringify(entry)}`);
logger.info(`entry: ${blue(entries.join(", "))}`);
return objectEntry;
}
async function toObjectEntry(entry, cwd) {
if (typeof entry === "string") entry = [entry];
if (!Array.isArray(entry)) return entry;
const resolvedEntry = await glob(entry, { cwd });
const base = lowestCommonAncestor(...resolvedEntry);
return Object.fromEntries(resolvedEntry.map((file) => {
const relative = path.relative(base, file);
return [relative.slice(0, relative.length - path.extname(relative).length), file];
}));
}
//#endregion
//#region src/features/tsconfig.ts
function findTsconfig(cwd, name = "tsconfig.json") {
return up(name, { cwd }) || false;
}
async function resolveTsconfig(tsconfig, cwd) {
if (tsconfig !== false) {
if (tsconfig === true || tsconfig == null) {
const isSet = tsconfig;
tsconfig = findTsconfig(cwd);
if (isSet && !tsconfig) logger.warn(`No tsconfig found in \`${cwd}\``);
} else {
const tsconfigPath = path.resolve(cwd, tsconfig);
if (await fsExists(tsconfigPath)) tsconfig = tsconfigPath;
else if (tsconfig.includes("\\") || tsconfig.includes("/")) {
logger.warn(`tsconfig \`${tsconfig}\` doesn't exist`);
tsconfig = false;
} else {
tsconfig = findTsconfig(cwd, tsconfig);
if (!tsconfig) logger.warn(`No \`${tsconfig}\` found in \`${cwd}\``);
}
}
if (tsconfig) logger.info(`Using tsconfig: ${underline(path.relative(cwd, tsconfig))}`);
}
return tsconfig;
}
//#endregion
//#region src/options.ts
const debug$1 = Debug("tsdown:options");
async function resolveOptions(options) {
const { configs: userConfigs, file, cwd } = await loadConfigFile(options);
if (userConfigs.length === 0) userConfigs.push({});
debug$1("Loaded config file %s from %s", file, cwd);
debug$1("User configs %o", userConfigs);
const configs = await Promise.all(userConfigs.map(async (subConfig) => {
const subOptions = {
...subConfig,
...options
};
let { entry, format = ["es"], plugins = [], clean = true, silent = false, treeshake = true, platform = "node", outDir = "dist", sourcemap = false, dts, unused = false, watch = false, shims = false, skipNodeModulesBundle = false, publint: publint$1 = false, fromVite, alias, tsconfig, report = true, target, env = {} } = subOptions;
outDir = path.resolve(outDir);
entry = await resolveEntry(entry, cwd);
clean = resolveClean(clean, outDir);
const pkg = await readPackageJson(cwd);
if (dts == null) dts = !!(pkg?.types || pkg?.typings);
tsconfig = await resolveTsconfig(tsconfig, cwd);
if (publint$1 === true) publint$1 = {};
if (fromVite) {
const viteUserConfig = await loadViteConfig(fromVite === true ? "vite" : fromVite, cwd);
if (viteUserConfig) {
if (Array.isArray(alias)) throw new TypeError("Unsupported resolve.alias in Vite config. Use object instead of array");
if (viteUserConfig.plugins) plugins = [viteUserConfig.plugins, plugins];
const viteAlias = viteUserConfig.resolve?.alias;
if (viteAlias && !Array.isArray(viteAlias)) alias = viteAlias;
}
}
const config = {
...subOptions,
entry,
plugins,
format: normalizeFormat(format),
target: target ? resolveComma(toArray(target)) : void 0,
outDir,
clean,
silent,
treeshake,
platform,
sourcemap,
dts: dts === true ? {} : dts,
report: report === true ? {} : report,
unused,
watch,
shims,
skipNodeModulesBundle,
publint: publint$1,
alias,
tsconfig,
cwd,
env,
pkg
};
return config;
}));
return {
configs,
file
};
}
let loaded = false;
async function loadConfigFile(options) {
let cwd = process.cwd();
let overrideConfig = false;
let { config: filePath } = options;
if (filePath === false) return {
configs: [],
cwd
};
if (typeof filePath === "string") {
const stats = await stat(filePath).catch(() => null);
if (stats) {
const resolved = path.resolve(filePath);
if (stats.isFile()) {
overrideConfig = true;
filePath = resolved;
cwd = path.dirname(filePath);
} else if (stats.isDirectory()) cwd = resolved;
}
}
const nativeTS = process.features.typescript || process.versions.bun || process.versions.deno;
let { config, sources } = await loadConfig.async({
sources: overrideConfig ? [{
files: filePath,
extensions: []
}] : [{
files: "tsdown.config",
extensions: [
"ts",
"mts",
"cts",
"js",
"mjs",
"cjs",
"json",
""
],
parser: loaded || !nativeTS ? "auto" : async (filepath) => {
const mod = await import(pathToFileURL(filepath).href);
const config$1 = mod.default || mod;
return config$1;
}
}, {
files: "package.json",
extensions: [],
rewrite: (config$1) => config$1?.tsdown
}],
cwd,
defaults: {}
}).finally(() => loaded = true);
const file = sources[0];
if (file) logger.info(`Using tsdown config: ${underline(file)}`);
if (typeof config === "function") config = await config(options);
return {
configs: toArray(config),
file,
cwd
};
}
async function loadViteConfig(prefix, cwd) {
const { config, sources: [source] } = await loadConfig({
sources: [{
files: `${prefix}.config`,
extensions: [
"ts",
"mts",
"cts",
"js",
"mjs",
"cjs",
"json",
""
]
}],
cwd,
defaults: {}
});
if (!source) return;
logger.info(`Using Vite config: ${underline(source)}`);
const resolved = await config;
if (typeof resolved === "function") return resolved({
command: "build",
mode: "production"
});
return resolved;
}
async function mergeUserOptions(defaults, user, args) {
const userOutputOptions = typeof user === "function" ? await user(defaults, ...args) : user;
return {
...defaults,
...userOutputOptions
};
}
//#endregion
//#region src/index.ts
const debug = Debug("tsdown:main");
/**
* Build with tsdown.
*/
async function build(userOptions = {}) {
if (typeof userOptions.silent === "boolean") setSilent(userOptions.silent);
debug("Loading config");
const { configs, file: configFile } = await resolveOptions(userOptions);
if (configFile) {
debug("Loaded config:", configFile);
configs.forEach((config) => {
debug("using resolved config: %O", config);
});
} else debug("No config file found");
let cleanPromise;
const clean = () => {
if (cleanPromise) return cleanPromise;
return cleanPromise = cleanOutDir(configs);
};
const rebuilds = await Promise.all(configs.map((options) => buildSingle(options, clean)));
const cleanCbs = [];
for (const [i, config] of configs.entries()) {
const rebuild = rebuilds[i];
if (!rebuild) continue;
const watcher = await watchBuild(config, configFile, rebuild, restart);
cleanCbs.push(() => watcher.close());
}
if (cleanCbs.length) shortcuts(restart);
async function restart() {
for (const clean$1 of cleanCbs) await clean$1();
build(userOptions);
}
}
const dirname$1 = path.dirname(fileURLToPath(import.meta.url));
const pkgRoot = path.resolve(dirname$1, "..");
/**
* Build a single configuration, without watch and shortcuts features.
*
* Internal API, not for public use
*
* @private
* @param config Resolved options
*/
async function buildSingle(config, clean) {
const { format: formats, dts, watch, onSuccess } = config;
let onSuccessCleanup;
const { hooks, context } = await createHooks$1(config);
await rebuild(true);
if (watch) return () => rebuild();
async function rebuild(first) {
const startTime = performance.now();
await hooks.callHook("build:prepare", context);
onSuccessCleanup?.();
await clean();
let hasErrors = false;
await Promise.all(formats.map(async (format) => {
try {
const formatLabel = prettyFormat(format);
logger.info(formatLabel, "Build start");
const buildOptions = await getBuildOptions(config, format);
await hooks.callHook("build:before", {
...context,
buildOptions
});
await build$1(buildOptions);
if (format === "cjs" && dts) await build$1(await getBuildOptions(config, format, true));
} catch (error) {
if (watch) {
logger.error(error);
hasErrors = true;
return;
}
throw error;
}
}));
if (hasErrors) return;
await hooks.callHook("build:done", context);
if (config.publint) if (config.pkg) await publint(config.pkg, config.publint === true ? {} : config.publint);
else logger.warn("publint is enabled but package.json is not found");
logger.success(`${first ? "Build" : "Rebuild"} complete in ${green(`${Math.round(performance.now() - startTime)}ms`)}`);
if (typeof onSuccess === "string") {
const p = exec(onSuccess, [], { nodeOptions: {
shell: true,
stdio: "inherit"
} });
p.then(({ exitCode }) => {
if (exitCode) process.exitCode = exitCode;
});
onSuccessCleanup = () => p.kill("SIGTERM");
} else await onSuccess?.(config);
}
}
async function getBuildOptions(config, format, cjsDts) {
const { entry, external, plugins: userPlugins, outDir, platform, alias, treeshake, sourcemap, dts, minify, unused, target, define, shims, tsconfig, cwd, report, env } = config;
const plugins = [];
if (config.pkg || config.skipNodeModulesBundle) plugins.push(ExternalPlugin(config));
if (dts) {
const { dts: dtsPlugin } = await import("rolldown-plugin-dts");
const options = {
tsconfig,
...dts
};
if (format === "es") plugins.push(dtsPlugin(options));
else if (cjsDts) plugins.push(dtsPlugin({
...options,
emitDtsOnly: true
}));
}
if (!cjsDts) {
if (unused) {
const { Unused } = await import("unplugin-unused");
plugins.push(Unused.rolldown(unused === true ? {} : unused));
}
if (target) plugins.push(transformPlugin({
include: /\.[cm]?[jt]sx?$/,
exclude: /\.d\.[cm]?ts$/,
transformOptions: { target }
}));
plugins.push(ShebangPlugin(cwd));
}
if (report && logger.level >= 3) plugins.push(ReportPlugin(report, cwd, cjsDts));
if (target) plugins.push(
// Use Lightning CSS to handle CSS input. This is a temporary solution
// until Rolldown supports CSS syntax lowering natively.
LightningCSSPlugin({ target })
);
plugins.push(userPlugins);
const inputOptions = await mergeUserOptions({
input: entry,
cwd,
external,
resolve: {
alias,
tsconfigFilename: tsconfig || void 0
},
treeshake,
platform,
define: {
...define,
...Object.keys(env).reduce((acc, key) => {
const value = JSON.stringify(env[key]);
acc[`process.env.${key}`] = value;
acc[`import.meta.env.${key}`] = value;
return acc;
}, Object.create(null))
},
plugins,
inject: { ...shims && !cjsDts && getShimsInject(format, platform) }
}, config.inputOptions, [format]);
const [entryFileNames, chunkFileNames] = resolveChunkFilename(config, inputOptions, format);
const outputOptions = await mergeUserOptions({
format: cjsDts ? "es" : format,
name: config.globalName,
sourcemap,
dir: outDir,
minify,
entryFileNames,
chunkFileNames
}, config.outputOptions, [format]);
return {
...inputOptions,
output: outputOptions
};
}
//#endregion
export { build, buildSingle, defineConfig, logger, pkgRoot };