UNPKG

tsdown

Version:

The Elegant Bundler for Libraries

645 lines (631 loc) 20 kB
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 };