diff --git a/common/shared/tsdown/configs/module.ts b/common/shared/tsdown/configs/module.ts index e2a681669d..44e2e11530 100644 --- a/common/shared/tsdown/configs/module.ts +++ b/common/shared/tsdown/configs/module.ts @@ -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; + 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 }, diff --git a/common/shared/tsdown/index.ts b/common/shared/tsdown/index.ts index c81d2ec0f0..17c7abccce 100644 --- a/common/shared/tsdown/index.ts +++ b/common/shared/tsdown/index.ts @@ -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, diff --git a/common/shared/tsdown/plugins/class-name-whitespace-cleanup.spec.ts b/common/shared/tsdown/plugins/class-name-whitespace-cleanup.spec.ts new file mode 100644 index 0000000000..86545c72c6 --- /dev/null +++ b/common/shared/tsdown/plugins/class-name-whitespace-cleanup.spec.ts @@ -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 = ( +
+ ); + `; + + 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); + }); +}); diff --git a/common/shared/tsdown/plugins/class-name-whitespace-cleanup.ts b/common/shared/tsdown/plugins/class-name-whitespace-cleanup.ts new file mode 100644 index 0000000000..e1e397ef6b --- /dev/null +++ b/common/shared/tsdown/plugins/class-name-whitespace-cleanup.ts @@ -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, + }; + }, + }; +} diff --git a/common/shared/tsdown/types.ts b/common/shared/tsdown/types.ts index 507202c16b..402495a3e3 100644 --- a/common/shared/tsdown/types.ts +++ b/common/shared/tsdown/types.ts @@ -38,6 +38,7 @@ export interface IPackageJson { } export interface IBuildContext { + bundledPackages: string[]; entries: IEntryConfig[]; externalPackages: string[]; facadeExternalPackages: string[]; diff --git a/common/shared/tsdown/utils/base-config.ts b/common/shared/tsdown/utils/base-config.ts index 061675f09a..68f0ede4b0 100644 --- a/common/shared/tsdown/utils/base-config.ts +++ b/common/shared/tsdown/utils/base-config.ts @@ -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()] : []), + ]; } /** diff --git a/common/shared/tsdown/utils/package.spec.ts b/common/shared/tsdown/utils/package.spec.ts new file mode 100644 index 0000000000..2074f6b247 --- /dev/null +++ b/common/shared/tsdown/utils/package.spec.ts @@ -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', + ]); + }); +}); diff --git a/common/shared/tsdown/utils/package.ts b/common/shared/tsdown/utils/package.ts index 999008fa3e..462ffa03fa 100644 --- a/common/shared/tsdown/utils/package.ts +++ b/common/shared/tsdown/utils/package.ts @@ -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)); }