From 3aa355a839045cf72b4d482c3578935ece017bbd Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 2 Jul 2024 13:42:04 +0200 Subject: [PATCH] refactor: overhaul builders structure (#410) --- src/build.ts | 11 +- src/builder/rollup.ts | 525 ------------------ .../copy.ts => builders/copy/index.ts} | 4 +- .../mkdist.ts => builders/mkdist/index.ts} | 4 +- src/builders/rollup/build.ts | 120 ++++ src/builders/rollup/config.ts | 146 +++++ src/builders/rollup/index.ts | 1 + .../rollup}/plugins/cjs.ts | 0 .../rollup}/plugins/esbuild.ts | 0 .../rollup}/plugins/json.ts | 0 .../rollup}/plugins/raw.ts | 0 .../rollup}/plugins/shebang.ts | 0 src/builders/rollup/stub.ts | 179 ++++++ src/builders/rollup/utils.ts | 66 +++ src/builders/rollup/watch.ts | 39 ++ .../untyped.ts => builders/untyped/index.ts} | 6 +- src/types.ts | 2 +- 17 files changed, 565 insertions(+), 538 deletions(-) delete mode 100644 src/builder/rollup.ts rename src/{builder/copy.ts => builders/copy/index.ts} (93%) rename src/{builder/mkdist.ts => builders/mkdist/index.ts} (91%) create mode 100644 src/builders/rollup/build.ts create mode 100644 src/builders/rollup/config.ts create mode 100644 src/builders/rollup/index.ts rename src/{builder => builders/rollup}/plugins/cjs.ts (100%) rename src/{builder => builders/rollup}/plugins/esbuild.ts (100%) rename src/{builder => builders/rollup}/plugins/json.ts (100%) rename src/{builder => builders/rollup}/plugins/raw.ts (100%) rename src/{builder => builders/rollup}/plugins/shebang.ts (100%) create mode 100644 src/builders/rollup/stub.ts create mode 100644 src/builders/rollup/utils.ts create mode 100644 src/builders/rollup/watch.ts rename src/{builder/untyped.ts => builders/untyped/index.ts} (96%) diff --git a/src/build.ts b/src/build.ts index 94231716..869f56e9 100644 --- a/src/build.ts +++ b/src/build.ts @@ -13,10 +13,10 @@ import type { RollupOptions } from "rollup"; import { dumpObject, rmdir, resolvePreset, removeExtension } from "./utils"; import type { BuildContext, BuildConfig, BuildOptions } from "./types"; import { validatePackage, validateDependencies } from "./validate"; -import { getRollupOptions, rollupBuild } from "./builder/rollup"; -import { typesBuild } from "./builder/untyped"; -import { mkdistBuild } from "./builder/mkdist"; -import { copyBuild } from "./builder/copy"; +import { rollupBuild } from "./builders/rollup"; +import { typesBuild } from "./builders/untyped"; +import { mkdistBuild } from "./builders/mkdist"; +import { copyBuild } from "./builders/copy"; import { createJiti } from "jiti"; export async function build( @@ -42,7 +42,6 @@ export async function build( // Invoke build for every build config defined in build.config.ts const cleanedDirs: string[] = []; - const rollupOptions: RollupOptions[] = []; const _watchMode = inputConfig.watch === true; const _stubMode = !_watchMode && (stub || inputConfig.stub === true); @@ -54,7 +53,6 @@ export async function build( buildConfig, pkg, cleanedDirs, - rollupOptions, _stubMode, _watchMode, ); @@ -67,7 +65,6 @@ async function _build( buildConfig: BuildConfig, pkg: PackageJson & Partial>, cleanedDirs: string[], - rollupOptions: RollupOptions[], _stubMode: boolean, _watchMode: boolean, ) { diff --git a/src/builder/rollup.ts b/src/builder/rollup.ts deleted file mode 100644 index 2064feed..00000000 --- a/src/builder/rollup.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { writeFile, mkdir } from "node:fs/promises"; -import { promises as fsp } from "node:fs"; -import type { OutputOptions, OutputChunk, PreRenderedChunk } from "rollup"; -import { rollup } from "rollup"; -import commonjs from "@rollup/plugin-commonjs"; -import { nodeResolve } from "@rollup/plugin-node-resolve"; -import alias from "@rollup/plugin-alias"; -import dts from "rollup-plugin-dts"; -import replace from "@rollup/plugin-replace"; -import { - resolve, - dirname, - normalize, - extname, - isAbsolute, - relative, -} from "pathe"; -import { resolvePath, pathToFileURL, resolveModuleExportNames } from "mlly"; -import { watch as rollupWatch } from "rollup"; -import { arrayIncludes, getpkg, warn } from "../utils"; -import type { BuildContext, RollupOptions } from "../types"; -import { esbuild } from "./plugins/esbuild"; -import { JSONPlugin } from "./plugins/json"; -import { rawPlugin } from "./plugins/raw"; -import { cjsPlugin } from "./plugins/cjs"; -import { - shebangPlugin, - makeExecutable, - getShebang, - removeShebangPlugin, -} from "./plugins/shebang"; -import consola from "consola"; -import chalk from "chalk"; - -const DEFAULT_EXTENSIONS = [ - ".ts", - ".tsx", - ".mts", - ".cts", - ".mjs", - ".cjs", - ".js", - ".jsx", - ".json", -]; - -export async function rollupBuild(ctx: BuildContext) { - if (ctx.options.stub) { - const babelPlugins = ctx.options.stubOptions.jiti.transformOptions?.babel - ?.plugins as any; - const importedBabelPlugins: Array = []; - const serializedJitiOptions = JSON.stringify( - { - ...ctx.options.stubOptions.jiti, - alias: { - ...resolveAliases(ctx), - ...ctx.options.stubOptions.jiti.alias, - }, - transformOptions: { - ...ctx.options.stubOptions.jiti.transformOptions, - babel: { - ...ctx.options.stubOptions.jiti.transformOptions?.babel, - plugins: "__$BABEL_PLUGINS", - }, - }, - }, - null, - 2, - ).replace( - '"__$BABEL_PLUGINS"', - Array.isArray(babelPlugins) - ? "[" + - babelPlugins - .map((plugin: string | Array, i) => { - if (Array.isArray(plugin)) { - const [name, ...args] = plugin; - importedBabelPlugins.push(name); - return ( - `[` + - [ - `plugin${i}`, - ...args.map((val) => JSON.stringify(val)), - ].join(", ") + - "]" - ); - } else { - importedBabelPlugins.push(plugin); - return `plugin${i}`; - } - }) - .join(",") + - "]" - : "[]", - ); - - for (const entry of ctx.options.entries.filter( - (entry) => entry.builder === "rollup", - )) { - const output = resolve( - ctx.options.rootDir, - ctx.options.outDir, - entry.name!, - ); - - const isESM = ctx.pkg.type === "module"; - const resolvedEntry = normalize( - ctx.jiti.esmResolve(entry.input, { try: true }) || entry.input, - ); - const resolvedEntryWithoutExt = resolvedEntry.slice( - 0, - Math.max(0, resolvedEntry.length - extname(resolvedEntry).length), - ); - const resolvedEntryForTypeImport = isESM - ? `${resolvedEntry.replace(/(\.m?)(ts)$/, "$1js")}` - : resolvedEntryWithoutExt; - const code = await fsp.readFile(resolvedEntry, "utf8"); - const shebang = getShebang(code); - - await mkdir(dirname(output), { recursive: true }); - - // CJS Stub - if (ctx.options.rollup.emitCJS) { - const jitiCJSPath = relative( - dirname(output), - await resolvePath("jiti", { - url: import.meta.url, - conditions: ["node", "require"], - }), - ); - await writeFile( - output + ".cjs", - shebang + - [ - `const { createJiti } = require(${JSON.stringify(jitiCJSPath)})`, - ...importedBabelPlugins.map( - (plugin, i) => - `const plugin${i} = require(${JSON.stringify(plugin)})`, - ), - "", - `const jiti = createJiti(__filename, ${serializedJitiOptions})`, - "", - `/** @type {import(${JSON.stringify( - resolvedEntryForTypeImport, - )})} */`, - `module.exports = jiti(${JSON.stringify(resolvedEntry)})`, - ].join("\n"), - ); - } - - // MJS Stub - // Try to analyze exports - const namedExports: string[] = await resolveModuleExportNames( - resolvedEntry, - { - extensions: DEFAULT_EXTENSIONS, - }, - ).catch((error) => { - warn(ctx, `Cannot analyze ${resolvedEntry} for exports:` + error); - return []; - }); - const hasDefaultExport = - namedExports.includes("default") || namedExports.length === 0; - - const jitiESMPath = relative( - dirname(output), - await resolvePath("jiti", { - url: import.meta.url, - conditions: ["node", "import"], - }), - ); - - await writeFile( - output + ".mjs", - shebang + - [ - `import { createJiti } from ${JSON.stringify(jitiESMPath)};`, - ...importedBabelPlugins.map( - (plugin, i) => `import plugin${i} from ${JSON.stringify(plugin)}`, - ), - "", - `const jiti = createJiti(import.meta.url, ${serializedJitiOptions})`, - "", - `/** @type {import(${JSON.stringify(resolvedEntryForTypeImport)})} */`, - `const _module = await jiti.import(${JSON.stringify( - resolvedEntry, - )});`, - hasDefaultExport ? "\nexport default _module;" : "", - ...namedExports - .filter((name) => name !== "default") - .map((name) => `export const ${name} = _module.${name};`), - ].join("\n"), - ); - - // DTS Stub - if (ctx.options.declaration) { - const dtsContent = [ - `export * from ${JSON.stringify(resolvedEntryForTypeImport)};`, - hasDefaultExport - ? `export { default } from ${JSON.stringify(resolvedEntryForTypeImport)};` - : "", - ].join("\n"); - await writeFile(output + ".d.cts", dtsContent); - await writeFile(output + ".d.mts", dtsContent); - if ( - ctx.options.declaration === "compatible" || - ctx.options.declaration === true - ) { - await writeFile(output + ".d.ts", dtsContent); - } - } - - if (shebang) { - await makeExecutable(output + ".cjs"); - await makeExecutable(output + ".mjs"); - } - } - await ctx.hooks.callHook("rollup:done", ctx); - return; - } - - const rollupOptions = getRollupOptions(ctx); - await ctx.hooks.callHook("rollup:options", ctx, rollupOptions); - - if (Object.keys(rollupOptions.input as any).length === 0) { - return; - } - - const buildResult = await rollup(rollupOptions); - await ctx.hooks.callHook("rollup:build", ctx, buildResult); - - const allOutputOptions = rollupOptions.output! as OutputOptions[]; - for (const outputOptions of allOutputOptions) { - const { output } = await buildResult.write(outputOptions); - const chunkFileNames = new Set(); - const outputChunks = output.filter( - (e) => e.type === "chunk", - ) as OutputChunk[]; - for (const entry of outputChunks) { - chunkFileNames.add(entry.fileName); - for (const id of entry.imports) { - ctx.usedImports.add(id); - } - if (entry.isEntry) { - ctx.buildEntries.push({ - chunks: entry.imports.filter((i) => - outputChunks.find((c) => c.fileName === i), - ), - modules: Object.entries(entry.modules).map(([id, mod]) => ({ - id, - bytes: mod.renderedLength, - })), - path: entry.fileName, - bytes: Buffer.byteLength(entry.code, "utf8"), - exports: entry.exports, - }); - } - } - for (const chunkFileName of chunkFileNames) { - ctx.usedImports.delete(chunkFileName); - } - } - - // Watch - if (ctx.options.watch) { - _watch(rollupOptions); - // TODO: Clone rollup options to continue types watching - if (ctx.options.declaration && ctx.options.watch) { - consola.warn("`rollup` DTS builder does not support watch mode yet."); - } - return; - } - - // Types - if (ctx.options.declaration) { - rollupOptions.plugins = [ - ...rollupOptions.plugins, - dts(ctx.options.rollup.dts), - removeShebangPlugin(), - ]; - - await ctx.hooks.callHook("rollup:dts:options", ctx, rollupOptions); - const typesBuild = await rollup(rollupOptions); - await ctx.hooks.callHook("rollup:dts:build", ctx, typesBuild); - // #region cjs - if (ctx.options.rollup.emitCJS) { - await typesBuild.write({ - dir: resolve(ctx.options.rootDir, ctx.options.outDir), - entryFileNames: "[name].d.cts", - chunkFileNames: (chunk) => getChunkFilename(ctx, chunk, "d.cts"), - }); - } - // #endregion - // #region mjs - await typesBuild.write({ - dir: resolve(ctx.options.rootDir, ctx.options.outDir), - entryFileNames: "[name].d.mts", - chunkFileNames: (chunk) => getChunkFilename(ctx, chunk, "d.mts"), - }); - // #endregion - // #region .d.ts for node10 compatibility (TypeScript version < 4.7) - if ( - ctx.options.declaration === true || - ctx.options.declaration === "compatible" - ) { - await typesBuild.write({ - dir: resolve(ctx.options.rootDir, ctx.options.outDir), - entryFileNames: "[name].d.ts", - chunkFileNames: (chunk) => getChunkFilename(ctx, chunk, "d.ts"), - }); - } - // #endregion - } - - await ctx.hooks.callHook("rollup:done", ctx); -} - -const getChunkFilename = ( - ctx: BuildContext, - chunk: PreRenderedChunk, - ext: string, -) => { - if (chunk.isDynamicEntry) { - return `chunks/[name].${ext}`; - } - // TODO: Find a way to generate human friendly hash for short groups - return `shared/${ctx.options.name}.[hash].${ext}`; -}; - -export function getRollupOptions(ctx: BuildContext): RollupOptions { - const _aliases = resolveAliases(ctx); - return ({ - input: Object.fromEntries( - ctx.options.entries - .filter((entry) => entry.builder === "rollup") - .map((entry) => [ - entry.name, - resolve(ctx.options.rootDir, entry.input), - ]), - ), - - output: [ - ctx.options.rollup.emitCJS && - { - dir: resolve(ctx.options.rootDir, ctx.options.outDir), - entryFileNames: "[name].cjs", - chunkFileNames: (chunk: PreRenderedChunk) => - getChunkFilename(ctx, chunk, "cjs"), - format: "cjs", - exports: "auto", - interop: "compat", - generatedCode: { constBindings: true }, - externalLiveBindings: false, - freeze: false, - sourcemap: ctx.options.sourcemap, - ...ctx.options.rollup.output, - }, - { - dir: resolve(ctx.options.rootDir, ctx.options.outDir), - entryFileNames: "[name].mjs", - chunkFileNames: (chunk: PreRenderedChunk) => - getChunkFilename(ctx, chunk, "mjs"), - format: "esm", - exports: "auto", - generatedCode: { constBindings: true }, - externalLiveBindings: false, - freeze: false, - sourcemap: ctx.options.sourcemap, - ...ctx.options.rollup.output, - }, - ].filter(Boolean), - - external(id) { - id = resolveAlias(id, _aliases); - const pkg = getpkg(id); - const isExplicitExternal = - arrayIncludes(ctx.options.externals, pkg) || - arrayIncludes(ctx.options.externals, id); - if (isExplicitExternal) { - return true; - } - if ( - ctx.options.rollup.inlineDependencies || - id[0] === "." || - isAbsolute(id) || - /src[/\\]/.test(id) || - id.startsWith(ctx.pkg.name!) - ) { - return false; - } - if (!isExplicitExternal) { - warn(ctx, `Inlined implicit external ${id}`); - } - return isExplicitExternal; - }, - - onwarn(warning, rollupWarn) { - if (!warning.code || !["CIRCULAR_DEPENDENCY"].includes(warning.code)) { - rollupWarn(warning); - } - }, - - plugins: [ - ctx.options.rollup.replace && - replace({ - ...ctx.options.rollup.replace, - values: { - ...ctx.options.replace, - ...ctx.options.rollup.replace.values, - }, - }), - - ctx.options.rollup.alias && - alias({ - ...ctx.options.rollup.alias, - entries: _aliases, - }), - - ctx.options.rollup.resolve && - nodeResolve({ - extensions: DEFAULT_EXTENSIONS, - ...ctx.options.rollup.resolve, - }), - - ctx.options.rollup.json && - JSONPlugin({ - ...ctx.options.rollup.json, - }), - - shebangPlugin(), - - ctx.options.rollup.esbuild && - esbuild({ - sourcemap: ctx.options.sourcemap, - ...ctx.options.rollup.esbuild, - }), - - ctx.options.rollup.commonjs && - commonjs({ - extensions: DEFAULT_EXTENSIONS, - ...ctx.options.rollup.commonjs, - }), - - ctx.options.rollup.preserveDynamicImports && { - renderDynamicImport() { - return { left: "import(", right: ")" }; - }, - }, - - ctx.options.rollup.cjsBridge && cjsPlugin({}), - - rawPlugin(), - ].filter(Boolean), - }) as RollupOptions; -} - -function resolveAliases(ctx: BuildContext) { - const aliases: Record = { - [ctx.pkg.name!]: ctx.options.rootDir, - ...ctx.options.alias, - }; - - if (ctx.options.rollup.alias) { - if (Array.isArray(ctx.options.rollup.alias.entries)) { - Object.assign( - aliases, - Object.fromEntries( - ctx.options.rollup.alias.entries.map((entry) => { - return [entry.find, entry.replacement]; - }), - ), - ); - } else { - Object.assign( - aliases, - ctx.options.rollup.alias.entries || ctx.options.rollup.alias, - ); - } - } - - return aliases; -} - -// TODO: use pathe utils to handle nested aliases -function resolveAlias(id: string, aliases: Record): string { - for (const [find, replacement] of Object.entries(aliases)) { - if (id.startsWith(find)) { - return id.replace(find, replacement); - } - } - return id; -} - -export function _watch(rollupOptions: RollupOptions) { - const watcher = rollupWatch(rollupOptions); - - let inputs: string[]; - if (Array.isArray(rollupOptions.input)) { - inputs = rollupOptions.input; - } else if (typeof rollupOptions.input === "string") { - inputs = [rollupOptions.input]; - } else { - inputs = Object.keys(rollupOptions.input || {}); - } - consola.info( - `[unbuild] [rollup] Starting watchers for entries: ${inputs.map((input) => "./" + relative(process.cwd(), input)).join(", ")}`, - ); - - consola.warn( - "[unbuild] [rollup] Watch mode is experimental and may be unstable", - ); - - watcher.on("change", (id, { event }) => { - consola.info(`${chalk.cyan(relative(".", id))} was ${event}d`); - }); - - watcher.on("restart", () => { - consola.info(chalk.gray("[unbuild] [rollup] Rebuilding bundle")); - }); - - watcher.on("event", (event) => { - if (event.code === "END") { - consola.success(chalk.green("[unbuild] [rollup] Rebuild finished\n")); - } - }); -} diff --git a/src/builder/copy.ts b/src/builders/copy/index.ts similarity index 93% rename from src/builder/copy.ts rename to src/builders/copy/index.ts index 1ef491a6..e3ca4907 100644 --- a/src/builder/copy.ts +++ b/src/builders/copy/index.ts @@ -1,8 +1,8 @@ import { promises as fsp } from "node:fs"; import { relative, resolve } from "pathe"; import { globby } from "globby"; -import { symlink, rmdir, warn } from "../utils"; -import type { CopyBuildEntry, BuildContext } from "../types"; +import { symlink, rmdir, warn } from "../../utils"; +import type { CopyBuildEntry, BuildContext } from "../../types"; import consola from "consola"; const copy = fsp.cp || fsp.copyFile; diff --git a/src/builder/mkdist.ts b/src/builders/mkdist/index.ts similarity index 91% rename from src/builder/mkdist.ts rename to src/builders/mkdist/index.ts index a8aaf7c5..9d6c53fd 100644 --- a/src/builder/mkdist.ts +++ b/src/builders/mkdist/index.ts @@ -1,7 +1,7 @@ import { relative } from "pathe"; import { mkdist, MkdistOptions } from "mkdist"; -import { symlink, rmdir } from "../utils"; -import type { MkdistBuildEntry, BuildContext } from "../types"; +import { symlink, rmdir } from "../../utils"; +import type { MkdistBuildEntry, BuildContext } from "../../types"; import consola from "consola"; export async function mkdistBuild(ctx: BuildContext) { diff --git a/src/builders/rollup/build.ts b/src/builders/rollup/build.ts new file mode 100644 index 00000000..2806bdd6 --- /dev/null +++ b/src/builders/rollup/build.ts @@ -0,0 +1,120 @@ +import type { OutputOptions, OutputChunk, PreRenderedChunk } from "rollup"; +import { rollup } from "rollup"; +import dts from "rollup-plugin-dts"; +import { resolve, relative } from "pathe"; +import type { BuildContext, RollupOptions } from "../../types"; +import { removeShebangPlugin } from "./plugins/shebang"; +import consola from "consola"; +import { getRollupOptions } from "./config"; +import { getChunkFilename } from "./utils"; +import { rollupStub } from "./stub"; +import { rollupWatch } from "./watch"; + +export async function rollupBuild(ctx: BuildContext) { + // Stub mode + if (ctx.options.stub) { + await rollupStub(ctx); + await ctx.hooks.callHook("rollup:done", ctx); + return; + } + + // Resolve options + const rollupOptions = getRollupOptions(ctx); + await ctx.hooks.callHook("rollup:options", ctx, rollupOptions); + + // Skip build if no input entries defined + if (Object.keys(rollupOptions.input as any).length === 0) { + await ctx.hooks.callHook("rollup:done", ctx); + return; + } + + // Do rollup build + const buildResult = await rollup(rollupOptions); + await ctx.hooks.callHook("rollup:build", ctx, buildResult); + + // Collect info about output entries + const allOutputOptions = rollupOptions.output! as OutputOptions[]; + for (const outputOptions of allOutputOptions) { + const { output } = await buildResult.write(outputOptions); + const chunkFileNames = new Set(); + const outputChunks = output.filter( + (e) => e.type === "chunk", + ) as OutputChunk[]; + for (const entry of outputChunks) { + chunkFileNames.add(entry.fileName); + for (const id of entry.imports) { + ctx.usedImports.add(id); + } + if (entry.isEntry) { + ctx.buildEntries.push({ + chunks: entry.imports.filter((i) => + outputChunks.find((c) => c.fileName === i), + ), + modules: Object.entries(entry.modules).map(([id, mod]) => ({ + id, + bytes: mod.renderedLength, + })), + path: entry.fileName, + bytes: Buffer.byteLength(entry.code, "utf8"), + exports: entry.exports, + }); + } + } + for (const chunkFileName of chunkFileNames) { + ctx.usedImports.delete(chunkFileName); + } + } + + // Watch + if (ctx.options.watch) { + rollupWatch(rollupOptions); + // TODO: Clone rollup options to continue types watching + if (ctx.options.declaration && ctx.options.watch) { + consola.warn("`rollup` DTS builder does not support watch mode yet."); + } + return; + } + + // Types + if (ctx.options.declaration) { + rollupOptions.plugins = [ + ...rollupOptions.plugins, + dts(ctx.options.rollup.dts), + removeShebangPlugin(), + ]; + + await ctx.hooks.callHook("rollup:dts:options", ctx, rollupOptions); + const typesBuild = await rollup(rollupOptions); + await ctx.hooks.callHook("rollup:dts:build", ctx, typesBuild); + // #region cjs + if (ctx.options.rollup.emitCJS) { + await typesBuild.write({ + dir: resolve(ctx.options.rootDir, ctx.options.outDir), + entryFileNames: "[name].d.cts", + chunkFileNames: (chunk) => getChunkFilename(ctx, chunk, "d.cts"), + }); + } + // #endregion + // #region mjs + await typesBuild.write({ + dir: resolve(ctx.options.rootDir, ctx.options.outDir), + entryFileNames: "[name].d.mts", + chunkFileNames: (chunk) => getChunkFilename(ctx, chunk, "d.mts"), + }); + // #endregion + // #region .d.ts for node10 compatibility (TypeScript version < 4.7) + if ( + ctx.options.declaration === true || + ctx.options.declaration === "compatible" + ) { + await typesBuild.write({ + dir: resolve(ctx.options.rootDir, ctx.options.outDir), + entryFileNames: "[name].d.ts", + chunkFileNames: (chunk) => getChunkFilename(ctx, chunk, "d.ts"), + }); + } + // #endregion + } + + await ctx.hooks.callHook("rollup:done", ctx); +} diff --git a/src/builders/rollup/config.ts b/src/builders/rollup/config.ts new file mode 100644 index 00000000..30683cdf --- /dev/null +++ b/src/builders/rollup/config.ts @@ -0,0 +1,146 @@ +import type { OutputOptions, PreRenderedChunk } from "rollup"; +import commonjs from "@rollup/plugin-commonjs"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import alias from "@rollup/plugin-alias"; +import replace from "@rollup/plugin-replace"; +import { resolve, isAbsolute } from "pathe"; +import { arrayIncludes, getpkg, warn } from "../../utils"; +import type { BuildContext, RollupOptions } from "../../types"; +import { esbuild } from "./plugins/esbuild"; +import { JSONPlugin } from "./plugins/json"; +import { rawPlugin } from "./plugins/raw"; +import { cjsPlugin } from "./plugins/cjs"; +import { shebangPlugin } from "./plugins/shebang"; +import { + DEFAULT_EXTENSIONS, + getChunkFilename, + resolveAlias, + resolveAliases, +} from "./utils"; + +export function getRollupOptions(ctx: BuildContext): RollupOptions { + const _aliases = resolveAliases(ctx); + return ({ + input: Object.fromEntries( + ctx.options.entries + .filter((entry) => entry.builder === "rollup") + .map((entry) => [ + entry.name, + resolve(ctx.options.rootDir, entry.input), + ]), + ), + + output: [ + ctx.options.rollup.emitCJS && + { + dir: resolve(ctx.options.rootDir, ctx.options.outDir), + entryFileNames: "[name].cjs", + chunkFileNames: (chunk: PreRenderedChunk) => + getChunkFilename(ctx, chunk, "cjs"), + format: "cjs", + exports: "auto", + interop: "compat", + generatedCode: { constBindings: true }, + externalLiveBindings: false, + freeze: false, + sourcemap: ctx.options.sourcemap, + ...ctx.options.rollup.output, + }, + { + dir: resolve(ctx.options.rootDir, ctx.options.outDir), + entryFileNames: "[name].mjs", + chunkFileNames: (chunk: PreRenderedChunk) => + getChunkFilename(ctx, chunk, "mjs"), + format: "esm", + exports: "auto", + generatedCode: { constBindings: true }, + externalLiveBindings: false, + freeze: false, + sourcemap: ctx.options.sourcemap, + ...ctx.options.rollup.output, + }, + ].filter(Boolean), + + external(id) { + id = resolveAlias(id, _aliases); + const pkg = getpkg(id); + const isExplicitExternal = + arrayIncludes(ctx.options.externals, pkg) || + arrayIncludes(ctx.options.externals, id); + if (isExplicitExternal) { + return true; + } + if ( + ctx.options.rollup.inlineDependencies || + id[0] === "." || + isAbsolute(id) || + /src[/\\]/.test(id) || + id.startsWith(ctx.pkg.name!) + ) { + return false; + } + if (!isExplicitExternal) { + warn(ctx, `Inlined implicit external ${id}`); + } + return isExplicitExternal; + }, + + onwarn(warning, rollupWarn) { + if (!warning.code || !["CIRCULAR_DEPENDENCY"].includes(warning.code)) { + rollupWarn(warning); + } + }, + + plugins: [ + ctx.options.rollup.replace && + replace({ + ...ctx.options.rollup.replace, + values: { + ...ctx.options.replace, + ...ctx.options.rollup.replace.values, + }, + }), + + ctx.options.rollup.alias && + alias({ + ...ctx.options.rollup.alias, + entries: _aliases, + }), + + ctx.options.rollup.resolve && + nodeResolve({ + extensions: DEFAULT_EXTENSIONS, + ...ctx.options.rollup.resolve, + }), + + ctx.options.rollup.json && + JSONPlugin({ + ...ctx.options.rollup.json, + }), + + shebangPlugin(), + + ctx.options.rollup.esbuild && + esbuild({ + sourcemap: ctx.options.sourcemap, + ...ctx.options.rollup.esbuild, + }), + + ctx.options.rollup.commonjs && + commonjs({ + extensions: DEFAULT_EXTENSIONS, + ...ctx.options.rollup.commonjs, + }), + + ctx.options.rollup.preserveDynamicImports && { + renderDynamicImport() { + return { left: "import(", right: ")" }; + }, + }, + + ctx.options.rollup.cjsBridge && cjsPlugin({}), + + rawPlugin(), + ].filter(Boolean), + }) as RollupOptions; +} diff --git a/src/builders/rollup/index.ts b/src/builders/rollup/index.ts new file mode 100644 index 00000000..92bbe016 --- /dev/null +++ b/src/builders/rollup/index.ts @@ -0,0 +1 @@ +export { rollupBuild } from "./build"; diff --git a/src/builder/plugins/cjs.ts b/src/builders/rollup/plugins/cjs.ts similarity index 100% rename from src/builder/plugins/cjs.ts rename to src/builders/rollup/plugins/cjs.ts diff --git a/src/builder/plugins/esbuild.ts b/src/builders/rollup/plugins/esbuild.ts similarity index 100% rename from src/builder/plugins/esbuild.ts rename to src/builders/rollup/plugins/esbuild.ts diff --git a/src/builder/plugins/json.ts b/src/builders/rollup/plugins/json.ts similarity index 100% rename from src/builder/plugins/json.ts rename to src/builders/rollup/plugins/json.ts diff --git a/src/builder/plugins/raw.ts b/src/builders/rollup/plugins/raw.ts similarity index 100% rename from src/builder/plugins/raw.ts rename to src/builders/rollup/plugins/raw.ts diff --git a/src/builder/plugins/shebang.ts b/src/builders/rollup/plugins/shebang.ts similarity index 100% rename from src/builder/plugins/shebang.ts rename to src/builders/rollup/plugins/shebang.ts diff --git a/src/builders/rollup/stub.ts b/src/builders/rollup/stub.ts new file mode 100644 index 00000000..06ea9866 --- /dev/null +++ b/src/builders/rollup/stub.ts @@ -0,0 +1,179 @@ +import { writeFile, mkdir } from "node:fs/promises"; +import { promises as fsp } from "node:fs"; +import { resolve, dirname, normalize, extname, relative } from "pathe"; +import { resolvePath, resolveModuleExportNames } from "mlly"; +import { warn } from "../../utils"; +import type { BuildContext } from "../../types"; +import { makeExecutable, getShebang } from "./plugins/shebang"; +import { DEFAULT_EXTENSIONS, resolveAliases } from "./utils"; + +export async function rollupStub(ctx: BuildContext) { + const babelPlugins = ctx.options.stubOptions.jiti.transformOptions?.babel + ?.plugins as any; + const importedBabelPlugins: Array = []; + const serializedJitiOptions = JSON.stringify( + { + ...ctx.options.stubOptions.jiti, + alias: { + ...resolveAliases(ctx), + ...ctx.options.stubOptions.jiti.alias, + }, + transformOptions: { + ...ctx.options.stubOptions.jiti.transformOptions, + babel: { + ...ctx.options.stubOptions.jiti.transformOptions?.babel, + plugins: "__$BABEL_PLUGINS", + }, + }, + }, + null, + 2, + ).replace( + '"__$BABEL_PLUGINS"', + Array.isArray(babelPlugins) + ? "[" + + babelPlugins + .map((plugin: string | Array, i) => { + if (Array.isArray(plugin)) { + const [name, ...args] = plugin; + importedBabelPlugins.push(name); + return ( + `[` + + [ + `plugin${i}`, + ...args.map((val) => JSON.stringify(val)), + ].join(", ") + + "]" + ); + } else { + importedBabelPlugins.push(plugin); + return `plugin${i}`; + } + }) + .join(",") + + "]" + : "[]", + ); + + for (const entry of ctx.options.entries.filter( + (entry) => entry.builder === "rollup", + )) { + const output = resolve( + ctx.options.rootDir, + ctx.options.outDir, + entry.name!, + ); + + const isESM = ctx.pkg.type === "module"; + const resolvedEntry = normalize( + ctx.jiti.esmResolve(entry.input, { try: true }) || entry.input, + ); + const resolvedEntryWithoutExt = resolvedEntry.slice( + 0, + Math.max(0, resolvedEntry.length - extname(resolvedEntry).length), + ); + const resolvedEntryForTypeImport = isESM + ? `${resolvedEntry.replace(/(\.m?)(ts)$/, "$1js")}` + : resolvedEntryWithoutExt; + const code = await fsp.readFile(resolvedEntry, "utf8"); + const shebang = getShebang(code); + + await mkdir(dirname(output), { recursive: true }); + + // CJS Stub + if (ctx.options.rollup.emitCJS) { + const jitiCJSPath = relative( + dirname(output), + await resolvePath("jiti", { + url: import.meta.url, + conditions: ["node", "require"], + }), + ); + await writeFile( + output + ".cjs", + shebang + + [ + `const { createJiti } = require(${JSON.stringify(jitiCJSPath)})`, + ...importedBabelPlugins.map( + (plugin, i) => + `const plugin${i} = require(${JSON.stringify(plugin)})`, + ), + "", + `const jiti = createJiti(__filename, ${serializedJitiOptions})`, + "", + `/** @type {import(${JSON.stringify( + resolvedEntryForTypeImport, + )})} */`, + `module.exports = jiti(${JSON.stringify(resolvedEntry)})`, + ].join("\n"), + ); + } + + // MJS Stub + // Try to analyze exports + const namedExports: string[] = await resolveModuleExportNames( + resolvedEntry, + { + extensions: DEFAULT_EXTENSIONS, + }, + ).catch((error) => { + warn(ctx, `Cannot analyze ${resolvedEntry} for exports:` + error); + return []; + }); + const hasDefaultExport = + namedExports.includes("default") || namedExports.length === 0; + + const jitiESMPath = relative( + dirname(output), + await resolvePath("jiti", { + url: import.meta.url, + conditions: ["node", "import"], + }), + ); + + await writeFile( + output + ".mjs", + shebang + + [ + `import { createJiti } from ${JSON.stringify(jitiESMPath)};`, + ...importedBabelPlugins.map( + (plugin, i) => `import plugin${i} from ${JSON.stringify(plugin)}`, + ), + "", + `const jiti = createJiti(import.meta.url, ${serializedJitiOptions})`, + "", + `/** @type {import(${JSON.stringify(resolvedEntryForTypeImport)})} */`, + `const _module = await jiti.import(${JSON.stringify( + resolvedEntry, + )});`, + hasDefaultExport ? "\nexport default _module;" : "", + ...namedExports + .filter((name) => name !== "default") + .map((name) => `export const ${name} = _module.${name};`), + ].join("\n"), + ); + + // DTS Stub + if (ctx.options.declaration) { + const dtsContent = [ + `export * from ${JSON.stringify(resolvedEntryForTypeImport)};`, + hasDefaultExport + ? `export { default } from ${JSON.stringify(resolvedEntryForTypeImport)};` + : "", + ].join("\n"); + await writeFile(output + ".d.cts", dtsContent); + await writeFile(output + ".d.mts", dtsContent); + if ( + ctx.options.declaration === "compatible" || + ctx.options.declaration === true + ) { + await writeFile(output + ".d.ts", dtsContent); + } + } + + if (shebang) { + await makeExecutable(output + ".cjs"); + await makeExecutable(output + ".mjs"); + } + } +} diff --git a/src/builders/rollup/utils.ts b/src/builders/rollup/utils.ts new file mode 100644 index 00000000..2e5c0272 --- /dev/null +++ b/src/builders/rollup/utils.ts @@ -0,0 +1,66 @@ +import type { PreRenderedChunk } from "rollup"; +import type { BuildContext } from "../../types"; + +export const DEFAULT_EXTENSIONS = [ + ".ts", + ".tsx", + ".mts", + ".cts", + ".mjs", + ".cjs", + ".js", + ".jsx", + ".json", +]; + +export function resolveAliases(ctx: BuildContext) { + const aliases: Record = { + [ctx.pkg.name!]: ctx.options.rootDir, + ...ctx.options.alias, + }; + + if (ctx.options.rollup.alias) { + if (Array.isArray(ctx.options.rollup.alias.entries)) { + Object.assign( + aliases, + Object.fromEntries( + ctx.options.rollup.alias.entries.map((entry) => { + return [entry.find, entry.replacement]; + }), + ), + ); + } else { + Object.assign( + aliases, + ctx.options.rollup.alias.entries || ctx.options.rollup.alias, + ); + } + } + + return aliases; +} + +// TODO: use pathe utils to handle nested aliases +export function resolveAlias( + id: string, + aliases: Record, +): string { + for (const [find, replacement] of Object.entries(aliases)) { + if (id.startsWith(find)) { + return id.replace(find, replacement); + } + } + return id; +} + +export function getChunkFilename( + ctx: BuildContext, + chunk: PreRenderedChunk, + ext: string, +) { + if (chunk.isDynamicEntry) { + return `chunks/[name].${ext}`; + } + // TODO: Find a way to generate human friendly hash for short groups + return `shared/${ctx.options.name}.[hash].${ext}`; +} diff --git a/src/builders/rollup/watch.ts b/src/builders/rollup/watch.ts new file mode 100644 index 00000000..074502bf --- /dev/null +++ b/src/builders/rollup/watch.ts @@ -0,0 +1,39 @@ +import { relative } from "pathe"; +import { watch as _rollupWatch } from "rollup"; +import type { RollupOptions } from "../../types"; +import consola from "consola"; +import chalk from "chalk"; + +export function rollupWatch(rollupOptions: RollupOptions) { + const watcher = _rollupWatch(rollupOptions); + + let inputs: string[]; + if (Array.isArray(rollupOptions.input)) { + inputs = rollupOptions.input; + } else if (typeof rollupOptions.input === "string") { + inputs = [rollupOptions.input]; + } else { + inputs = Object.keys(rollupOptions.input || {}); + } + consola.info( + `[unbuild] [rollup] Starting watchers for entries: ${inputs.map((input) => "./" + relative(process.cwd(), input)).join(", ")}`, + ); + + consola.warn( + "[unbuild] [rollup] Watch mode is experimental and may be unstable", + ); + + watcher.on("change", (id, { event }) => { + consola.info(`${chalk.cyan(relative(".", id))} was ${event}d`); + }); + + watcher.on("restart", () => { + consola.info(chalk.gray("[unbuild] [rollup] Rebuilding bundle")); + }); + + watcher.on("event", (event) => { + if (event.code === "END") { + consola.success(chalk.green("[unbuild] [rollup] Rebuild finished\n")); + } + }); +} diff --git a/src/builder/untyped.ts b/src/builders/untyped/index.ts similarity index 96% rename from src/builder/untyped.ts rename to src/builders/untyped/index.ts index 39ff3710..631f7dd7 100644 --- a/src/builder/untyped.ts +++ b/src/builders/untyped/index.ts @@ -9,7 +9,11 @@ import { // @ts-ignore import untypedPlugin from "untyped/babel-plugin"; import { pascalCase } from "scule"; -import type { BuildContext, UntypedBuildEntry, UntypedOutputs } from "../types"; +import type { + BuildContext, + UntypedBuildEntry, + UntypedOutputs, +} from "../../types"; import consola from "consola"; export async function typesBuild(ctx: BuildContext) { diff --git a/src/types.ts b/src/types.ts index 4f9f4f1a..0826f838 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,7 @@ import type { RollupJsonOptions } from "@rollup/plugin-json"; import type { Options as RollupDtsOptions } from "rollup-plugin-dts"; import type commonjs from "@rollup/plugin-commonjs"; import type { Jiti, JitiOptions } from "jiti"; -import type { EsbuildOptions } from "./builder/plugins/esbuild"; +import type { EsbuildOptions } from "./builders/rollup/plugins/esbuild"; // eslint-disable-next-line @typescript-eslint/ban-types export type RollupCommonJSOptions = Parameters[0] & {};