mirror of
https://github.com/dream-num/univer.git
synced 2026-09-24 15:32:18 +08:00
build(tsdown): bundle third-party deps and normalize class templates (#6711)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export interface IPackageJson {
|
||||
}
|
||||
|
||||
export interface IBuildContext {
|
||||
bundledPackages: string[];
|
||||
entries: IEntryConfig[];
|
||||
externalPackages: string[];
|
||||
facadeExternalPackages: string[];
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user