build(tsdown): bundle third-party deps and normalize class templates (#6711)

This commit is contained in:
白熱
2026-03-19 21:09:11 +08:00
committed by GitHub
parent 4ce8da8c32
commit 6d84a00d7d
8 changed files with 283 additions and 14 deletions
+10 -3
View File
@@ -22,8 +22,13 @@ import { createOutputObfuscatorPlugin } from '../plugins/output-obfuscator';
export type TModuleFormat = 'cjs' | 'esm';
function matchesPackage(source: string, packageNames: string[]) {
return packageNames.some((packageName) => source === packageName || source.startsWith(`${packageName}/`));
}
export interface ICreateModuleConfigOptions {
baseConfig: Partial<UserConfig>;
bundledPackages: string[];
enableObfuscation: boolean;
entry: IEntryConfig;
externalPackages: string[];
@@ -38,15 +43,17 @@ export interface ICreateModuleConfigOptions {
* Creates the common ESM/CJS bundle config for a single package entry.
*/
export function createModuleConfig(options: ICreateModuleConfigOptions): UserConfig {
const { baseConfig, enableObfuscation, entry, externalPackages, facadeExternalPackages, format, outDir, packageDir, plugins } = options;
const neverBundle = entry.type === 'facade' ? facadeExternalPackages : externalPackages;
const { baseConfig, bundledPackages, enableObfuscation, entry, externalPackages, facadeExternalPackages, format, outDir, packageDir, plugins } = options;
const neverBundlePackages = entry.type === 'facade' ? facadeExternalPackages : externalPackages;
const copyToRoot = format === 'esm';
const keepRootIndexCss = entry.type === 'index' && format === 'esm';
return defineConfig({
...baseConfig,
deps: {
neverBundle,
alwaysBundle: (source) => matchesPackage(source, bundledPackages),
neverBundle: (source) => matchesPackage(source, neverBundlePackages),
onlyBundle: false,
},
dts: false,
entry: { [entry.key]: entry.path },
+5 -2
View File
@@ -27,7 +27,7 @@ import { createBaseConfig, createInputOptions, createInputPlugins } from './util
import { cleanupPackageJson } from './utils/cleanup-pkg';
import { getEntries } from './utils/entries';
import { removeCssArtifacts } from './utils/files';
import { createExternalPackages, readPackageJson } from './utils/package';
import { createBundledPackages, createExternalPackages, readPackageJson } from './utils/package';
import { emitPublishPackageJson } from './utils/publish-manifest';
/**
@@ -35,12 +35,14 @@ import { emitPublishPackageJson } from './utils/publish-manifest';
*/
function createBuildContext(packageDir: string, options: IBuildOptions): IBuildContext {
const packageJson = readPackageJson(packageDir);
const bundledPackages = createBundledPackages(packageJson);
const externalPackages = createExternalPackages(packageJson);
return {
bundledPackages,
entries: getEntries(packageDir),
externalPackages,
facadeExternalPackages: [...externalPackages, packageJson.name, `${packageJson.name}/*`],
facadeExternalPackages: [...externalPackages, packageJson.name],
inputOptions: createInputOptions(options),
packageDir,
packageJson,
@@ -59,6 +61,7 @@ function createConfigs(context: IBuildContext, options: IBuildOptions) {
const moduleConfigs = context.entries.flatMap((entry) => {
return moduleFormats.map((format) => createModuleConfig({
baseConfig,
bundledPackages: context.bundledPackages,
enableObfuscation,
entry,
externalPackages: context.externalPackages,
@@ -0,0 +1,60 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { cleanupClassNameTemplateWhitespace } from './class-name-whitespace-cleanup';
describe('cleanupClassNameTemplateWhitespace', () => {
it('should normalize whitespace for clsx template arguments', () => {
const sourceCode = `
const value = clsx(\`
univer-flex univer-size-6 univer-cursor-pointer univer-items-center univer-justify-center
univer-justify-self-center univer-rounded
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
\`, {
active: true,
});
`;
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.tsx')).toContain(
'clsx("univer-flex univer-size-6 univer-cursor-pointer univer-items-center univer-justify-center univer-justify-self-center univer-rounded hover:univer-bg-gray-100 dark:hover:!univer-bg-gray-700", {'
);
});
it('should normalize whitespace for className template literals', () => {
const sourceCode = `
const value = (
<div
className={\`
univer-box-border univer-grid univer-grid-cols-5 univer-gap-2 univer-text-gray-600
dark:!univer-text-gray-200
\`}
/>
);
`;
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.tsx')).toContain(
'className={"univer-box-border univer-grid univer-grid-cols-5 univer-gap-2 univer-text-gray-600 dark:!univer-text-gray-200"}'
);
});
it('should not touch unrelated template literals', () => {
const sourceCode = 'const message = ` hello\\n world `;';
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.ts')).toBe(sourceCode);
});
});
@@ -0,0 +1,131 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import path from 'node:path';
import * as ts from 'typescript';
interface ITextEdit {
end: number;
start: number;
text: string;
}
const SUPPORTED_SCRIPT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
function stripQueryAndHash(filePath: string) {
return filePath.replace(/[?#].*$/, '');
}
function shouldProcessFile(filePath: string) {
const normalizedPath = stripQueryAndHash(filePath);
if (normalizedPath.includes('/node_modules/')) {
return false;
}
return SUPPORTED_SCRIPT_EXTENSIONS.has(path.extname(normalizedPath));
}
function getScriptKind(filePath: string) {
switch (path.extname(stripQueryAndHash(filePath))) {
case '.jsx':
return ts.ScriptKind.JSX;
case '.tsx':
return ts.ScriptKind.TSX;
case '.js':
return ts.ScriptKind.JS;
default:
return ts.ScriptKind.TS;
}
}
function normalizeClassNameWhitespace(value: string) {
return value.replace(/\s+/g, ' ').trim();
}
function isClsxIdentifier(node: ts.Expression) {
return ts.isIdentifier(node) && node.text === 'clsx';
}
function isClsxTemplate(node: ts.NoSubstitutionTemplateLiteral) {
return ts.isCallExpression(node.parent)
&& node.parent.arguments.includes(node)
&& isClsxIdentifier(node.parent.expression);
}
function isClassNameTemplate(node: ts.NoSubstitutionTemplateLiteral) {
return ts.isJsxExpression(node.parent)
&& ts.isJsxAttribute(node.parent.parent)
&& ts.isIdentifier(node.parent.parent.name)
&& node.parent.parent.name.text === 'className';
}
function applyTextEdits(sourceCode: string, edits: ITextEdit[]) {
return edits
.sort((left, right) => right.start - left.start)
.reduce((code, edit) => `${code.slice(0, edit.start)}${edit.text}${code.slice(edit.end)}`, sourceCode);
}
export function cleanupClassNameTemplateWhitespace(sourceCode: string, filePath: string) {
if (!shouldProcessFile(filePath)) {
return sourceCode;
}
const sourceFile = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true, getScriptKind(filePath));
const edits: ITextEdit[] = [];
function visit(node: ts.Node) {
if (ts.isNoSubstitutionTemplateLiteral(node) && (isClsxTemplate(node) || isClassNameTemplate(node))) {
const normalized = normalizeClassNameWhitespace(node.text);
if (normalized !== node.text) {
edits.push({
end: node.end,
start: node.getStart(sourceFile),
text: JSON.stringify(normalized),
});
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (edits.length === 0) {
return sourceCode;
}
return applyTextEdits(sourceCode, edits);
}
export function createClassNameWhitespaceCleanupPlugin() {
return {
name: 'class-name-whitespace-cleanup',
transform(sourceCode: string, id: string) {
const cleanedCode = cleanupClassNameTemplateWhitespace(sourceCode, id);
if (cleanedCode === sourceCode) {
return null;
}
return {
code: cleanedCode,
map: null,
};
},
};
}
+1
View File
@@ -38,6 +38,7 @@ export interface IPackageJson {
}
export interface IBuildContext {
bundledPackages: string[];
entries: IEntryConfig[];
externalPackages: string[];
facadeExternalPackages: string[];
+5 -1
View File
@@ -19,13 +19,17 @@ import type { IBuildContext, IBuildOptions } from '../types';
import path from 'node:path';
import vue from 'unplugin-vue/rolldown';
import { DEFAULT_BROWSER_TARGET } from '../constants';
import { createClassNameWhitespaceCleanupPlugin } from '../plugins/class-name-whitespace-cleanup';
import { hasSourceFiles } from './files';
/**
* Resolves extra bundler plugins based on the package source layout.
*/
export function createInputPlugins(packageDir: string): any[] {
return hasSourceFiles(packageDir, '.vue') ? [vue()] : [];
return [
createClassNameWhitespaceCleanupPlugin(),
...(hasSourceFiles(packageDir, '.vue') ? [vue()] : []),
];
}
/**
@@ -0,0 +1,50 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { createBundledPackages, createExternalPackages } from './package';
const packageJson = {
name: '@univerjs/core',
dependencies: {
'@univerjs/design': 'workspace:*',
'@univerjs-pro/example': 'workspace:*',
'@wendellhu/redi': '1.1.1',
dayjs: '^1.11.20',
},
peerDependencies: {
react: '^19.0.0',
rxjs: '>=7.0.0',
},
};
describe('package dependency helpers', () => {
it('should only keep @univerjs and @univerjs-pro packages as external', () => {
expect(createExternalPackages(packageJson)).toEqual([
'@univerjs-pro/example',
'@univerjs/design',
]);
});
it('should bundle third-party production dependencies and peer dependencies', () => {
expect(createBundledPackages(packageJson)).toEqual([
'@wendellhu/redi',
'dayjs',
'react',
'rxjs',
]);
});
});
+21 -8
View File
@@ -18,6 +18,17 @@ import type { IPackageJson } from '../types';
import { readFileSync } from 'node:fs';
import path from 'node:path';
function isUniverPackage(packageName: string) {
return packageName.startsWith('@univerjs/') || packageName.startsWith('@univerjs-pro/');
}
function getProductionDependencyNames(packageJson: IPackageJson) {
return [...new Set([
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.peerDependencies ?? {}),
])].sort((left, right) => left.localeCompare(right));
}
/**
* Reads the package manifest used to derive dependency externalization rules.
*/
@@ -26,15 +37,17 @@ export function readPackageJson(packageDir: string): IPackageJson {
}
/**
* Produces the external package allowlist including subpath imports.
* Produces the external package allowlist.
*/
export function createExternalPackages(packageJson: IPackageJson) {
const dependencyNames = [
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.peerDependencies ?? {}),
];
return getProductionDependencyNames(packageJson)
.filter((packageName) => isUniverPackage(packageName));
}
return [...new Set(dependencyNames)]
.sort((left, right) => left.localeCompare(right))
.flatMap((packageName) => [packageName, `${packageName}/*`]);
/**
* Produces the package allowlist that should be bundled into ESM/CJS outputs.
*/
export function createBundledPackages(packageJson: IPackageJson) {
return getProductionDependencyNames(packageJson)
.filter((packageName) => !isUniverPackage(packageName));
}