From 52ef5fd3ae009b3eb19fa52a40f075eafdcc6262 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 02:19:24 +0800 Subject: [PATCH 01/12] feat!: move all CSS support to @tsdown/css package BREAKING CHANGE: Basic CSS support has been removed from the core tsdown package. All CSS processing now requires @tsdown/css to be installed. - Moved CSS types, config resolution, and processing utilities to @tsdown/css - Removed tsdown/css public export path - Added guard plugin (transform.order=post) that errors when CSS files are encountered without @tsdown/css installed - Projects without CSS files are unaffected --- dts.snapshot.json | 50 +--- package.json | 9 +- packages/css/src/index.ts | 276 +----------------- packages/css/src/lightningcss.ts | 2 +- .../index.ts => packages/css/src/options.ts | 37 ++- packages/css/src/plugin.ts | 275 +++++++++++++++++ .../features/css => packages/css/src}/post.ts | 2 +- packages/css/src/postcss.ts | 2 +- packages/css/src/preprocessors.ts | 2 +- .../css => packages/css/src}/pure-chunk.ts | 0 packages/css/src/utils.ts | 6 + src/config/options.ts | 3 +- src/config/types.ts | 21 +- src/css.ts | 8 - src/features/css/plugin.ts | 41 --- src/features/pkg/exports.test.ts | 13 +- src/features/pkg/exports.ts | 10 +- src/features/rolldown.ts | 43 ++- tsdown.config.ts | 2 +- 19 files changed, 363 insertions(+), 439 deletions(-) rename src/features/css/index.ts => packages/css/src/options.ts (88%) create mode 100644 packages/css/src/plugin.ts rename {src/features/css => packages/css/src}/post.ts (99%) rename {src/features/css => packages/css/src}/pure-chunk.ts (100%) create mode 100644 packages/css/src/utils.ts delete mode 100644 src/css.ts delete mode 100644 src/features/css/plugin.ts diff --git a/dts.snapshot.json b/dts.snapshot.json index ecfd03250..569a8383d 100644 --- a/dts.snapshot.json +++ b/dts.snapshot.json @@ -1,5 +1,5 @@ { - "config-!~{00f}~.d.mts": { + "config-!~{00b}~.d.mts": { "defineConfig": "declare function defineConfig(_: UserConfigExport): UserConfigExport", "mergeConfig": "declare function mergeConfig(_: InlineConfig, _: InlineConfig): InlineConfig", "resolveUserConfig": "declare function resolveUserConfig(_: UserConfig, _: InlineConfig): Promise" @@ -13,37 +13,6 @@ "mergeConfig" ] }, - "css.d.mts": { - "CssPostPlugin": "declare function CssPostPlugin(_: Pick, _: CssStyles): Plugin", - "CssStyles": "type CssStyles = Map", - "getCleanId": "declare function getCleanId(_: string): string", - "RE_CSS": "RegExp", - "#exports": [ - "CssPostPlugin", - "CssStyles", - "LightningCSSOptions", - "PostCSSOptions", - "PreprocessorOptions", - "RE_CSS", - "getCleanId" - ] - }, - "index-!~{00d}~.d.mts": { - "Arrayable": "type Arrayable = T | T[]", - "Awaitable": "type Awaitable = T | Promise", - "CssOptions": "interface CssOptions {\n splitting?: boolean\n fileName?: string\n target?: string | string[] | false\n preprocessorOptions?: PreprocessorOptions\n minify?: boolean\n lightningcss?: LightningCSSOptions\n postcss?: PostCSSOptions\n inject?: boolean\n transformer?: 'postcss' | 'lightningcss'\n}", - "LessPreprocessorOptions": "interface LessPreprocessorOptions {\n additionalData?: PreprocessorAdditionalData\n math?: any\n paths?: string[]\n plugins?: any[]\n [key: string]: any\n}", - "LightningCSSOptions": "type LightningCSSOptions = Record", - "MarkPartial": "type MarkPartial = Omit, K> & Partial>", - "Overwrite": "type Overwrite = Omit & U", - "PostCSSOptions": "type PostCSSOptions = string | (Record & { plugins?: any[] })", - "PreprocessorAdditionalData": "type PreprocessorAdditionalData = string | ((_: string, _: string) => PreprocessorAdditionalDataResult | Promise)", - "PreprocessorAdditionalDataResult": "type PreprocessorAdditionalDataResult = string | { content: string; map?: any }", - "PreprocessorOptions": "interface PreprocessorOptions {\n scss?: SassPreprocessorOptions\n sass?: SassPreprocessorOptions\n less?: LessPreprocessorOptions\n styl?: StylusPreprocessorOptions\n stylus?: StylusPreprocessorOptions\n}", - "ResolvedCssOptions": "type ResolvedCssOptions = Overwrite, 'preprocessorOptions' | 'lightningcss' | 'postcss'>, { target?: string[] }>", - "SassPreprocessorOptions": "interface SassPreprocessorOptions {\n additionalData?: PreprocessorAdditionalData\n [key: string]: any\n}", - "StylusPreprocessorOptions": "interface StylusPreprocessorOptions {\n additionalData?: PreprocessorAdditionalData\n define?: Record\n paths?: string[]\n [key: string]: any\n}" - }, "index.d.mts": { "build": "declare function build(_: InlineConfig): Promise", "buildWithConfigs": "declare function buildWithConfigs(_: ResolvedConfig[], _: string[], _: () => void): Promise", @@ -58,7 +27,6 @@ "CopyEntry", "CopyOptions", "CopyOptionsFn", - "CssOptions", "DepsConfig", "DevtoolsOptions", "DtsOptions", @@ -66,8 +34,6 @@ "ExportsOptions", "Format", "InlineConfig", - "LessPreprocessorOptions", - "LightningCSSOptions", "Logger", "NoExternalFn", "NormalizedFormat", @@ -76,19 +42,15 @@ "OutExtensionObject", "PackageJsonWithPath", "PackageType", - "PreprocessorOptions", "PublintOptions", "ReportOptions", "ResolvedConfig", - "ResolvedCssOptions", "ResolvedDepsConfig", "Rolldown", "RolldownChunk", "RolldownContext", - "SassPreprocessorOptions", "SeaConfig", "Sourcemap", - "StylusPreprocessorOptions", "TreeshakingOptions", "TsdownBundle", "TsdownHooks", @@ -123,8 +85,10 @@ "run.d.mts": { "#exports": [] }, - "types-!~{00e}~.d.mts": { + "types-!~{00a}~.d.mts": { + "Arrayable": "type Arrayable = T | T[]", "AttwOptions": "interface AttwOptions extends CheckPackageOptions {\n profile?: 'strict' | 'node16' | 'esm-only'\n level?: 'error' | 'warn'\n ignoreRules?: string[]\n}", + "Awaitable": "type Awaitable = T | Promise", "BuildContext": "interface BuildContext {\n options: ResolvedConfig\n hooks: Hookable\n}", "ChunkAddon": "type ChunkAddon = ChunkAddonObject | ChunkAddonFunction | string", "ChunkAddonFunction": "type ChunkAddonFunction = (_: { format: Format; fileName: string }) => ChunkAddonObject | string | undefined", @@ -146,11 +110,13 @@ "LoggerOptions": "interface LoggerOptions {\n allowClearScreen?: boolean\n customLogger?: Logger\n console?: Console\n failOnWarn?: boolean\n}", "LogLevel": "type LogLevel = LogType | 'silent'", "LogType": "type LogType = 'error' | 'warn' | 'info'", + "MarkPartial": "type MarkPartial = Omit, K> & Partial>", "NoExternalFn": "type NoExternalFn = (_: string, _: string | undefined) => boolean | null | undefined | void", "NormalizedFormat": "type NormalizedFormat = InternalModuleFormat", "OutExtensionContext": "interface OutExtensionContext {\n options: InputOptions\n format: NormalizedFormat\n pkgType?: PackageType\n}", "OutExtensionFactory": "type OutExtensionFactory = (_: OutExtensionContext) => OutExtensionObject | undefined", "OutExtensionObject": "interface OutExtensionObject {\n js?: string\n dts?: string\n}", + "Overwrite": "type Overwrite = Omit & U", "PackageJson": "interface PackageJson {\n name?: string\n version?: string\n description?: string\n keywords?: string[]\n homepage?: string\n bugs?: string | { url?: string; email?: string }\n license?: string\n repository?: string | { type: string; url: string; directory?: string }\n scripts?: PackageJsonScripts\n private?: boolean\n author?: PackageJsonPerson\n contributors?: PackageJsonPerson[]\n funding?: PackageJsonFunding | PackageJsonFunding[]\n files?: string[]\n main?: string\n browser?: string | Record\n unpkg?: string\n bin?: string | Record\n man?: string | string[]\n dependencies?: Record\n devDependencies?: Record\n optionalDependencies?: Record\n peerDependencies?: Record\n types?: string\n typings?: string\n module?: string\n type?: 'module' | 'commonjs'\n exports?: PackageJsonExports\n imports?: Record>\n workspaces?: string[] | { packages?: string[]; nohoist?: string[] }\n typesVersions?: Record>\n os?: string[]\n cpu?: string[]\n publishConfig?: { registry?: string; tag?: string; access?: 'public' | 'restricted'; executableFiles?: string[]; directory?: string; linkDirectory?: boolean } & Pick\n packageManager?: string\n [key: string]: any\n}", "PackageJsonCommonScripts": "type PackageJsonCommonScripts = 'build' | 'coverage' | 'deploy' | 'dev' | 'format' | 'lint' | 'preview' | 'release' | 'typecheck' | 'watch'", "PackageJsonExportKey": "type PackageJsonExportKey = '.' | 'import' | 'require' | 'types' | 'node' | 'browser' | 'default' | (string & {})", @@ -168,7 +134,7 @@ "PublintOptions": "interface PublintOptions extends Omit {}", "ReportOptions": "interface ReportOptions {\n gzip?: boolean\n brotli?: boolean\n maxCompressSize?: number\n}", "ReportPlugin": "declare function ReportPlugin(_: ResolvedConfig, _: boolean, _: boolean): Plugin", - "ResolvedConfig": "type ResolvedConfig = Overwrite, 'globalName' | 'inputOptions' | 'outputOptions' | 'minify' | 'define' | 'alias' | 'onSuccess' | 'outExtensions' | 'hooks' | 'copy' | 'loader' | 'name' | 'banner' | 'footer' | 'checks'>, { entry: Record; rawEntry?: TsdownInputOption; nameLabel: string | undefined; format: NormalizedFormat; target?: string[]; clean: string[]; pkg?: PackageJsonWithPath; nodeProtocol: 'strip' | boolean; logger: Logger; ignoreWatch: Array; deps: ResolvedDepsConfig; css: ResolvedCssOptions; dts: false | DtsOptions; report: false | ReportOptions; tsconfig: false | string; exports: false | ExportsOptions; devtools: false | DevtoolsOptions; publint: false | PublintOptions; attw: false | AttwOptions; unused: false | UnusedOptions; exe: false | ExeOptions }>", + "ResolvedConfig": "type ResolvedConfig = Overwrite, 'globalName' | 'inputOptions' | 'outputOptions' | 'minify' | 'define' | 'alias' | 'onSuccess' | 'outExtensions' | 'hooks' | 'copy' | 'loader' | 'name' | 'banner' | 'footer' | 'checks' | 'css'>, { entry: Record; rawEntry?: TsdownInputOption; nameLabel: string | undefined; format: NormalizedFormat; target?: string[]; clean: string[]; pkg?: PackageJsonWithPath; nodeProtocol: 'strip' | boolean; logger: Logger; ignoreWatch: Array; deps: ResolvedDepsConfig; dts: false | DtsOptions; report: false | ReportOptions; tsconfig: false | string; exports: false | ExportsOptions; devtools: false | DevtoolsOptions; publint: false | PublintOptions; attw: false | AttwOptions; unused: false | UnusedOptions; exe: false | ExeOptions }>", "ResolvedDepsConfig": "interface ResolvedDepsConfig {\n neverBundle?: ExternalOption\n alwaysBundle?: NoExternalFn\n onlyAllowBundle?: Array | false\n skipNodeModulesBundle: boolean\n}", "RolldownChunk": "type RolldownChunk = (OutputChunk | OutputAsset) & { outDir: string }", "RolldownContext": "interface RolldownContext {\n buildOptions: BuildOptions\n}", @@ -177,7 +143,7 @@ "TsdownBundle": "interface TsdownBundle extends AsyncDisposable {\n chunks: RolldownChunk[]\n config: ResolvedConfig\n inlinedDeps: Map>\n}", "TsdownHooks": "interface TsdownHooks {\n 'build:prepare': (_: BuildContext) => void | Promise\n 'build:before': (_: BuildContext & RolldownContext) => void | Promise\n 'build:done': (_: BuildContext & { chunks: RolldownChunk[] }) => void | Promise\n}", "TsdownInputOption": "type TsdownInputOption = Arrayable>>", - "UserConfig": "interface UserConfig {\n entry?: TsdownInputOption\n deps?: DepsConfig\n external?: ExternalOption\n noExternal?: Arrayable | NoExternalFn\n inlineOnly?: Arrayable | false\n skipNodeModulesBundle?: boolean\n alias?: Record\n tsconfig?: string | boolean\n platform?: 'node' | 'neutral' | 'browser'\n target?: string | string[] | false\n env?: Record\n envFile?: string\n envPrefix?: string | string[]\n define?: Record\n shims?: boolean\n treeshake?: boolean | TreeshakingOptions\n loader?: ModuleTypes\n removeNodeProtocol?: boolean\n nodeProtocol?: 'strip' | boolean\n checks?: ChecksOptions & { legacyCjs?: boolean }\n plugins?: InputOptions['plugins']\n inputOptions?: InputOptions | ((_: InputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable)\n format?: Format | Format[] | Partial>>\n globalName?: string\n outDir?: string\n write?: boolean\n sourcemap?: Sourcemap\n clean?: boolean | string[]\n minify?: boolean | 'dce-only' | MinifyOptions\n footer?: ChunkAddon\n banner?: ChunkAddon\n unbundle?: boolean\n bundle?: boolean\n fixedExtension?: boolean\n outExtensions?: OutExtensionFactory\n hash?: boolean\n cjsDefault?: boolean\n outputOptions?: OutputOptions | ((_: OutputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable)\n cwd?: string\n name?: string\n logLevel?: LogLevel\n failOnWarn?: boolean | CIOption\n customLogger?: Logger\n fromVite?: boolean | 'vitest'\n watch?: boolean | Arrayable\n ignoreWatch?: Arrayable\n devtools?: WithEnabled\n onSuccess?: string | ((_: ResolvedConfig, _: AbortSignal) => void | Promise)\n dts?: WithEnabled\n unused?: WithEnabled\n publint?: WithEnabled\n attw?: WithEnabled\n report?: WithEnabled\n globImport?: boolean\n exports?: WithEnabled\n css?: CssOptions\n injectStyle?: boolean\n publicDir?: CopyOptions | CopyOptionsFn\n copy?: CopyOptions | CopyOptionsFn\n hooks?: Partial | ((_: Hookable) => Awaitable)\n exe?: WithEnabled\n workspace?: Workspace | Arrayable | true\n}", + "UserConfig": "interface UserConfig {\n entry?: TsdownInputOption\n deps?: DepsConfig\n external?: ExternalOption\n noExternal?: Arrayable | NoExternalFn\n inlineOnly?: Arrayable | false\n skipNodeModulesBundle?: boolean\n alias?: Record\n tsconfig?: string | boolean\n platform?: 'node' | 'neutral' | 'browser'\n target?: string | string[] | false\n env?: Record\n envFile?: string\n envPrefix?: string | string[]\n define?: Record\n shims?: boolean\n treeshake?: boolean | TreeshakingOptions\n loader?: ModuleTypes\n removeNodeProtocol?: boolean\n nodeProtocol?: 'strip' | boolean\n checks?: ChecksOptions & { legacyCjs?: boolean }\n plugins?: InputOptions['plugins']\n inputOptions?: InputOptions | ((_: InputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable)\n format?: Format | Format[] | Partial>>\n globalName?: string\n outDir?: string\n write?: boolean\n sourcemap?: Sourcemap\n clean?: boolean | string[]\n minify?: boolean | 'dce-only' | MinifyOptions\n footer?: ChunkAddon\n banner?: ChunkAddon\n unbundle?: boolean\n bundle?: boolean\n fixedExtension?: boolean\n outExtensions?: OutExtensionFactory\n hash?: boolean\n cjsDefault?: boolean\n outputOptions?: OutputOptions | ((_: OutputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable)\n cwd?: string\n name?: string\n logLevel?: LogLevel\n failOnWarn?: boolean | CIOption\n customLogger?: Logger\n fromVite?: boolean | 'vitest'\n watch?: boolean | Arrayable\n ignoreWatch?: Arrayable\n devtools?: WithEnabled\n onSuccess?: string | ((_: ResolvedConfig, _: AbortSignal) => void | Promise)\n dts?: WithEnabled\n unused?: WithEnabled\n publint?: WithEnabled\n attw?: WithEnabled\n report?: WithEnabled\n globImport?: boolean\n exports?: WithEnabled\n css?: _tsdown_css0.CssOptions\n injectStyle?: boolean\n publicDir?: CopyOptions | CopyOptionsFn\n copy?: CopyOptions | CopyOptionsFn\n hooks?: Partial | ((_: Hookable) => Awaitable)\n exe?: WithEnabled\n workspace?: Workspace | Arrayable | true\n}", "UserConfigExport": "type UserConfigExport = Awaitable | UserConfigFn>", "UserConfigFn": "type UserConfigFn = (_: InlineConfig, _: { ci: boolean }) => Awaitable>", "WithEnabled": "type WithEnabled = boolean | undefined | CIOption | (T & { enabled?: boolean | CIOption })", diff --git a/package.json b/package.json index d8d683579..bd1f6d973 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,6 @@ "dev": "./src/config.ts", "default": "./dist/config.mjs" }, - "./css": { - "dev": "./src/css.ts", - "default": "./dist/css.mjs" - }, "./plugins": { "dev": "./src/plugins.ts", "default": "./dist/plugins.mjs" @@ -61,7 +57,6 @@ "exports": { ".": "./dist/index.mjs", "./config": "./dist/config.mjs", - "./css": "./dist/css.mjs", "./plugins": "./dist/plugins.mjs", "./run": "./dist/run.mjs", "./package.json": "./package.json", @@ -74,8 +69,8 @@ "scripts": { "lint": "eslint --cache --max-warnings 0 .", "lint:fix": "pnpm run lint --fix", - "build": "unrun ./src/run.ts", - "dev": "node ./src/run.ts", + "build": "node -C dev ./src/run.ts", + "dev": "pnpm run build", "test": "vitest", "typecheck": "tsgo --noEmit", "format": "prettier --cache --write .", diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts index 9da2afa89..73db5a107 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -1,262 +1,14 @@ -import path from 'node:path' -import { CssPostPlugin, getCleanId, RE_CSS, type CssStyles } from 'tsdown/css' -import { - bundleWithLightningCSS, - transformWithLightningCSS, -} from './lightningcss.ts' -import { processWithPostCSS as runPostCSS } from './postcss.ts' -import { compilePreprocessor, getPreprocessorLang } from './preprocessors.ts' -import type { MinimalLogger } from './types.ts' -import type { Plugin } from 'rolldown' -import type { ResolvedConfig } from 'tsdown' - -const CSS_LANGS_RE = /\.(?:css|less|sass|scss|styl|stylus)$/ - -export function CssPlugin( - config: ResolvedConfig, - { logger }: { logger: MinimalLogger }, -): Plugin[] { - const styles: CssStyles = new Map() - - const transformPlugin: Plugin = { - name: '@tsdown/css', - - buildStart() { - styles.clear() - }, - - transform: { - filter: { id: CSS_LANGS_RE }, - async handler(code, id) { - const cleanId = getCleanId(id) - const deps: string[] = [] - - if (config.css.transformer === 'lightningcss') { - code = await processWithLightningCSS( - code, - id, - cleanId, - deps, - config, - logger, - ) - } else { - code = await processWithPostCSS(code, id, cleanId, deps, config) - } - - for (const dep of deps) { - this.addWatchFile(dep) - } - - if (code.length && !code.endsWith('\n')) { - code += '\n' - } - - styles.set(id, code) - return { - code: '', - moduleSideEffects: 'no-treeshake', - moduleType: 'js', - } - }, - }, - } - - const plugins: Plugin[] = [transformPlugin] - - if (config.css.inject) { - // Inject plugin runs BEFORE CssPostPlugin so it can see pure CSS chunks - // before they are removed, and rewrite their imports to CSS asset paths. - const injectPlugin: Plugin = { - name: '@tsdown/css:inject', - - generateBundle(_outputOptions, bundle) { - const chunks = Object.values(bundle) - // Identify pure CSS chunks and empty CSS wrapper chunks - const pureCssChunks = new Set() - for (const chunk of chunks) { - if ( - chunk.type !== 'chunk' || - chunk.exports.length || - !chunk.moduleIds.length || - chunk.isEntry || - chunk.isDynamicEntry - ) - continue - // Strict: all modules are CSS - if (chunk.moduleIds.every((id) => styles.has(id))) { - pureCssChunks.add(chunk.fileName) - continue - } - // Relaxed: chunk has CSS modules and code is trivially empty - // (e.g. a JS file whose only purpose is `import './foo.css'`) - if ( - chunk.moduleIds.some((id) => styles.has(id)) && - isEmptyChunkCode(chunk.code) - ) { - pureCssChunks.add(chunk.fileName) - } - } - - for (const chunk of chunks) { - if (chunk.type !== 'chunk') continue - if (pureCssChunks.has(chunk.fileName)) continue - - if (config.css.splitting) { - // Rewrite pure CSS chunk imports in-place: swap .mjs/.cjs/.js → .css - // This preserves import order and sourcemap line positions. - for (const imp of chunk.imports) { - if (!pureCssChunks.has(imp)) continue - const basename = path.basename(imp) - const escaped = basename.replaceAll( - /[.*+?^${}()|[\]\\]/g, - String.raw`\$&`, - ) - const cssBasename = basename.replace(/\.[cm]?js$/, '.css') - const importRE = new RegExp( - String.raw`(\bimport\s*["'][^"']*)${escaped}(["'];)`, - ) - chunk.code = chunk.code.replace(importRE, `$1${cssBasename}$2`) - } - // Direct CSS modules in this chunk need a prepended import - if (chunk.moduleIds.some((id) => styles.has(id))) { - const cssFile = chunk.fileName.replace(/\.[cm]?js$/, '.css') - const relativePath = path.posix.relative( - path.posix.dirname(chunk.fileName), - cssFile, - ) - const importPath = - relativePath[0] === '.' ? relativePath : `./${relativePath}` - chunk.code = `import '${importPath}';\n${chunk.code}` - if (chunk.map) { - chunk.map.mappings = `;${chunk.map.mappings}` - } - } - } else { - const hasCss = - chunk.moduleIds.some((id) => styles.has(id)) || - chunk.imports.some((imp) => pureCssChunks.has(imp)) - if (hasCss) { - const cssFile = config.css.fileName - const relativePath = path.posix.relative( - path.posix.dirname(chunk.fileName), - cssFile, - ) - const importPath = - relativePath[0] === '.' ? relativePath : `./${relativePath}` - chunk.code = `import '${importPath}';\n${chunk.code}` - if (chunk.map) { - chunk.map.mappings = `;${chunk.map.mappings}` - } - } - } - } - }, - } - plugins.push(injectPlugin) - } - - plugins.push(CssPostPlugin(config.css, styles)) - return plugins -} - -async function processWithLightningCSS( - code: string, - id: string, - cleanId: string, - deps: string[], - config: ResolvedConfig, - logger: MinimalLogger, -): Promise { - const lang = getPreprocessorLang(id) - - if (lang) { - const preResult = await compilePreprocessor( - lang, - code, - cleanId, - config.css.preprocessorOptions, - ) - deps.push(...preResult.deps) - - return transformWithLightningCSS(preResult.code, cleanId, { - target: config.css.target, - lightningcss: config.css.lightningcss, - minify: config.css.minify, - }) - } - - // Virtual modules (with query strings) can't use file-based bundling - if (id !== cleanId) { - return transformWithLightningCSS(code, cleanId, { - target: config.css.target, - lightningcss: config.css.lightningcss, - minify: config.css.minify, - }) - } - - if (RE_CSS.test(cleanId)) { - const bundleResult = await bundleWithLightningCSS( - cleanId, - { - target: config.css.target, - lightningcss: config.css.lightningcss, - minify: config.css.minify, - preprocessorOptions: config.css.preprocessorOptions, - logger, - }, - code, - ) - deps.push(...bundleResult.deps) - return bundleResult.code - } - - return '' -} - -async function processWithPostCSS( - code: string, - id: string, - cleanId: string, - deps: string[], - config: ResolvedConfig, -): Promise { - const lang = getPreprocessorLang(id) - - if (lang) { - const preResult = await compilePreprocessor( - lang, - code, - cleanId, - config.css.preprocessorOptions, - ) - code = preResult.code - deps.push(...preResult.deps) - } - - const needInlineImport = code.includes('@import') - const postcssResult = await runPostCSS( - code, - cleanId, - config.css.postcss, - config.cwd, - needInlineImport, - ) - code = postcssResult.code - deps.push(...postcssResult.deps) - - return transformWithLightningCSS(code, cleanId, { - target: config.css.target, - lightningcss: config.css.lightningcss, - minify: config.css.minify, - }) -} - -function isEmptyChunkCode(code: string): boolean { - return !code - .replaceAll(/\/\*[\s\S]*?\*\//g, '') - .replaceAll(/\/\/[^\n]*/g, '') - .replaceAll(/\bexport\s*\{\s*\};?/g, '') - .replaceAll(/\bimport\s*["'][^"']*["'];?/g, '') - .trim() -} +export { resolveCssOptions } from './options.ts' +export { CssPlugin } from './plugin.ts' +export type { + CssOptions, + LessPreprocessorOptions, + LightningCSSOptions, + PostCSSOptions, + PreprocessorAdditionalData, + PreprocessorAdditionalDataResult, + PreprocessorOptions, + ResolvedCssOptions, + SassPreprocessorOptions, + StylusPreprocessorOptions, +} from './options.ts' diff --git a/packages/css/src/lightningcss.ts b/packages/css/src/lightningcss.ts index 08c95ec1b..446c84d2f 100644 --- a/packages/css/src/lightningcss.ts +++ b/packages/css/src/lightningcss.ts @@ -2,9 +2,9 @@ import { readFileSync } from 'node:fs' import path from 'node:path' import { ResolverFactory } from 'rolldown/experimental' import { compilePreprocessor, getPreprocessorLang } from './preprocessors.ts' +import type { LightningCSSOptions, PreprocessorOptions } from './options.ts' import type { MinimalLogger } from './types.ts' import type { Targets } from 'lightningcss' -import type { LightningCSSOptions, PreprocessorOptions } from 'tsdown/css' let resolver: ResolverFactory | undefined function getResolver(): ResolverFactory { diff --git a/src/features/css/index.ts b/packages/css/src/options.ts similarity index 88% rename from src/features/css/index.ts rename to packages/css/src/options.ts index 689e3f4b2..58941b85a 100644 --- a/src/features/css/index.ts +++ b/packages/css/src/options.ts @@ -1,6 +1,3 @@ -import { resolveComma, toArray } from '../../utils/general.ts' -import type { MarkPartial, Overwrite } from '../../utils/types.ts' - export interface CssOptions { /** * Enable/disable CSS code splitting. @@ -21,8 +18,6 @@ export interface CssOptions { * Accepts esbuild-style target strings (e.g., `'chrome99'`, `'safari16.2'`). * Defaults to the top-level `target` option. * - * Requires `@tsdown/css` to be installed. - * * @see https://vite.dev/config/build-options#build-csstarget */ target?: string | string[] | false @@ -32,24 +27,18 @@ export interface CssOptions { * * In addition to options specific to each processor, `additionalData` option * can be used to inject extra code for each style content. - * - * Requires `@tsdown/css` to be installed. */ preprocessorOptions?: PreprocessorOptions /** * Enable/disable CSS minification. * - * Requires `@tsdown/css` to be installed. - * * @default false */ minify?: boolean /** * Lightning CSS options for CSS syntax lowering and transformations. - * - * Requires `@tsdown/css` to be installed. */ lightningcss?: LightningCSSOptions @@ -61,7 +50,7 @@ export interface CssOptions { * - Omitted: Auto-detect PostCSS config from the project root. * * Only used when `transformer` is `'postcss'`. - * Requires `postcss` and `@tsdown/css` to be installed. + * Requires `postcss` to be installed. * * @see https://github.com/postcss/postcss */ @@ -71,8 +60,6 @@ export interface CssOptions { * When enabled, JS output preserves import statements pointing to emitted CSS files. * Consumers of the library will automatically import the CSS alongside the JS. * - * Requires `@tsdown/css` to be installed. - * * @default false */ inject?: boolean @@ -86,8 +73,6 @@ export interface CssOptions { * PostCSS plugins applied, Lightning CSS used only for final * targets/minify transform. * - * Requires `@tsdown/css` to be installed. - * * @default 'lightningcss' * @see https://vite.dev/config/shared-options#css-transformer */ @@ -149,6 +134,18 @@ export type ResolvedCssOptions = Overwrite< { target?: string[] } > +// export interface ResolvedCssOptions { +// transformer: 'postcss' | 'lightningcss' +// splitting: boolean +// fileName: string +// minify: boolean +// inject: boolean +// target?: string[] +// preprocessorOptions?: PreprocessorOptions +// lightningcss?: LightningCSSOptions +// postcss?: PostCSSOptions +// } + export const defaultCssBundleName = 'style.css' export function resolveCssOptions( @@ -176,3 +173,11 @@ export function resolveCssOptions( postcss: options.postcss, } } + +function toArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value] +} + +function resolveComma(items: string[]): string[] { + return items.flatMap((item) => item.split(',')) +} diff --git a/packages/css/src/plugin.ts b/packages/css/src/plugin.ts new file mode 100644 index 000000000..03cef234d --- /dev/null +++ b/packages/css/src/plugin.ts @@ -0,0 +1,275 @@ +import path from 'node:path' +import { + bundleWithLightningCSS, + transformWithLightningCSS, +} from './lightningcss.ts' +import { resolveCssOptions, type ResolvedCssOptions } from './options.ts' +import { CssPostPlugin, type CssStyles } from './post.ts' +import { processWithPostCSS as runPostCSS } from './postcss.ts' +import { compilePreprocessor, getPreprocessorLang } from './preprocessors.ts' +import { getCleanId, RE_CSS } from './utils.ts' +import type { MinimalLogger } from './types.ts' +import type { Plugin } from 'rolldown' +import type { ResolvedConfig } from 'tsdown' + +const CSS_LANGS_RE = /\.(?:css|less|sass|scss|styl|stylus)$/ + +interface CssPluginConfig { + css: ResolvedCssOptions + cwd: string + target?: string[] +} + +export function CssPlugin( + config: ResolvedConfig, + { logger }: { logger: MinimalLogger }, +): Plugin[] { + const cssConfig: CssPluginConfig = { + css: resolveCssOptions(config.css, config.target), + cwd: config.cwd, + target: config.target, + } + const styles: CssStyles = new Map() + + const transformPlugin: Plugin = { + name: '@tsdown/css', + + buildStart() { + styles.clear() + }, + + transform: { + filter: { id: CSS_LANGS_RE }, + async handler(code, id) { + const cleanId = getCleanId(id) + const deps: string[] = [] + + if (cssConfig.css.transformer === 'lightningcss') { + code = await processWithLightningCSS( + code, + id, + cleanId, + deps, + cssConfig, + logger, + ) + } else { + code = await processWithPostCSS(code, id, cleanId, deps, cssConfig) + } + + for (const dep of deps) { + this.addWatchFile(dep) + } + + if (code.length && !code.endsWith('\n')) { + code += '\n' + } + + styles.set(id, code) + return { + code: '', + moduleSideEffects: 'no-treeshake', + moduleType: 'js', + } + }, + }, + } + + const plugins: Plugin[] = [transformPlugin] + + if (cssConfig.css.inject) { + // Inject plugin runs BEFORE CssPostPlugin so it can see pure CSS chunks + // before they are removed, and rewrite their imports to CSS asset paths. + const injectPlugin: Plugin = { + name: '@tsdown/css:inject', + + generateBundle(_outputOptions, bundle) { + const chunks = Object.values(bundle) + // Identify pure CSS chunks and empty CSS wrapper chunks + const pureCssChunks = new Set() + for (const chunk of chunks) { + if ( + chunk.type !== 'chunk' || + chunk.exports.length || + !chunk.moduleIds.length || + chunk.isEntry || + chunk.isDynamicEntry + ) + continue + // Strict: all modules are CSS + if (chunk.moduleIds.every((id) => styles.has(id))) { + pureCssChunks.add(chunk.fileName) + continue + } + // Relaxed: chunk has CSS modules and code is trivially empty + // (e.g. a JS file whose only purpose is `import './foo.css'`) + if ( + chunk.moduleIds.some((id) => styles.has(id)) && + isEmptyChunkCode(chunk.code) + ) { + pureCssChunks.add(chunk.fileName) + } + } + + for (const chunk of chunks) { + if (chunk.type !== 'chunk') continue + if (pureCssChunks.has(chunk.fileName)) continue + + if (cssConfig.css.splitting) { + // Rewrite pure CSS chunk imports in-place: swap .mjs/.cjs/.js → .css + // This preserves import order and sourcemap line positions. + for (const imp of chunk.imports) { + if (!pureCssChunks.has(imp)) continue + const basename = path.basename(imp) + const escaped = basename.replaceAll( + /[.*+?^${}()|[\]\\]/g, + String.raw`\$&`, + ) + const cssBasename = basename.replace(/\.[cm]?js$/, '.css') + const importRE = new RegExp( + String.raw`(\bimport\s*["'][^"']*)${escaped}(["'];)`, + ) + chunk.code = chunk.code.replace(importRE, `$1${cssBasename}$2`) + } + // Direct CSS modules in this chunk need a prepended import + if (chunk.moduleIds.some((id) => styles.has(id))) { + const cssFile = chunk.fileName.replace(/\.[cm]?js$/, '.css') + const relativePath = path.posix.relative( + path.posix.dirname(chunk.fileName), + cssFile, + ) + const importPath = + relativePath[0] === '.' ? relativePath : `./${relativePath}` + chunk.code = `import '${importPath}';\n${chunk.code}` + if (chunk.map) { + chunk.map.mappings = `;${chunk.map.mappings}` + } + } + } else { + const hasCss = + chunk.moduleIds.some((id) => styles.has(id)) || + chunk.imports.some((imp) => pureCssChunks.has(imp)) + if (hasCss) { + const cssFile = cssConfig.css.fileName + const relativePath = path.posix.relative( + path.posix.dirname(chunk.fileName), + cssFile, + ) + const importPath = + relativePath[0] === '.' ? relativePath : `./${relativePath}` + chunk.code = `import '${importPath}';\n${chunk.code}` + if (chunk.map) { + chunk.map.mappings = `;${chunk.map.mappings}` + } + } + } + } + }, + } + plugins.push(injectPlugin) + } + + plugins.push(CssPostPlugin(cssConfig.css, styles)) + return plugins +} + +async function processWithLightningCSS( + code: string, + id: string, + cleanId: string, + deps: string[], + config: CssPluginConfig, + logger: MinimalLogger, +): Promise { + const lang = getPreprocessorLang(id) + + if (lang) { + const preResult = await compilePreprocessor( + lang, + code, + cleanId, + config.css.preprocessorOptions, + ) + deps.push(...preResult.deps) + + return transformWithLightningCSS(preResult.code, cleanId, { + target: config.css.target, + lightningcss: config.css.lightningcss, + minify: config.css.minify, + }) + } + + // Virtual modules (with query strings) can't use file-based bundling + if (id !== cleanId) { + return transformWithLightningCSS(code, cleanId, { + target: config.css.target, + lightningcss: config.css.lightningcss, + minify: config.css.minify, + }) + } + + if (RE_CSS.test(cleanId)) { + const bundleResult = await bundleWithLightningCSS( + cleanId, + { + target: config.css.target, + lightningcss: config.css.lightningcss, + minify: config.css.minify, + preprocessorOptions: config.css.preprocessorOptions, + logger, + }, + code, + ) + deps.push(...bundleResult.deps) + return bundleResult.code + } + + return '' +} + +async function processWithPostCSS( + code: string, + id: string, + cleanId: string, + deps: string[], + config: CssPluginConfig, +): Promise { + const lang = getPreprocessorLang(id) + + if (lang) { + const preResult = await compilePreprocessor( + lang, + code, + cleanId, + config.css.preprocessorOptions, + ) + code = preResult.code + deps.push(...preResult.deps) + } + + const needInlineImport = code.includes('@import') + const postcssResult = await runPostCSS( + code, + cleanId, + config.css.postcss, + config.cwd, + needInlineImport, + ) + code = postcssResult.code + deps.push(...postcssResult.deps) + + return transformWithLightningCSS(code, cleanId, { + target: config.css.target, + lightningcss: config.css.lightningcss, + minify: config.css.minify, + }) +} + +function isEmptyChunkCode(code: string): boolean { + return !code + .replaceAll(/\/\*[\s\S]*?\*\//g, '') + .replaceAll(/\/\/[^\n]*/g, '') + .replaceAll(/\bexport\s*\{\s*\};?/g, '') + .replaceAll(/\bimport\s*["'][^"']*["'];?/g, '') + .trim() +} diff --git a/src/features/css/post.ts b/packages/css/src/post.ts similarity index 99% rename from src/features/css/post.ts rename to packages/css/src/post.ts index c5b9a21db..7d9bd2b0c 100644 --- a/src/features/css/post.ts +++ b/packages/css/src/post.ts @@ -1,5 +1,5 @@ +import { defaultCssBundleName, type ResolvedCssOptions } from './options.ts' import { removePureCssChunks } from './pure-chunk.ts' -import { defaultCssBundleName, type ResolvedCssOptions } from './index.ts' import type { Plugin } from 'rolldown' export type CssStyles = Map diff --git a/packages/css/src/postcss.ts b/packages/css/src/postcss.ts index fa6ba9c66..3c7c479df 100644 --- a/packages/css/src/postcss.ts +++ b/packages/css/src/postcss.ts @@ -1,5 +1,5 @@ import { importWithError } from '../../../src/utils/general.ts' -import type { PostCSSOptions } from 'tsdown/css' +import type { PostCSSOptions } from './options.ts' interface PostCSSConfigResult { options: Record diff --git a/packages/css/src/preprocessors.ts b/packages/css/src/preprocessors.ts index dd439265d..b94ab5e19 100644 --- a/packages/css/src/preprocessors.ts +++ b/packages/css/src/preprocessors.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import type { PreprocessorOptions } from 'tsdown/css' +import type { PreprocessorOptions } from './options.ts' export type PreprocessorLang = 'sass' | 'scss' | 'less' | 'styl' | 'stylus' diff --git a/src/features/css/pure-chunk.ts b/packages/css/src/pure-chunk.ts similarity index 100% rename from src/features/css/pure-chunk.ts rename to packages/css/src/pure-chunk.ts diff --git a/packages/css/src/utils.ts b/packages/css/src/utils.ts new file mode 100644 index 000000000..6b902abae --- /dev/null +++ b/packages/css/src/utils.ts @@ -0,0 +1,6 @@ +export const RE_CSS: RegExp = /\.css$/ + +export function getCleanId(id: string): string { + const queryIndex = id.indexOf('?') + return queryIndex === -1 ? id : id.slice(0, queryIndex) +} diff --git a/src/config/options.ts b/src/config/options.ts index f8c34c3ed..12abdd9f3 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -6,7 +6,6 @@ import { createDefu } from 'defu' import isInCi from 'is-in-ci' import { createDebug } from 'obug' import { resolveClean } from '../features/clean.ts' -import { resolveCssOptions } from '../features/css/index.ts' import { resolveDepsConfig } from '../features/deps.ts' import { resolveEntry } from '../features/entry.ts' import { validateSea } from '../features/exe.ts' @@ -271,7 +270,7 @@ export async function resolveUserConfig( cjsDefault, clean, copy: publicDir || copy, - css: resolveCssOptions(css, target), + css, cwd, deps: depsConfig, devtools, diff --git a/src/config/types.ts b/src/config/types.ts index 2e982b292..c06504653 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,13 +1,4 @@ import type { CopyEntry, CopyOptions, CopyOptionsFn } from '../features/copy.ts' -import type { - CssOptions, - LessPreprocessorOptions, - LightningCSSOptions, - PreprocessorOptions, - ResolvedCssOptions, - SassPreprocessorOptions, - StylusPreprocessorOptions, -} from '../features/css/index.ts' import type { DepsConfig, NoExternalFn, @@ -90,30 +81,23 @@ export type { CopyEntry, CopyOptions, CopyOptionsFn, - CssOptions, DepsConfig, DevtoolsOptions, DtsOptions, ExeOptions, ExportsOptions, - LessPreprocessorOptions, - LightningCSSOptions, NoExternalFn, OutExtensionContext, OutExtensionFactory, OutExtensionObject, PackageJsonWithPath, PackageType, - PreprocessorOptions, PublintOptions, ReportOptions, - ResolvedCssOptions, ResolvedDepsConfig, RolldownChunk, RolldownContext, - SassPreprocessorOptions, SeaConfig, - StylusPreprocessorOptions, TreeshakingOptions, TsdownBundle, TsdownHooks, @@ -557,8 +541,9 @@ export interface UserConfig { /** * **[experimental]** CSS options. + * Requires `@tsdown/css` to be installed. */ - css?: CssOptions + css?: import('@tsdown/css').CssOptions /** * @deprecated Use `css.inject` instead. @@ -664,6 +649,7 @@ export type ResolvedConfig = Overwrite< | 'banner' | 'footer' | 'checks' + | 'css' >, { /** Resolved entry map (after glob expansion) */ @@ -679,7 +665,6 @@ export type ResolvedConfig = Overwrite< logger: Logger ignoreWatch: Array deps: ResolvedDepsConfig - css: ResolvedCssOptions dts: false | DtsOptions report: false | ReportOptions diff --git a/src/css.ts b/src/css.ts deleted file mode 100644 index ad609c388..000000000 --- a/src/css.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { getCleanId, RE_CSS } from './features/css/plugin.ts' -export { CssPostPlugin } from './features/css/post.ts' -export type { - LightningCSSOptions, - PostCSSOptions, - PreprocessorOptions, -} from './features/css/index.ts' -export type { CssStyles } from './features/css/post.ts' diff --git a/src/features/css/plugin.ts b/src/features/css/plugin.ts deleted file mode 100644 index 3a5f9abb6..000000000 --- a/src/features/css/plugin.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { CssPostPlugin, type CssStyles } from './post.ts' -import type { ResolvedConfig } from '../../config/index.ts' -import type { Plugin } from 'rolldown' - -export const RE_CSS: RegExp = /\.css$/ - -export function getCleanId(id: string): string { - const queryIndex = id.indexOf('?') - return queryIndex === -1 ? id : id.slice(0, queryIndex) -} - -export function CssPlugin(config: ResolvedConfig): Plugin[] { - const styles: CssStyles = new Map() - - return [ - { - name: 'tsdown:css', - - buildStart() { - styles.clear() - }, - transform: { - filter: { id: RE_CSS }, - handler(code, id) { - if (code.length && !code.endsWith('\n')) { - code += '\n' - } - - styles.set(id, code) - return { - code: '', - moduleSideEffects: 'no-treeshake', - moduleType: 'js', - } - }, - }, - }, - - CssPostPlugin(config.css, styles), - ] -} diff --git a/src/features/pkg/exports.test.ts b/src/features/pkg/exports.test.ts index d7a89b89d..2cdfe16ca 100644 --- a/src/features/pkg/exports.test.ts +++ b/src/features/pkg/exports.test.ts @@ -2,7 +2,6 @@ import path from 'node:path' import process from 'node:process' import { describe, test } from 'vitest' import { globalLogger } from '../../utils/logger.ts' -import { resolveCssOptions } from '../css/index.ts' import { generateExports as _generateExports } from './exports.ts' import type { ResolvedConfig } from '../../config/types.ts' import type { ChunksByFormat, RolldownChunk } from '../../utils/chunks.ts' @@ -12,7 +11,7 @@ const FAKE_PACKAGE_JSON = { name: 'fake-pkg', packageJsonPath: path.join(cwd, 'package.json'), } -const DEFAULT_CSS_OPTIONS = resolveCssOptions() +const DEFAULT_CSS_OPTIONS = {} function generateExports( chunks: ChunksByFormat = {}, @@ -577,7 +576,7 @@ describe('generateExports', () => { { es: [genChunk('index.js'), genAsset('style.css')] }, { exports: {}, - css: resolveCssOptions({ splitting: false }), + css: { splitting: false }, }, ) await expect(results).resolves.toMatchInlineSnapshot(` @@ -603,7 +602,7 @@ describe('generateExports', () => { { es: [genChunk('index.js')] }, { exports: {}, - css: resolveCssOptions({ splitting: false }), + css: { splitting: false }, }, ) await expect(results).resolves.toMatchInlineSnapshot(` @@ -626,7 +625,7 @@ describe('generateExports', () => { { es: [genChunk('index.js'), genAsset('custom.css')] }, { exports: {}, - css: resolveCssOptions({ splitting: false, fileName: 'custom.css' }), + css: { splitting: false, fileName: 'custom.css' }, }, ) await expect(results).resolves.toMatchInlineSnapshot(` @@ -650,7 +649,7 @@ describe('generateExports', () => { { es: [genChunk('index.js'), genAsset('style.css', 'dist')] }, { exports: {}, - css: resolveCssOptions({ splitting: false }), + css: { splitting: false }, }, ) await expect(results).resolves.toMatchInlineSnapshot(` @@ -676,7 +675,7 @@ describe('generateExports', () => { exports: { devExports: 'dev', }, - css: resolveCssOptions({ splitting: false }), + css: { splitting: false }, }, ) await expect(results).resolves.toMatchInlineSnapshot(` diff --git a/src/features/pkg/exports.ts b/src/features/pkg/exports.ts index 2e31942b0..fa471412d 100644 --- a/src/features/pkg/exports.ts +++ b/src/features/pkg/exports.ts @@ -4,11 +4,7 @@ import { RE_CSS, RE_DTS, RE_NODE_MODULES } from 'rolldown-plugin-dts/filename' import { detectIndentation } from '../../utils/format.ts' import { stripExtname } from '../../utils/fs.ts' import { matchPattern, slash, typeAssert } from '../../utils/general.ts' -import type { - NormalizedFormat, - ResolvedConfig, - ResolvedCssOptions, -} from '../../config/types.ts' +import type { NormalizedFormat, ResolvedConfig } from '../../config/types.ts' import type { ChunksByFormat, RolldownChunk, @@ -357,10 +353,10 @@ function exportMeta( function exportCss( exports: Record, chunks: ChunksByFormat, - { splitting }: Pick, + css: { splitting?: boolean } | undefined, pkgRoot: string, ) { - if (splitting) return + if (css?.splitting) return for (const chunksByFormat of Object.values(chunks)) { for (const chunk of chunksByFormat) { diff --git a/src/features/rolldown.ts b/src/features/rolldown.ts index fa23c5d34..681ea328d 100644 --- a/src/features/rolldown.ts +++ b/src/features/rolldown.ts @@ -8,16 +8,14 @@ import { type BuildOptions, type InputOptions, type OutputOptions, - type Plugin, type RolldownPluginOption, } from 'rolldown' import { importGlobPlugin } from 'rolldown/experimental' import pkg from '../../package.json' with { type: 'json' } import { mergeUserOptions } from '../config/options.ts' import { lowestCommonAncestor } from '../utils/fs.ts' -import { importWithError } from '../utils/general.ts' +import { importWithError, pkgExists } from '../utils/general.ts' import { LogLevels } from '../utils/logger.ts' -import { CssPlugin } from './css/plugin.ts' import { DepPlugin } from './deps.ts' import { NodeProtocolPlugin } from './node-protocol.ts' import { resolveChunkAddon, resolveChunkFilename } from './output.ts' @@ -150,29 +148,26 @@ async function resolveInputOptions( ) } - let cssPlugins: Plugin[] - try { - const { CssPlugin: AdvancedCssPlugin } = await import('@tsdown/css') - cssPlugins = AdvancedCssPlugin(config, { logger }) - } catch { - if ( - config.css.inject || - config.css.minify || - config.css.preprocessorOptions || - config.css.lightningcss || - config.css.postcss - ) { - throw new Error( - '`@tsdown/css` is required to use advanced CSS features. Please install it with `npm install @tsdown/css`.', - ) - } - cssPlugins = CssPlugin(config) + if (pkgExists('@tsdown/css')) { + const { CssPlugin } = await import('@tsdown/css') + plugins.push(CssPlugin(config, { logger })) + } else { + plugins.push({ + name: 'tsdown:css-guard', + transform: { + order: 'post', + filter: { id: /\.(?:css|less|sass|scss|styl|stylus)$/ }, + handler(_code, id) { + throw new Error( + `CSS file "${id}" was encountered but \`@tsdown/css\` is not installed. ` + + `Please install it: \`npm install @tsdown/css\``, + ) + }, + }, + }) } - plugins.push( - ...cssPlugins, - ShebangPlugin(logger, cwd, nameLabel, isDualFormat), - ) + plugins.push(ShebangPlugin(logger, cwd, nameLabel, isDualFormat)) if (globImport) { plugins.push(importGlobPlugin({ root: cwd })) } diff --git a/tsdown.config.ts b/tsdown.config.ts index 6776c2b4e..99460a004 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from './src/config.ts' export default defineConfig([ { - entry: ['./src/{index,run,plugins,config,css}.ts'], + entry: ['./src/{index,run,plugins,config}.ts'], name: 'tsdown', deps: { onlyAllowBundle: [ From e27a4efd02db9b872fcbba267bf39fd3ced0a078 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 02:21:02 +0800 Subject: [PATCH 02/12] refactor --- packages/css/src/options.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/css/src/options.ts b/packages/css/src/options.ts index 58941b85a..8753deedf 100644 --- a/packages/css/src/options.ts +++ b/packages/css/src/options.ts @@ -1,3 +1,5 @@ +import type { MarkPartial, Overwrite } from '../../../src/utils/types.ts' + export interface CssOptions { /** * Enable/disable CSS code splitting. @@ -134,18 +136,6 @@ export type ResolvedCssOptions = Overwrite< { target?: string[] } > -// export interface ResolvedCssOptions { -// transformer: 'postcss' | 'lightningcss' -// splitting: boolean -// fileName: string -// minify: boolean -// inject: boolean -// target?: string[] -// preprocessorOptions?: PreprocessorOptions -// lightningcss?: LightningCSSOptions -// postcss?: PostCSSOptions -// } - export const defaultCssBundleName = 'style.css' export function resolveCssOptions( From 7894cdb3c54f5a702ec71d2490af35b35c3297f4 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 02:29:37 +0800 Subject: [PATCH 03/12] docs: update documentation to reflect CSS moved to @tsdown/css All CSS support now requires @tsdown/css - remove "requires @tsdown/css" per-option notes, update architecture descriptions, and fix references to removed src/features/css/ directory. --- CLAUDE.md | 3 +- docs/options/css.md | 39 ++++++++--------------- docs/zh-CN/options/css.md | 35 +++++++------------- packages/css/README.md | 7 +++- packages/css/src/options.ts | 9 +----- skills/tsdown/SKILL.md | 2 +- skills/tsdown/references/option-css.md | 31 +++++++++--------- skills/tsdown/references/option-target.md | 20 ++---------- 8 files changed, 51 insertions(+), 95 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f87da6625..b8f197db3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,6 @@ Each feature is self-contained and modular: **Advanced Features:** -- `css/` - CSS handling with Lightning CSS integration - `pkg/exports.ts` - Auto-generate package.json exports field - `pkg/publint.ts` - Package linting - `pkg/attw.ts` - "Are the types wrong" integration @@ -176,7 +175,7 @@ Public plugin exports in `src/plugins.ts`: `DepPlugin`, `NodeProtocolPlugin`, `R 2. **Package-aware building:** Detects package.json, auto-generates exports field, validates bundled dependencies, runs package linters -3. **Lazy feature loading:** Optional peer dependencies loaded on-demand (unplugin-unused, unplugin-lightningcss, etc.) +3. **Lazy feature loading:** Optional peer dependencies loaded on-demand (`@tsdown/css`, unplugin-unused, etc.) 4. **Watch mode coordination:** Config file changes trigger full rebuild restart; file changes tracked per bundle; keyboard shortcuts for manual rebuild/exit diff --git a/docs/options/css.md b/docs/options/css.md index 3e93d5088..db2524d99 100644 --- a/docs/options/css.md +++ b/docs/options/css.md @@ -5,22 +5,19 @@ CSS support in `tsdown` is still an experimental feature. While it covers the co > [!WARNING] Experimental Feature > CSS support is experimental. Please test thoroughly and report any issues you encounter. The API and behavior may change as the feature matures. -## Basic vs Advanced CSS +## Getting Started -`tsdown` provides two levels of CSS support: - -- **Built-in (basic):** CSS file extraction and bundling works out of the box — no extra dependencies needed. -- **Advanced (`@tsdown/css`):** Preprocessors (Sass/Less/Stylus), CSS syntax lowering, minification, and `@import` inlining require the `@tsdown/css` package: +All CSS support in `tsdown` is provided by the `@tsdown/css` package. Install it to enable CSS handling: ```bash npm install -D @tsdown/css ``` -When `@tsdown/css` is installed, the advanced CSS plugin is automatically used in place of the built-in one. +When `@tsdown/css` is installed, CSS processing is automatically enabled. ## CSS Import -Importing `.css` files from your TypeScript or JavaScript entry points is supported out of the box. The CSS content is extracted and emitted as a separate `.css` asset file: +Importing `.css` files from your TypeScript or JavaScript entry points is supported. The CSS content is extracted and emitted as a separate `.css` asset file: ```ts // src/index.ts @@ -35,7 +32,7 @@ This produces both `index.mjs` and `index.css` in the output directory. ### `@import` Inlining -When `@tsdown/css` is installed, CSS `@import` statements are automatically resolved and inlined into the output. This means you can use `@import` to organize your CSS across multiple files without producing separate output files: +CSS `@import` statements are automatically resolved and inlined into the output. This means you can use `@import` to organize your CSS across multiple files without producing separate output files: ```css /* style.css */ @@ -51,8 +48,6 @@ All imported CSS is bundled into a single output file with `@import` statements ## CSS Pre-processors -> [!NOTE] -> Requires `@tsdown/css` to be installed. `tsdown` provides built-in support for `.scss`, `.sass`, `.less`, `.styl`, and `.stylus` files. The corresponding pre-processor must be installed as a dev dependency: @@ -138,8 +133,6 @@ export default defineConfig({ ## CSS Minification -> [!NOTE] -> Requires `@tsdown/css` to be installed. Enable CSS minification via `css.minify`: @@ -155,8 +148,6 @@ Minification is powered by [Lightning CSS](https://lightningcss.dev/). ## CSS Target -> [!NOTE] -> Requires `@tsdown/css` to be installed. By default, CSS syntax lowering uses the top-level [`target`](/options/target) option. You can override this specifically for CSS with `css.target`: @@ -182,8 +173,6 @@ export default defineConfig({ ## CSS Transformer -> [!NOTE] -> Requires `@tsdown/css` to be installed. The `css.transformer` option controls how CSS is processed. PostCSS and Lightning CSS are **mutually exclusive** processing paths: @@ -234,8 +223,6 @@ When `css.postcss` is omitted and `transformer` is `'postcss'`, tsdown auto-dete ## Lightning CSS -> [!NOTE] -> Requires `@tsdown/css` to be installed. `tsdown` uses [Lightning CSS](https://lightningcss.dev/) for CSS syntax lowering — transforming modern CSS features into syntax compatible with older browsers based on your `target` setting. @@ -349,11 +336,11 @@ dist/ | Option | Type | Default | Description | | ------------------------- | ----------------------------- | ---------------- | ---------------------------------------------------------------------------- | -| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS processing pipeline (requires `@tsdown/css`) | -| `css.splitting` | `boolean` | `false` | Enable CSS code splitting per chunk | -| `css.fileName` | `string` | `'style.css'` | File name for the merged CSS file (when `splitting: false`) | -| `css.minify` | `boolean` | `false` | Enable CSS minification (requires `@tsdown/css`) | -| `css.target` | `string \| string[] \| false` | _from `target`_ | CSS-specific syntax lowering target (requires `@tsdown/css`) | -| `css.postcss` | `string \| object` | — | PostCSS config path or inline options (requires `@tsdown/css`) | -| `css.preprocessorOptions` | `object` | — | Options for CSS preprocessors (requires `@tsdown/css`) | -| `css.lightningcss` | `object` | — | Options passed to Lightning CSS for syntax lowering (requires `@tsdown/css`) | +| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS processing pipeline | +| `css.splitting` | `boolean` | `false` | Enable CSS code splitting per chunk | +| `css.fileName` | `string` | `'style.css'` | File name for the merged CSS file (when `splitting: false`) | +| `css.minify` | `boolean` | `false` | Enable CSS minification | +| `css.target` | `string \| string[] \| false` | _from `target`_ | CSS-specific syntax lowering target | +| `css.postcss` | `string \| object` | — | PostCSS config path or inline options | +| `css.preprocessorOptions` | `object` | — | Options for CSS preprocessors | +| `css.lightningcss` | `object` | — | Options passed to Lightning CSS for syntax lowering | diff --git a/docs/zh-CN/options/css.md b/docs/zh-CN/options/css.md index 8055c76fd..49577da01 100644 --- a/docs/zh-CN/options/css.md +++ b/docs/zh-CN/options/css.md @@ -5,18 +5,15 @@ > [!WARNING] 实验性功能 > CSS 支持属于实验性特性。请务必充分测试,并反馈您遇到的任何问题。随着功能的完善,API 和行为可能会有所调整。 -## 基础与高级 CSS +## 快速开始 -`tsdown` 提供两个层级的 CSS 支持: - -- **内置(基础):** CSS 文件提取和打包开箱即用,无需额外依赖。 -- **高级(`@tsdown/css`):** 预处理器(Sass/Less/Stylus)、CSS 语法降级、压缩和 `@import` 内联需要安装 `@tsdown/css` 包: +`tsdown` 的所有 CSS 功能由 `@tsdown/css` 包提供。安装后即可启用 CSS 处理: ```bash npm install -D @tsdown/css ``` -安装 `@tsdown/css` 后,高级 CSS 插件会自动替代内置的基础插件。 +安装 `@tsdown/css` 后,CSS 处理会自动启用。 ## CSS 导入 @@ -51,8 +48,6 @@ export function greet() { ## CSS 预处理器 -> [!NOTE] -> 需要安装 `@tsdown/css`。 `tsdown` 内置支持 `.scss`、`.sass`、`.less`、`.styl` 和 `.stylus` 文件。需要安装对应的预处理器作为开发依赖: @@ -138,8 +133,6 @@ export default defineConfig({ ## CSS 压缩 -> [!NOTE] -> 需要安装 `@tsdown/css`。 通过 `css.minify` 启用 CSS 压缩: @@ -155,8 +148,6 @@ export default defineConfig({ ## CSS 目标 -> [!NOTE] -> 需要安装 `@tsdown/css`。 默认情况下,CSS 语法降级使用顶层的 [`target`](/zh-CN/options/target) 选项。你可以通过 `css.target` 单独为 CSS 设置目标: @@ -182,8 +173,6 @@ export default defineConfig({ ## CSS 转换器 -> [!NOTE] -> 需要安装 `@tsdown/css`。 `css.transformer` 选项控制 CSS 的处理方式。PostCSS 和 Lightning CSS 是**互斥的**处理路径: @@ -234,8 +223,6 @@ export default defineConfig({ ## Lightning CSS -> [!NOTE] -> 需要安装 `@tsdown/css`。 `tsdown` 使用 [Lightning CSS](https://lightningcss.dev/) 进行 CSS 语法降级——根据 `target` 设置将现代 CSS 特性转换为兼容旧版浏览器的语法。 @@ -349,11 +336,11 @@ dist/ | 选项 | 类型 | 默认值 | 描述 | | ------------------------- | ----------------------------- | ---------------- | --------------------------------------------------------- | -| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS 处理管线(需要 `@tsdown/css`) | -| `css.splitting` | `boolean` | `false` | 启用按 chunk 的 CSS 代码分割 | -| `css.fileName` | `string` | `'style.css'` | 合并 CSS 的文件名(当 `splitting: false` 时) | -| `css.minify` | `boolean` | `false` | 启用 CSS 压缩(需要 `@tsdown/css`) | -| `css.target` | `string \| string[] \| false` | _继承 `target`_ | CSS 专用语法降级目标(需要 `@tsdown/css`) | -| `css.postcss` | `string \| object` | — | PostCSS 配置路径或内联选项(需要 `@tsdown/css`) | -| `css.preprocessorOptions` | `object` | — | CSS 预处理器选项(需要 `@tsdown/css`) | -| `css.lightningcss` | `object` | — | 传递给 Lightning CSS 的语法降级选项(需要 `@tsdown/css`) | +| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS 处理管线 | +| `css.splitting` | `boolean` | `false` | 启用按 chunk 的 CSS 代码分割 | +| `css.fileName` | `string` | `'style.css'` | 合并 CSS 的文件名(当 `splitting: false` 时) | +| `css.minify` | `boolean` | `false` | 启用 CSS 压缩 | +| `css.target` | `string \| string[] \| false` | _继承 `target`_ | CSS 专用语法降级目标 | +| `css.postcss` | `string \| object` | — | PostCSS 配置路径或内联选项 | +| `css.preprocessorOptions` | `object` | — | CSS 预处理器选项 | +| `css.lightningcss` | `object` | — | 传递给 Lightning CSS 的语法降级选项 | diff --git a/packages/css/README.md b/packages/css/README.md index cd9fd7ccb..5c2c9af16 100644 --- a/packages/css/README.md +++ b/packages/css/README.md @@ -3,15 +3,20 @@ [![npm version][npmx-version-src]][npmx-href] [![npm downloads][npmx-downloads-src]][npmx-href] -Advanced CSS pipeline for [tsdown](https://tsdown.dev), powered by [Lightning CSS](https://lightningcss.dev/). +CSS support for [tsdown](https://tsdown.dev), powered by [Lightning CSS](https://lightningcss.dev/). + +All CSS processing in tsdown requires this package. Install it to enable CSS handling in your builds. ## Features +- CSS extraction and bundling - CSS `@import` inlining via Lightning CSS `bundleAsync` - CSS syntax lowering and autoprefixing - CSS minification +- CSS code splitting - Source map support - Preprocessor support (Sass, Less, Stylus) +- PostCSS integration ## Documentation diff --git a/packages/css/src/options.ts b/packages/css/src/options.ts index 8753deedf..881e0d906 100644 --- a/packages/css/src/options.ts +++ b/packages/css/src/options.ts @@ -1,3 +1,4 @@ +import { resolveComma, toArray } from '../../../src/utils/general.ts' import type { MarkPartial, Overwrite } from '../../../src/utils/types.ts' export interface CssOptions { @@ -163,11 +164,3 @@ export function resolveCssOptions( postcss: options.postcss, } } - -function toArray(value: T | T[]): T[] { - return Array.isArray(value) ? value : [value] -} - -function resolveComma(items: string[]): string[] { - return items.flatMap((item) => item.split(',')) -} diff --git a/skills/tsdown/SKILL.md b/skills/tsdown/SKILL.md index 6b289a066..afded0b7d 100644 --- a/skills/tsdown/SKILL.md +++ b/skills/tsdown/SKILL.md @@ -96,7 +96,7 @@ export default defineConfig({ | Shims | `shims: true` - Add ESM/CJS compatibility | [option-shims](references/option-shims.md) | | CJS default | `cjsDefault: true` (default) / `false` | [option-cjs-default](references/option-cjs-default.md) | | Package exports | `exports: true` - Auto-generate exports field | [option-package-exports](references/option-package-exports.md) | -| CSS handling | **[experimental]** `css: { splitting, preprocessorOptions, lightningcss }` | [option-css](references/option-css.md) | +| CSS handling | **[experimental]** `css: { ... }` — requires `@tsdown/css` | [option-css](references/option-css.md) | | Unbundle mode | `unbundle: true` - Preserve directory structure | [option-unbundle](references/option-unbundle.md) | | Executable | **[experimental]** `exe: true` - Bundle as standalone executable, cross-platform via `@tsdown/exe` | [option-exe](references/option-exe.md) | | Package validation | `publint: true`, `attw: true` - Validate package | [option-lint](references/option-lint.md) | diff --git a/skills/tsdown/references/option-css.md b/skills/tsdown/references/option-css.md index c995cc09f..d70d4c385 100644 --- a/skills/tsdown/references/option-css.md +++ b/skills/tsdown/references/option-css.md @@ -4,16 +4,15 @@ Configure CSS handling including preprocessors, syntax lowering, minification, and code splitting. -## Architecture: Basic vs Advanced +## Getting Started -- **Built-in (basic):** CSS extraction and bundling — no extra dependencies. -- **Advanced (`@tsdown/css`):** Preprocessors, syntax lowering, minification, `@import` inlining — install `@tsdown/css`. +All CSS support in `tsdown` is provided by the `@tsdown/css` package. Install it to enable CSS handling: ```bash npm install -D @tsdown/css ``` -When installed, the advanced plugin replaces the built-in one automatically. +When `@tsdown/css` is installed, CSS processing is automatically enabled. Without it, encountering CSS files will result in an error. ## CSS Import @@ -27,11 +26,11 @@ export function greet() { return 'Hello' } Output: `index.mjs` + `index.css` -### `@import` Inlining (requires `@tsdown/css`) +### `@import` Inlining CSS `@import` statements are resolved and inlined automatically. No separate output files produced. -## CSS Pre-processors (requires `@tsdown/css`) +## CSS Pre-processors Built-in support for Sass, Less, and Stylus. Install the preprocessor: @@ -94,7 +93,7 @@ scss: { } ``` -## CSS Minification (requires `@tsdown/css`) +## CSS Minification ```ts export default defineConfig({ @@ -106,7 +105,7 @@ export default defineConfig({ Powered by Lightning CSS. -## CSS Target (requires `@tsdown/css`) +## CSS Target Override the top-level `target` specifically for CSS: @@ -121,7 +120,7 @@ export default defineConfig({ Set `css.target: false` to disable CSS syntax lowering entirely. -## CSS Transformer (requires `@tsdown/css`) +## CSS Transformer `css.transformer` controls mutually exclusive CSS processing paths: @@ -152,7 +151,7 @@ export default defineConfig({ Auto-detects PostCSS config from project root when `transformer` is `'postcss'` and `css.postcss` is omitted. -## Lightning CSS (Syntax Lowering, requires `@tsdown/css`) +## Lightning CSS (Syntax Lowering) Install `lightningcss` to enable CSS syntax lowering based on your `target`: @@ -215,14 +214,14 @@ export default defineConfig({ | Option | Type | Default | Description | |--------|------|---------|-------------| -| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS processing pipeline (requires `@tsdown/css`) | +| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS processing pipeline | | `css.splitting` | `boolean` | `false` | Per-chunk CSS splitting | | `css.fileName` | `string` | `'style.css'` | Merged CSS file name | -| `css.minify` | `boolean` | `false` | CSS minification (requires `@tsdown/css`) | -| `css.target` | `string \| string[] \| false` | _from `target`_ | CSS-specific lowering target (requires `@tsdown/css`) | -| `css.postcss` | `string \| object` | — | PostCSS config path or inline options (requires `@tsdown/css`) | -| `css.preprocessorOptions` | `object` | — | Preprocessor options (requires `@tsdown/css`) | -| `css.lightningcss` | `object` | — | Lightning CSS options (requires `@tsdown/css`) | +| `css.minify` | `boolean` | `false` | CSS minification | +| `css.target` | `string \| string[] \| false` | _from `target`_ | CSS-specific lowering target | +| `css.postcss` | `string \| object` | — | PostCSS config path or inline options | +| `css.preprocessorOptions` | `object` | — | Preprocessor options | +| `css.lightningcss` | `object` | — | Lightning CSS options | ## Related diff --git a/skills/tsdown/references/option-target.md b/skills/tsdown/references/option-target.md index 2d3511644..d470c3a99 100644 --- a/skills/tsdown/references/option-target.md +++ b/skills/tsdown/references/option-target.md @@ -195,11 +195,7 @@ export default defineConfig({ ## CSS Targeting -When a browser target is set and `lightningcss` is installed, CSS syntax is also lowered: - -```bash -npm install -D lightningcss -``` +When `@tsdown/css` is installed and a browser target is set, CSS syntax is also lowered automatically: ```ts export default defineConfig({ @@ -207,17 +203,7 @@ export default defineConfig({ }) ``` -Custom Lightning CSS options via `css.lightningcss`: - -```ts -export default defineConfig({ - css: { - lightningcss: { - targets: { chrome: 100 << 16 }, - }, - }, -}) -``` +See [CSS](option-css.md) for full CSS configuration options. ## Tips @@ -225,7 +211,7 @@ export default defineConfig({ 2. **Use `false`** for modern-only builds 3. **Specify multiple targets** for broader compatibility 4. **Use legacy decorators** with `experimentalDecorators` -5. **Install `lightningcss`** for CSS syntax lowering +5. **Install `@tsdown/css`** for CSS support and syntax lowering 6. **Test output** in target environments ## Related Options From 8a31e78f49ac1bf0c825e7bfa21f25740a4ec677 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 18:30:59 +0000 Subject: [PATCH 04/12] [autofix.ci] apply automated fixes --- docs/options/css.md | 25 ++++++++++--------------- docs/zh-CN/options/css.md | 25 ++++++++++--------------- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/docs/options/css.md b/docs/options/css.md index db2524d99..6512da876 100644 --- a/docs/options/css.md +++ b/docs/options/css.md @@ -48,7 +48,6 @@ All imported CSS is bundled into a single output file with `@import` statements ## CSS Pre-processors - `tsdown` provides built-in support for `.scss`, `.sass`, `.less`, `.styl`, and `.stylus` files. The corresponding pre-processor must be installed as a dev dependency: ::: code-group @@ -133,7 +132,6 @@ export default defineConfig({ ## CSS Minification - Enable CSS minification via `css.minify`: ```ts @@ -148,7 +146,6 @@ Minification is powered by [Lightning CSS](https://lightningcss.dev/). ## CSS Target - By default, CSS syntax lowering uses the top-level [`target`](/options/target) option. You can override this specifically for CSS with `css.target`: ```ts @@ -173,7 +170,6 @@ export default defineConfig({ ## CSS Transformer - The `css.transformer` option controls how CSS is processed. PostCSS and Lightning CSS are **mutually exclusive** processing paths: - **`'lightningcss'`** (default): `@import` is resolved by Lightning CSS's `bundleAsync()`, and PostCSS is **not used at all**. @@ -223,7 +219,6 @@ When `css.postcss` is omitted and `transformer` is `'postcss'`, tsdown auto-dete ## Lightning CSS - `tsdown` uses [Lightning CSS](https://lightningcss.dev/) for CSS syntax lowering — transforming modern CSS features into syntax compatible with older browsers based on your `target` setting. To enable CSS syntax lowering, install `lightningcss`: @@ -334,13 +329,13 @@ dist/ ## Options Reference -| Option | Type | Default | Description | -| ------------------------- | ----------------------------- | ---------------- | ---------------------------------------------------------------------------- | -| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS processing pipeline | -| `css.splitting` | `boolean` | `false` | Enable CSS code splitting per chunk | -| `css.fileName` | `string` | `'style.css'` | File name for the merged CSS file (when `splitting: false`) | -| `css.minify` | `boolean` | `false` | Enable CSS minification | -| `css.target` | `string \| string[] \| false` | _from `target`_ | CSS-specific syntax lowering target | -| `css.postcss` | `string \| object` | — | PostCSS config path or inline options | -| `css.preprocessorOptions` | `object` | — | Options for CSS preprocessors | -| `css.lightningcss` | `object` | — | Options passed to Lightning CSS for syntax lowering | +| Option | Type | Default | Description | +| ------------------------- | ----------------------------- | ---------------- | ----------------------------------------------------------- | +| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS processing pipeline | +| `css.splitting` | `boolean` | `false` | Enable CSS code splitting per chunk | +| `css.fileName` | `string` | `'style.css'` | File name for the merged CSS file (when `splitting: false`) | +| `css.minify` | `boolean` | `false` | Enable CSS minification | +| `css.target` | `string \| string[] \| false` | _from `target`_ | CSS-specific syntax lowering target | +| `css.postcss` | `string \| object` | — | PostCSS config path or inline options | +| `css.preprocessorOptions` | `object` | — | Options for CSS preprocessors | +| `css.lightningcss` | `object` | — | Options passed to Lightning CSS for syntax lowering | diff --git a/docs/zh-CN/options/css.md b/docs/zh-CN/options/css.md index 49577da01..88c9a6065 100644 --- a/docs/zh-CN/options/css.md +++ b/docs/zh-CN/options/css.md @@ -48,7 +48,6 @@ export function greet() { ## CSS 预处理器 - `tsdown` 内置支持 `.scss`、`.sass`、`.less`、`.styl` 和 `.stylus` 文件。需要安装对应的预处理器作为开发依赖: ::: code-group @@ -133,7 +132,6 @@ export default defineConfig({ ## CSS 压缩 - 通过 `css.minify` 启用 CSS 压缩: ```ts @@ -148,7 +146,6 @@ export default defineConfig({ ## CSS 目标 - 默认情况下,CSS 语法降级使用顶层的 [`target`](/zh-CN/options/target) 选项。你可以通过 `css.target` 单独为 CSS 设置目标: ```ts @@ -173,7 +170,6 @@ export default defineConfig({ ## CSS 转换器 - `css.transformer` 选项控制 CSS 的处理方式。PostCSS 和 Lightning CSS 是**互斥的**处理路径: - **`'lightningcss'`**(默认):`@import` 由 Lightning CSS 的 `bundleAsync()` 解析,**完全不使用** PostCSS。 @@ -223,7 +219,6 @@ export default defineConfig({ ## Lightning CSS - `tsdown` 使用 [Lightning CSS](https://lightningcss.dev/) 进行 CSS 语法降级——根据 `target` 设置将现代 CSS 特性转换为兼容旧版浏览器的语法。 要启用 CSS 语法降级,需安装 `lightningcss`: @@ -334,13 +329,13 @@ dist/ ## 选项参考 -| 选项 | 类型 | 默认值 | 描述 | -| ------------------------- | ----------------------------- | ---------------- | --------------------------------------------------------- | -| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS 处理管线 | -| `css.splitting` | `boolean` | `false` | 启用按 chunk 的 CSS 代码分割 | -| `css.fileName` | `string` | `'style.css'` | 合并 CSS 的文件名(当 `splitting: false` 时) | -| `css.minify` | `boolean` | `false` | 启用 CSS 压缩 | -| `css.target` | `string \| string[] \| false` | _继承 `target`_ | CSS 专用语法降级目标 | -| `css.postcss` | `string \| object` | — | PostCSS 配置路径或内联选项 | -| `css.preprocessorOptions` | `object` | — | CSS 预处理器选项 | -| `css.lightningcss` | `object` | — | 传递给 Lightning CSS 的语法降级选项 | +| 选项 | 类型 | 默认值 | 描述 | +| ------------------------- | ----------------------------- | ---------------- | --------------------------------------------- | +| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS 处理管线 | +| `css.splitting` | `boolean` | `false` | 启用按 chunk 的 CSS 代码分割 | +| `css.fileName` | `string` | `'style.css'` | 合并 CSS 的文件名(当 `splitting: false` 时) | +| `css.minify` | `boolean` | `false` | 启用 CSS 压缩 | +| `css.target` | `string \| string[] \| false` | _继承 `target`_ | CSS 专用语法降级目标 | +| `css.postcss` | `string \| object` | — | PostCSS 配置路径或内联选项 | +| `css.preprocessorOptions` | `object` | — | CSS 预处理器选项 | +| `css.lightningcss` | `object` | — | 传递给 Lightning CSS 的语法降级选项 | From 95191313595fe810f06fbe1cb91437de9b8dd6e5 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 02:32:12 +0800 Subject: [PATCH 05/12] refactor: export internal utilities via tsdown/internal Replace relative path imports in @tsdown/css with tsdown/internal subpath export for utility functions (toArray, resolveComma, importWithError) and types (MarkPartial, Overwrite). --- package.json | 5 +++++ packages/css/src/options.ts | 4 ++-- packages/css/src/postcss.ts | 2 +- src/internal.ts | 2 ++ tsdown.config.ts | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 src/internal.ts diff --git a/package.json b/package.json index bd1f6d973..1a630fa19 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,10 @@ "dev": "./src/config.ts", "default": "./dist/config.mjs" }, + "./internal": { + "dev": "./src/internal.ts", + "default": "./dist/internal.mjs" + }, "./plugins": { "dev": "./src/plugins.ts", "default": "./dist/plugins.mjs" @@ -57,6 +61,7 @@ "exports": { ".": "./dist/index.mjs", "./config": "./dist/config.mjs", + "./internal": "./dist/internal.mjs", "./plugins": "./dist/plugins.mjs", "./run": "./dist/run.mjs", "./package.json": "./package.json", diff --git a/packages/css/src/options.ts b/packages/css/src/options.ts index 881e0d906..76d1e12e6 100644 --- a/packages/css/src/options.ts +++ b/packages/css/src/options.ts @@ -1,5 +1,5 @@ -import { resolveComma, toArray } from '../../../src/utils/general.ts' -import type { MarkPartial, Overwrite } from '../../../src/utils/types.ts' +import { resolveComma, toArray } from 'tsdown/internal' +import type { MarkPartial, Overwrite } from 'tsdown/internal' export interface CssOptions { /** diff --git a/packages/css/src/postcss.ts b/packages/css/src/postcss.ts index 3c7c479df..7c138d01b 100644 --- a/packages/css/src/postcss.ts +++ b/packages/css/src/postcss.ts @@ -1,4 +1,4 @@ -import { importWithError } from '../../../src/utils/general.ts' +import { importWithError } from 'tsdown/internal' import type { PostCSSOptions } from './options.ts' interface PostCSSConfigResult { diff --git a/src/internal.ts b/src/internal.ts new file mode 100644 index 000000000..fbb9a60b7 --- /dev/null +++ b/src/internal.ts @@ -0,0 +1,2 @@ +export { importWithError, resolveComma, toArray } from './utils/general.ts' +export type { MarkPartial, Overwrite } from './utils/types.ts' diff --git a/tsdown.config.ts b/tsdown.config.ts index 99460a004..90c76fc61 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from './src/config.ts' export default defineConfig([ { - entry: ['./src/{index,run,plugins,config}.ts'], + entry: ['./src/{index,run,plugins,config,internal}.ts'], name: 'tsdown', deps: { onlyAllowBundle: [ From 292baf673c63c5ae722df008e0723c3e0815e6fd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 18:33:40 +0000 Subject: [PATCH 06/12] [autofix.ci] apply automated fixes --- dts.snapshot.json | 24 +++++++++++++++++++----- packages/css/src/options.ts | 8 ++++++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/dts.snapshot.json b/dts.snapshot.json index 569a8383d..7a3bca849 100644 --- a/dts.snapshot.json +++ b/dts.snapshot.json @@ -1,5 +1,5 @@ { - "config-!~{00b}~.d.mts": { + "config-!~{00d}~.d.mts": { "defineConfig": "declare function defineConfig(_: UserConfigExport): UserConfigExport", "mergeConfig": "declare function mergeConfig(_: InlineConfig, _: InlineConfig): InlineConfig", "resolveUserConfig": "declare function resolveUserConfig(_: UserConfig, _: InlineConfig): Promise" @@ -70,6 +70,18 @@ "resolveUserConfig" ] }, + "internal.d.mts": { + "importWithError": "declare function importWithError(_: string, _: string[]): Promise", + "resolveComma": "declare function resolveComma(_: T[]): T[]", + "toArray": "declare function toArray(_: T | T[] | null | undefined, _: T): T[]", + "#exports": [ + "MarkPartial", + "Overwrite", + "importWithError", + "resolveComma", + "toArray" + ] + }, "plugins.d.mts": { "NodeProtocolPlugin": "declare function NodeProtocolPlugin(_: 'strip' | true): Plugin", "ShebangPlugin": "declare function ShebangPlugin(_: Logger, _: string, _: string, _: boolean): Plugin", @@ -85,10 +97,14 @@ "run.d.mts": { "#exports": [] }, - "types-!~{00a}~.d.mts": { + "types-!~{00b}~.d.mts": { "Arrayable": "type Arrayable = T | T[]", - "AttwOptions": "interface AttwOptions extends CheckPackageOptions {\n profile?: 'strict' | 'node16' | 'esm-only'\n level?: 'error' | 'warn'\n ignoreRules?: string[]\n}", "Awaitable": "type Awaitable = T | Promise", + "MarkPartial": "type MarkPartial = Omit, K> & Partial>", + "Overwrite": "type Overwrite = Omit & U" + }, + "types-!~{00c}~.d.mts": { + "AttwOptions": "interface AttwOptions extends CheckPackageOptions {\n profile?: 'strict' | 'node16' | 'esm-only'\n level?: 'error' | 'warn'\n ignoreRules?: string[]\n}", "BuildContext": "interface BuildContext {\n options: ResolvedConfig\n hooks: Hookable\n}", "ChunkAddon": "type ChunkAddon = ChunkAddonObject | ChunkAddonFunction | string", "ChunkAddonFunction": "type ChunkAddonFunction = (_: { format: Format; fileName: string }) => ChunkAddonObject | string | undefined", @@ -110,13 +126,11 @@ "LoggerOptions": "interface LoggerOptions {\n allowClearScreen?: boolean\n customLogger?: Logger\n console?: Console\n failOnWarn?: boolean\n}", "LogLevel": "type LogLevel = LogType | 'silent'", "LogType": "type LogType = 'error' | 'warn' | 'info'", - "MarkPartial": "type MarkPartial = Omit, K> & Partial>", "NoExternalFn": "type NoExternalFn = (_: string, _: string | undefined) => boolean | null | undefined | void", "NormalizedFormat": "type NormalizedFormat = InternalModuleFormat", "OutExtensionContext": "interface OutExtensionContext {\n options: InputOptions\n format: NormalizedFormat\n pkgType?: PackageType\n}", "OutExtensionFactory": "type OutExtensionFactory = (_: OutExtensionContext) => OutExtensionObject | undefined", "OutExtensionObject": "interface OutExtensionObject {\n js?: string\n dts?: string\n}", - "Overwrite": "type Overwrite = Omit & U", "PackageJson": "interface PackageJson {\n name?: string\n version?: string\n description?: string\n keywords?: string[]\n homepage?: string\n bugs?: string | { url?: string; email?: string }\n license?: string\n repository?: string | { type: string; url: string; directory?: string }\n scripts?: PackageJsonScripts\n private?: boolean\n author?: PackageJsonPerson\n contributors?: PackageJsonPerson[]\n funding?: PackageJsonFunding | PackageJsonFunding[]\n files?: string[]\n main?: string\n browser?: string | Record\n unpkg?: string\n bin?: string | Record\n man?: string | string[]\n dependencies?: Record\n devDependencies?: Record\n optionalDependencies?: Record\n peerDependencies?: Record\n types?: string\n typings?: string\n module?: string\n type?: 'module' | 'commonjs'\n exports?: PackageJsonExports\n imports?: Record>\n workspaces?: string[] | { packages?: string[]; nohoist?: string[] }\n typesVersions?: Record>\n os?: string[]\n cpu?: string[]\n publishConfig?: { registry?: string; tag?: string; access?: 'public' | 'restricted'; executableFiles?: string[]; directory?: string; linkDirectory?: boolean } & Pick\n packageManager?: string\n [key: string]: any\n}", "PackageJsonCommonScripts": "type PackageJsonCommonScripts = 'build' | 'coverage' | 'deploy' | 'dev' | 'format' | 'lint' | 'preview' | 'release' | 'typecheck' | 'watch'", "PackageJsonExportKey": "type PackageJsonExportKey = '.' | 'import' | 'require' | 'types' | 'node' | 'browser' | 'default' | (string & {})", diff --git a/packages/css/src/options.ts b/packages/css/src/options.ts index 76d1e12e6..d3a8fd806 100644 --- a/packages/css/src/options.ts +++ b/packages/css/src/options.ts @@ -1,5 +1,9 @@ -import { resolveComma, toArray } from 'tsdown/internal' -import type { MarkPartial, Overwrite } from 'tsdown/internal' +import { + resolveComma, + toArray, + type MarkPartial, + type Overwrite, +} from 'tsdown/internal' export interface CssOptions { /** From ccfdd0666d3dc2f190d602f41377163785cfc358 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 02:34:15 +0800 Subject: [PATCH 07/12] fix --- packages/create-tsdown/package.json | 2 +- packages/css/package.json | 2 +- packages/exe/package.json | 2 +- packages/migrate/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/create-tsdown/package.json b/packages/create-tsdown/package.json index c7950c4a8..059a4db5f 100644 --- a/packages/create-tsdown/package.json +++ b/packages/create-tsdown/package.json @@ -54,7 +54,7 @@ }, "scripts": { "dev": "node ./src/run.ts", - "build": "unrun ../../src/run.ts -c ../../tsdown.config.ts -F ." + "build": "node -C dev ../../src/run.ts -c ../../tsdown.config.ts -F ." }, "dependencies": { "@clack/prompts": "catalog:create", diff --git a/packages/css/package.json b/packages/css/package.json index c1ea3fe9e..6edc6c7c4 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -45,7 +45,7 @@ "node": ">=20.19.0" }, "scripts": { - "build": "unrun ../../src/run.ts -c ../../tsdown.config.ts -F ." + "build": "node -C dev ../../src/run.ts -c ../../tsdown.config.ts -F ." }, "peerDependencies": { "postcss": "^8.4.0", diff --git a/packages/exe/package.json b/packages/exe/package.json index 389eadfbf..cc333717c 100644 --- a/packages/exe/package.json +++ b/packages/exe/package.json @@ -45,7 +45,7 @@ "node": ">=20.19.0" }, "scripts": { - "build": "unrun ../../src/run.ts -c ../../tsdown.config.ts -F ." + "build": "node -C dev ../../src/run.ts -c ../../tsdown.config.ts -F ." }, "dependencies": { "obug": "catalog:prod", diff --git a/packages/migrate/package.json b/packages/migrate/package.json index 9f15c44d9..ba69ea068 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -54,7 +54,7 @@ }, "scripts": { "dev": "node ./src/run.ts", - "build": "unrun ../../src/run.ts -c ../../tsdown.config.ts -F ." + "build": "node -C dev ../../src/run.ts -c ../../tsdown.config.ts -F ." }, "dependencies": { "@antfu/ni": "catalog:migrate", From 854c61c204982b1e7edd53df7cd158f5667e92d6 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 02:38:45 +0800 Subject: [PATCH 08/12] refactor: use tsdown/internal in @tsdown/css and @tsdown/exe - Export fsExists, fsRemove, Logger from tsdown/internal - Replace MinimalLogger with Logger from tsdown/internal - Delete packages/css/src/types.ts (MinimalLogger no longer needed) - Update @tsdown/exe to use tsdown/internal for fs utils and Logger --- dts.snapshot.json | 31 ++++++++++++++++++------------- packages/css/src/lightningcss.ts | 4 ++-- packages/css/src/plugin.ts | 6 +++--- packages/css/src/types.ts | 4 ---- packages/exe/package.json | 3 +++ packages/exe/src/download.ts | 8 ++------ pnpm-lock.yaml | 3 +++ src/internal.ts | 2 ++ 8 files changed, 33 insertions(+), 28 deletions(-) delete mode 100644 packages/css/src/types.ts diff --git a/dts.snapshot.json b/dts.snapshot.json index 7a3bca849..7d1ef2468 100644 --- a/dts.snapshot.json +++ b/dts.snapshot.json @@ -1,5 +1,5 @@ { - "config-!~{00d}~.d.mts": { + "config-!~{00e}~.d.mts": { "defineConfig": "declare function defineConfig(_: UserConfigExport): UserConfigExport", "mergeConfig": "declare function mergeConfig(_: InlineConfig, _: InlineConfig): InlineConfig", "resolveUserConfig": "declare function resolveUserConfig(_: UserConfig, _: InlineConfig): Promise" @@ -71,17 +71,33 @@ ] }, "internal.d.mts": { + "fsExists": "declare function fsExists(_: string): Promise", + "fsRemove": "declare function fsRemove(_: string): Promise", "importWithError": "declare function importWithError(_: string, _: string[]): Promise", "resolveComma": "declare function resolveComma(_: T[]): T[]", "toArray": "declare function toArray(_: T | T[] | null | undefined, _: T): T[]", "#exports": [ + "Logger", "MarkPartial", "Overwrite", + "fsExists", + "fsRemove", "importWithError", "resolveComma", "toArray" ] }, + "logger-!~{00c}~.d.mts": { + "Arrayable": "type Arrayable = T | T[]", + "Awaitable": "type Awaitable = T | Promise", + "globalLogger": "Logger", + "Logger": "interface Logger {\n level: LogLevel\n options?: LoggerOptions\n info: (...args: any[]) => void\n warn: (...args: any[]) => void\n warnOnce: (...args: any[]) => void\n error: (...args: any[]) => void\n success: (...args: any[]) => void\n clearScreen: (_: LogType) => void\n}", + "LoggerOptions": "interface LoggerOptions {\n allowClearScreen?: boolean\n customLogger?: Logger\n console?: Console\n failOnWarn?: boolean\n}", + "LogLevel": "type LogLevel = LogType | 'silent'", + "LogType": "type LogType = 'error' | 'warn' | 'info'", + "MarkPartial": "type MarkPartial = Omit, K> & Partial>", + "Overwrite": "type Overwrite = Omit & U" + }, "plugins.d.mts": { "NodeProtocolPlugin": "declare function NodeProtocolPlugin(_: 'strip' | true): Plugin", "ShebangPlugin": "declare function ShebangPlugin(_: Logger, _: string, _: string, _: boolean): Plugin", @@ -97,13 +113,7 @@ "run.d.mts": { "#exports": [] }, - "types-!~{00b}~.d.mts": { - "Arrayable": "type Arrayable = T | T[]", - "Awaitable": "type Awaitable = T | Promise", - "MarkPartial": "type MarkPartial = Omit, K> & Partial>", - "Overwrite": "type Overwrite = Omit & U" - }, - "types-!~{00c}~.d.mts": { + "types-!~{00d}~.d.mts": { "AttwOptions": "interface AttwOptions extends CheckPackageOptions {\n profile?: 'strict' | 'node16' | 'esm-only'\n level?: 'error' | 'warn'\n ignoreRules?: string[]\n}", "BuildContext": "interface BuildContext {\n options: ResolvedConfig\n hooks: Hookable\n}", "ChunkAddon": "type ChunkAddon = ChunkAddonObject | ChunkAddonFunction | string", @@ -120,12 +130,7 @@ "ExeOptions": "interface ExeOptions extends ExeExtensionOptions {\n seaConfig?: Omit\n fileName?: string | ((_: RolldownChunk) => string)\n}", "ExportsOptions": "interface ExportsOptions {\n devExports?: boolean | string\n packageJson?: boolean\n all?: boolean\n exclude?: (RegExp | string)[]\n legacy?: boolean\n customExports?: Record | ((_: Record, _: { pkg: PackageJson; chunks: ChunksByFormat; isPublish: boolean }) => Awaitable>)\n inlinedDependencies?: boolean\n}", "Format": "type Format = ModuleFormat", - "globalLogger": "Logger", "InlineConfig": "interface InlineConfig extends UserConfig {\n config?: boolean | string\n configLoader?: 'auto' | 'native' | 'unrun'\n filter?: RegExp | Arrayable\n}", - "Logger": "interface Logger {\n level: LogLevel\n options?: LoggerOptions\n info: (...args: any[]) => void\n warn: (...args: any[]) => void\n warnOnce: (...args: any[]) => void\n error: (...args: any[]) => void\n success: (...args: any[]) => void\n clearScreen: (_: LogType) => void\n}", - "LoggerOptions": "interface LoggerOptions {\n allowClearScreen?: boolean\n customLogger?: Logger\n console?: Console\n failOnWarn?: boolean\n}", - "LogLevel": "type LogLevel = LogType | 'silent'", - "LogType": "type LogType = 'error' | 'warn' | 'info'", "NoExternalFn": "type NoExternalFn = (_: string, _: string | undefined) => boolean | null | undefined | void", "NormalizedFormat": "type NormalizedFormat = InternalModuleFormat", "OutExtensionContext": "interface OutExtensionContext {\n options: InputOptions\n format: NormalizedFormat\n pkgType?: PackageType\n}", diff --git a/packages/css/src/lightningcss.ts b/packages/css/src/lightningcss.ts index 446c84d2f..c9637688e 100644 --- a/packages/css/src/lightningcss.ts +++ b/packages/css/src/lightningcss.ts @@ -3,8 +3,8 @@ import path from 'node:path' import { ResolverFactory } from 'rolldown/experimental' import { compilePreprocessor, getPreprocessorLang } from './preprocessors.ts' import type { LightningCSSOptions, PreprocessorOptions } from './options.ts' -import type { MinimalLogger } from './types.ts' import type { Targets } from 'lightningcss' +import type { Logger } from 'tsdown/internal' let resolver: ResolverFactory | undefined function getResolver(): ResolverFactory { @@ -27,7 +27,7 @@ export interface BundleCssOptions { lightningcss?: LightningCSSOptions minify?: boolean preprocessorOptions?: PreprocessorOptions - logger: MinimalLogger + logger: Logger } export interface BundleCssResult { diff --git a/packages/css/src/plugin.ts b/packages/css/src/plugin.ts index 03cef234d..9282d5874 100644 --- a/packages/css/src/plugin.ts +++ b/packages/css/src/plugin.ts @@ -8,9 +8,9 @@ import { CssPostPlugin, type CssStyles } from './post.ts' import { processWithPostCSS as runPostCSS } from './postcss.ts' import { compilePreprocessor, getPreprocessorLang } from './preprocessors.ts' import { getCleanId, RE_CSS } from './utils.ts' -import type { MinimalLogger } from './types.ts' import type { Plugin } from 'rolldown' import type { ResolvedConfig } from 'tsdown' +import type { Logger } from 'tsdown/internal' const CSS_LANGS_RE = /\.(?:css|less|sass|scss|styl|stylus)$/ @@ -22,7 +22,7 @@ interface CssPluginConfig { export function CssPlugin( config: ResolvedConfig, - { logger }: { logger: MinimalLogger }, + { logger }: { logger: Logger }, ): Plugin[] { const cssConfig: CssPluginConfig = { css: resolveCssOptions(config.css, config.target), @@ -179,7 +179,7 @@ async function processWithLightningCSS( cleanId: string, deps: string[], config: CssPluginConfig, - logger: MinimalLogger, + logger: Logger, ): Promise { const lang = getPreprocessorLang(id) diff --git a/packages/css/src/types.ts b/packages/css/src/types.ts deleted file mode 100644 index bda95a7be..000000000 --- a/packages/css/src/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface MinimalLogger { - info: (...args: any[]) => void - warn: (...args: any[]) => void -} diff --git a/packages/exe/package.json b/packages/exe/package.json index cc333717c..cc4b135be 100644 --- a/packages/exe/package.json +++ b/packages/exe/package.json @@ -47,6 +47,9 @@ "scripts": { "build": "node -C dev ../../src/run.ts -c ../../tsdown.config.ts -F ." }, + "peerDependencies": { + "tsdown": "workspace:*" + }, "dependencies": { "obug": "catalog:prod", "semver": "catalog:prod", diff --git a/packages/exe/src/download.ts b/packages/exe/src/download.ts index e726289ae..8d7ea3677 100644 --- a/packages/exe/src/download.ts +++ b/packages/exe/src/download.ts @@ -3,7 +3,7 @@ import { chmod, mkdir, rename, writeFile } from 'node:fs/promises' import path from 'node:path' import { createDebug } from 'obug' import { x } from 'tinyexec' -import { fsExists, fsRemove } from '../../../src/utils/fs.ts' +import { fsExists, fsRemove, type Logger } from 'tsdown/internal' import { getCachedBinaryPath } from './cache.ts' import { getArchiveExtension, @@ -15,13 +15,9 @@ import { const debug = createDebug('tsdown:exe:download') -export interface MinimalLogger { - info: (...args: any[]) => void -} - export async function resolveNodeBinary( target: ExeTarget, - logger?: MinimalLogger, + logger?: Logger, ): Promise { debug('Resolving Node.js binary for target: %O', target) target.nodeVersion = await resolveNodeVersion(target.nodeVersion) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcfe87d82..3f695e9ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -491,6 +491,9 @@ importers: tinyexec: specifier: catalog:prod version: 1.0.2 + tsdown: + specifier: workspace:* + version: link:../.. packages/migrate: dependencies: diff --git a/src/internal.ts b/src/internal.ts index fbb9a60b7..21509763e 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -1,2 +1,4 @@ +export { fsExists, fsRemove } from './utils/fs.ts' export { importWithError, resolveComma, toArray } from './utils/general.ts' +export type { Logger } from './utils/logger.ts' export type { MarkPartial, Overwrite } from './utils/types.ts' From 6630d7823f31baeab11277039175216032a7a942 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 02:41:27 +0800 Subject: [PATCH 09/12] test: add unit tests for resolveCssOptions --- packages/css/src/options.test.ts | 68 ++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 packages/css/src/options.test.ts diff --git a/packages/css/src/options.test.ts b/packages/css/src/options.test.ts new file mode 100644 index 000000000..761a96358 --- /dev/null +++ b/packages/css/src/options.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'vitest' +import { defaultCssBundleName, resolveCssOptions } from './options.ts' + +describe('resolveCssOptions', () => { + test('defaults', () => { + const result = resolveCssOptions() + expect(result).toEqual({ + transformer: 'lightningcss', + splitting: false, + fileName: defaultCssBundleName, + minify: false, + inject: false, + target: undefined, + preprocessorOptions: undefined, + lightningcss: undefined, + postcss: undefined, + }) + }) + + test('inherits top-level target', () => { + const result = resolveCssOptions({}, ['chrome100']) + expect(result.target).toEqual(['chrome100']) + }) + + test('css.target overrides top-level target', () => { + const result = resolveCssOptions({ target: 'safari16' }, ['chrome100']) + expect(result.target).toEqual(['safari16']) + }) + + test('css.target with comma-separated values', () => { + const result = resolveCssOptions({ target: 'chrome100,safari16' }) + expect(result.target).toEqual(['chrome100', 'safari16']) + }) + + test('css.target array', () => { + const result = resolveCssOptions({ target: ['chrome100', 'safari16'] }) + expect(result.target).toEqual(['chrome100', 'safari16']) + }) + + test('css.target=false disables target', () => { + const result = resolveCssOptions({ target: false }, ['chrome100']) + expect(result.target).toBeUndefined() + }) + + test('custom options are passed through', () => { + const result = resolveCssOptions({ + transformer: 'postcss', + splitting: true, + fileName: 'custom.css', + minify: true, + inject: true, + preprocessorOptions: { scss: { additionalData: '$x: 1;' } }, + lightningcss: { drafts: { customMedia: true } }, + postcss: { plugins: [] }, + }) + expect(result).toEqual({ + transformer: 'postcss', + splitting: true, + fileName: 'custom.css', + minify: true, + inject: true, + target: undefined, + preprocessorOptions: { scss: { additionalData: '$x: 1;' } }, + lightningcss: { drafts: { customMedia: true } }, + postcss: { plugins: [] }, + }) + }) +}) From 2b026012f2666d12dc142691c92b07d80a3c85b2 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 02:45:03 +0800 Subject: [PATCH 10/12] test: add css-guard plugin error message test --- tests/css.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/css.test.ts b/tests/css.test.ts index 1e39cbc28..4181af7dc 100644 --- a/tests/css.test.ts +++ b/tests/css.test.ts @@ -2,6 +2,44 @@ import { describe, expect, test } from 'vitest' import { testBuild } from './utils.ts' describe('css', () => { + test('css-guard plugin throws error for CSS files', async () => { + // Test the guard plugin behavior directly — this is the plugin that gets + // registered when @tsdown/css is not installed. + const guardPlugin = { + name: 'tsdown:css-guard', + transform: { + order: 'post' as const, + filter: { id: /\.(?:css|less|sass|scss|styl|stylus)$/ }, + handler(_code: string, id: string) { + throw new Error( + `CSS file "${id}" was encountered but \`@tsdown/css\` is not installed. ` + + `Please install it: \`npm install @tsdown/css\``, + ) + }, + }, + } + + const cssExtensions = [ + 'style.css', + 'theme.less', + 'app.sass', + 'main.scss', + 'base.styl', + 'global.stylus', + ] + for (const file of cssExtensions) { + expect(() => + guardPlugin.transform.handler('', file), + ).toThrow(`CSS file "${file}" was encountered but \`@tsdown/css\` is not installed`) + } + + // Non-CSS files should not match the filter + expect(guardPlugin.transform.filter.id.test('index.ts')).toBe(false) + expect(guardPlugin.transform.filter.id.test('app.js')).toBe(false) + expect(guardPlugin.transform.filter.id.test('style.css')).toBe(true) + expect(guardPlugin.transform.filter.id.test('theme.scss')).toBe(true) + }) + test('basic', async (context) => { const { outputFiles } = await testBuild({ context, From b75d55bd097b115be899822d047553b77383a0ec Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 02:46:36 +0800 Subject: [PATCH 11/12] refactor: extract CssGuardPlugin and import it in tests --- src/features/rolldown.ts | 31 ++++++++++++++++++------------- tests/css.test.ts | 37 ++++++++++++++----------------------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/features/rolldown.ts b/src/features/rolldown.ts index 681ea328d..1ffd62148 100644 --- a/src/features/rolldown.ts +++ b/src/features/rolldown.ts @@ -8,6 +8,7 @@ import { type BuildOptions, type InputOptions, type OutputOptions, + type Plugin, type RolldownPluginOption, } from 'rolldown' import { importGlobPlugin } from 'rolldown/experimental' @@ -152,19 +153,7 @@ async function resolveInputOptions( const { CssPlugin } = await import('@tsdown/css') plugins.push(CssPlugin(config, { logger })) } else { - plugins.push({ - name: 'tsdown:css-guard', - transform: { - order: 'post', - filter: { id: /\.(?:css|less|sass|scss|styl|stylus)$/ }, - handler(_code, id) { - throw new Error( - `CSS file "${id}" was encountered but \`@tsdown/css\` is not installed. ` + - `Please install it: \`npm install @tsdown/css\``, - ) - }, - }, - }) + plugins.push(CssGuardPlugin()) } plugins.push(ShebangPlugin(logger, cwd, nameLabel, isDualFormat)) @@ -351,3 +340,19 @@ function handlePluginInspect(plugins: RolldownPluginOption) { } } } + +export function CssGuardPlugin(): Plugin { + return { + name: 'tsdown:css-guard', + transform: { + order: 'post', + filter: { id: /\.(?:css|less|sass|scss|styl|stylus)$/ }, + handler(_code, id) { + throw new Error( + `CSS file "${id}" was encountered but \`@tsdown/css\` is not installed. ` + + `Please install it: \`npm install @tsdown/css\``, + ) + }, + }, + } +} diff --git a/tests/css.test.ts b/tests/css.test.ts index 4181af7dc..b03aa0044 100644 --- a/tests/css.test.ts +++ b/tests/css.test.ts @@ -1,23 +1,13 @@ import { describe, expect, test } from 'vitest' +import { CssGuardPlugin } from '../src/features/rolldown.ts' import { testBuild } from './utils.ts' describe('css', () => { - test('css-guard plugin throws error for CSS files', async () => { - // Test the guard plugin behavior directly — this is the plugin that gets - // registered when @tsdown/css is not installed. - const guardPlugin = { - name: 'tsdown:css-guard', - transform: { - order: 'post' as const, - filter: { id: /\.(?:css|less|sass|scss|styl|stylus)$/ }, - handler(_code: string, id: string) { - throw new Error( - `CSS file "${id}" was encountered but \`@tsdown/css\` is not installed. ` + - `Please install it: \`npm install @tsdown/css\``, - ) - }, - }, - } + test('css-guard plugin throws error for CSS files', () => { + const guardPlugin = CssGuardPlugin() + const { transform } = guardPlugin + const handler = typeof transform === 'object' ? transform.handler : transform + const filter = typeof transform === 'object' ? transform.filter : undefined const cssExtensions = [ 'style.css', @@ -28,16 +18,17 @@ describe('css', () => { 'global.stylus', ] for (const file of cssExtensions) { - expect(() => - guardPlugin.transform.handler('', file), - ).toThrow(`CSS file "${file}" was encountered but \`@tsdown/css\` is not installed`) + expect(() => handler!.call({} as any, '', file)).toThrow( + `CSS file "${file}" was encountered but \`@tsdown/css\` is not installed`, + ) } // Non-CSS files should not match the filter - expect(guardPlugin.transform.filter.id.test('index.ts')).toBe(false) - expect(guardPlugin.transform.filter.id.test('app.js')).toBe(false) - expect(guardPlugin.transform.filter.id.test('style.css')).toBe(true) - expect(guardPlugin.transform.filter.id.test('theme.scss')).toBe(true) + const idFilter = filter!.id as RegExp + expect(idFilter.test('index.ts')).toBe(false) + expect(idFilter.test('app.js')).toBe(false) + expect(idFilter.test('style.css')).toBe(true) + expect(idFilter.test('theme.scss')).toBe(true) }) test('basic', async (context) => { From 3c4d56727adf84171860f3c38999905c925fd5f0 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 02:47:31 +0800 Subject: [PATCH 12/12] test: remove --- tests/css.test.ts | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/tests/css.test.ts b/tests/css.test.ts index b03aa0044..1e39cbc28 100644 --- a/tests/css.test.ts +++ b/tests/css.test.ts @@ -1,36 +1,7 @@ import { describe, expect, test } from 'vitest' -import { CssGuardPlugin } from '../src/features/rolldown.ts' import { testBuild } from './utils.ts' describe('css', () => { - test('css-guard plugin throws error for CSS files', () => { - const guardPlugin = CssGuardPlugin() - const { transform } = guardPlugin - const handler = typeof transform === 'object' ? transform.handler : transform - const filter = typeof transform === 'object' ? transform.filter : undefined - - const cssExtensions = [ - 'style.css', - 'theme.less', - 'app.sass', - 'main.scss', - 'base.styl', - 'global.stylus', - ] - for (const file of cssExtensions) { - expect(() => handler!.call({} as any, '', file)).toThrow( - `CSS file "${file}" was encountered but \`@tsdown/css\` is not installed`, - ) - } - - // Non-CSS files should not match the filter - const idFilter = filter!.id as RegExp - expect(idFilter.test('index.ts')).toBe(false) - expect(idFilter.test('app.js')).toBe(false) - expect(idFilter.test('style.css')).toBe(true) - expect(idFilter.test('theme.scss')).toBe(true) - }) - test('basic', async (context) => { const { outputFiles } = await testBuild({ context,