mirror of
https://github.com/dream-num/univer.git
synced 2026-09-19 02:18:42 +08:00
build: enhance whitespace normalization for className handling in templates (#7040)
This commit is contained in:
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 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 { resolvePresetBuildOptions } from '../index';
|
||||
|
||||
describe('resolvePresetBuildOptions', () => {
|
||||
it('reads UMD prepend inputs from preset config and keeps CLI options as appenders', () => {
|
||||
expect(resolvePresetBuildOptions({
|
||||
umdAdditionalFiles: ['./polyfill.js'],
|
||||
umdDeps: ['@univerjs/core', '@univerjs/ui'],
|
||||
}, {
|
||||
cleanup: true,
|
||||
umdAdditionalFiles: ['./polyfill.js', './extra.js'],
|
||||
umdDeps: ['@univerjs/ui', '@univerjs/docs'],
|
||||
})).toEqual({
|
||||
cleanup: true,
|
||||
umdAdditionalFiles: ['./polyfill.js', './extra.js'],
|
||||
umdDeps: ['@univerjs/core', '@univerjs/ui', '@univerjs/docs'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,16 @@ export function resolvePresetBuildOptions(presetBuildConfig: IPresetBuildConfig
|
||||
return resolvedOptions;
|
||||
}
|
||||
|
||||
export function createPresetModuleEntryGroups(entries: ReturnType<typeof getPresetModuleEntries>) {
|
||||
const primaryEntries = entries.filter((entry) => entry.type === 'index' || entry.type === 'locale');
|
||||
const isolatedEntries = entries.filter((entry) => entry.type !== 'index' && entry.type !== 'locale');
|
||||
|
||||
return [
|
||||
...(primaryEntries.length > 0 ? [primaryEntries] : []),
|
||||
...isolatedEntries.map((entry) => [entry]),
|
||||
];
|
||||
}
|
||||
|
||||
export function removePresetOutputs(packageDir = process.cwd()) {
|
||||
for (const dir of CLEANUP_DIRECTORIES) {
|
||||
const targetDir = path.resolve(packageDir, dir);
|
||||
@@ -163,11 +173,11 @@ export async function buildPresetPackage(options: IPresetBuildOptions = {}) {
|
||||
const plugins = createInputPlugins(packageDir);
|
||||
const userConfig = await loadUserConfig(resolvedOptions, packageDir);
|
||||
const moduleFormats: TModuleFormat[] = ['esm', 'cjs'];
|
||||
const moduleConfigs = moduleFormats.flatMap((format) => {
|
||||
return getPresetModuleEntries(packageDir).map((entry) => createModuleConfig({
|
||||
const moduleConfigs = createPresetModuleEntryGroups(getPresetModuleEntries(packageDir)).flatMap((entries) => {
|
||||
return moduleFormats.map((format) => createModuleConfig({
|
||||
baseConfig,
|
||||
enableObfuscation: false,
|
||||
entry,
|
||||
entries,
|
||||
externalPackages,
|
||||
facadeExternalPackages: externalPackages,
|
||||
format,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { UserConfig } from 'tsdown';
|
||||
import type { IEntryConfig } from '../types';
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { createCssNoopInputOptions } from '../plugins/css-noop';
|
||||
import { createOutputAliasPlugin } from '../plugins/output-alias';
|
||||
import { createOutputObfuscatorPlugin } from '../plugins/output-obfuscator';
|
||||
|
||||
@@ -25,7 +26,7 @@ export type TModuleFormat = 'cjs' | 'esm';
|
||||
export interface ICreateModuleConfigOptions {
|
||||
baseConfig: Partial<UserConfig>;
|
||||
enableObfuscation: boolean;
|
||||
entry: IEntryConfig;
|
||||
entries: IEntryConfig[];
|
||||
externalPackages: string[];
|
||||
facadeExternalPackages: string[];
|
||||
format: TModuleFormat;
|
||||
@@ -39,10 +40,12 @@ 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, obfuscatorIgnorePatterns, outDir, packageDir, plugins } = options;
|
||||
const neverBundle = entry.type === 'facade' ? facadeExternalPackages : externalPackages;
|
||||
const { baseConfig, enableObfuscation, entries, externalPackages, facadeExternalPackages, format, obfuscatorIgnorePatterns, outDir, packageDir, plugins } = options;
|
||||
const hasFacadeEntry = entries.some((entry) => entry.type === 'facade');
|
||||
const hasIndexEntry = entries.some((entry) => entry.type === 'index');
|
||||
const neverBundle = hasFacadeEntry ? facadeExternalPackages : externalPackages;
|
||||
const copyToRoot = format === 'esm';
|
||||
const keepRootIndexCss = entry.type === 'index' && format === 'esm';
|
||||
const keepRootIndexCss = hasIndexEntry && format === 'esm';
|
||||
|
||||
return defineConfig({
|
||||
...baseConfig,
|
||||
@@ -50,8 +53,9 @@ export function createModuleConfig(options: ICreateModuleConfigOptions): UserCon
|
||||
neverBundle,
|
||||
},
|
||||
dts: false,
|
||||
entry: { [entry.key]: entry.path },
|
||||
entry: Object.fromEntries(entries.map((entry) => [entry.key, entry.path])),
|
||||
format,
|
||||
inputOptions: keepRootIndexCss ? baseConfig.inputOptions : createCssNoopInputOptions(baseConfig.inputOptions),
|
||||
outputOptions: {
|
||||
codeSplitting: true,
|
||||
minify: enableObfuscation,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { UserConfig } from 'tsdown';
|
||||
import type { IEntryConfig } from '../types';
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { peerDepsMap } from '../data/peer-deps';
|
||||
import { createCssNoopInputOptions } from '../plugins/css-noop';
|
||||
import { createOutputAliasPlugin } from '../plugins/output-alias';
|
||||
import { createOutputObfuscatorPlugin } from '../plugins/output-obfuscator';
|
||||
|
||||
@@ -126,6 +127,7 @@ export function createUmdConfig(options: ICreateUmdConfigOptions): UserConfig {
|
||||
entry: { [entry.key]: entry.path },
|
||||
format: 'umd',
|
||||
globalName: getGlobalName(packageName, entry.key),
|
||||
inputOptions: createCssNoopInputOptions(baseConfig.inputOptions),
|
||||
outDir,
|
||||
outputOptions: {
|
||||
entryFileNames: '[name].js',
|
||||
|
||||
@@ -51,16 +51,26 @@ function createBuildContext(packageDir: string, options: IBuildOptions): IBuildC
|
||||
/**
|
||||
* Expands the package context into all required tsdown configs.
|
||||
*/
|
||||
function createConfigs(context: IBuildContext, options: IBuildOptions) {
|
||||
function createModuleEntryGroups(entries: IBuildContext['entries']) {
|
||||
const primaryEntries = entries.filter((entry) => entry.type === 'index' || entry.type === 'locale');
|
||||
const isolatedEntries = entries.filter((entry) => entry.type !== 'index' && entry.type !== 'locale');
|
||||
|
||||
return [
|
||||
...(primaryEntries.length > 0 ? [primaryEntries] : []),
|
||||
...isolatedEntries.map((entry) => [entry]),
|
||||
];
|
||||
}
|
||||
|
||||
export function createConfigs(context: IBuildContext, options: IBuildOptions) {
|
||||
const baseConfig = createBaseConfig(context);
|
||||
const moduleFormats: TModuleFormat[] = ['esm', 'cjs'];
|
||||
const enableObfuscation = context.packageJson.name.startsWith('@univerjs-pro/');
|
||||
|
||||
const moduleConfigs = context.entries.flatMap((entry) => {
|
||||
const moduleConfigs = createModuleEntryGroups(context.entries).flatMap((entries) => {
|
||||
return moduleFormats.map((format) => createModuleConfig({
|
||||
baseConfig,
|
||||
enableObfuscation,
|
||||
entry,
|
||||
entries,
|
||||
externalPackages: context.externalPackages,
|
||||
facadeExternalPackages: context.facadeExternalPackages,
|
||||
format,
|
||||
|
||||
@@ -35,6 +35,25 @@ describe('cleanupClassNameTemplateWhitespace', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize whitespace for conditional clsx template arguments', () => {
|
||||
const sourceCode = `
|
||||
const value = clsx(
|
||||
"univer-relative univer-transition-all univer-duration-150",
|
||||
isDraggingItem && "univer-opacity-0",
|
||||
dragOverId === itemId && !isDraggingItem && \`
|
||||
univer-bg-primary-50/60
|
||||
dark:!univer-bg-primary-900/20
|
||||
univer-rounded univer-border univer-border-primary-200
|
||||
dark:!univer-border-primary-700
|
||||
\`
|
||||
);
|
||||
`;
|
||||
|
||||
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.tsx')).toContain(
|
||||
'dragOverId === itemId && !isDraggingItem && "univer-bg-primary-50/60 dark:!univer-bg-primary-900/20 univer-rounded univer-border univer-border-primary-200 dark:!univer-border-primary-700"'
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize whitespace for className template literals', () => {
|
||||
const sourceCode = `
|
||||
const value = (
|
||||
@@ -52,6 +71,76 @@ describe('cleanupClassNameTemplateWhitespace', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize whitespace for className string literals', () => {
|
||||
const sourceCode = `
|
||||
const value = (
|
||||
<button
|
||||
className="
|
||||
univer-flex univer-cursor-pointer univer-items-center univer-justify-center
|
||||
univer-border-none
|
||||
hover:univer-opacity-70
|
||||
"
|
||||
/>
|
||||
);
|
||||
`;
|
||||
|
||||
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.tsx')).toContain(
|
||||
'className="univer-flex univer-cursor-pointer univer-items-center univer-justify-center univer-border-none hover:univer-opacity-70"'
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize whitespace for compiled className properties', () => {
|
||||
const sourceCode = `
|
||||
jsx("button", {
|
||||
className: "\\n univer-flex univer-cursor-pointer univer-items-center univer-justify-center\\n univer-border-none\\n hover:univer-opacity-70\\n ",
|
||||
type: "button",
|
||||
});
|
||||
`;
|
||||
|
||||
expect(cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.js')).toContain(
|
||||
'className: "univer-flex univer-cursor-pointer univer-items-center univer-justify-center univer-border-none hover:univer-opacity-70"'
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize whitespace inside cva definitions', () => {
|
||||
const sourceCode = `
|
||||
const buttonVariants = cva(
|
||||
\`
|
||||
univer-box-border univer-inline-flex univer-cursor-pointer
|
||||
disabled:univer-pointer-events-none
|
||||
\`,
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: \`
|
||||
univer-border-primary-600 univer-bg-primary-600 univer-text-white
|
||||
hover:univer-bg-primary-500
|
||||
\`,
|
||||
},
|
||||
},
|
||||
compoundVariants: [{
|
||||
className: \`
|
||||
univer-gap-1 univer-rounded
|
||||
dark:!univer-bg-gray-700
|
||||
\`,
|
||||
}],
|
||||
}
|
||||
);
|
||||
`;
|
||||
|
||||
const cleanedCode = cleanupClassNameTemplateWhitespace(sourceCode, '/tmp/example.tsx');
|
||||
|
||||
expect(cleanedCode).toContain(
|
||||
'cva(\n "univer-box-border univer-inline-flex univer-cursor-pointer disabled:univer-pointer-events-none",'
|
||||
);
|
||||
expect(cleanedCode).toContain(
|
||||
'primary: "univer-border-primary-600 univer-bg-primary-600 univer-text-white hover:univer-bg-primary-500"'
|
||||
);
|
||||
expect(cleanedCode).toContain(
|
||||
'className: "univer-gap-1 univer-rounded dark:!univer-bg-gray-700"'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not touch unrelated template literals', () => {
|
||||
const sourceCode = 'const message = ` hello\\n world `;';
|
||||
|
||||
|
||||
@@ -56,21 +56,81 @@ function normalizeClassNameWhitespace(value: string) {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function mayContainCleanupTarget(sourceCode: string) {
|
||||
return /(?:className|clsx\s*\(|cva\s*\()/.test(sourceCode)
|
||||
&& /(?:\s{2,}|\\[nr])/.test(sourceCode);
|
||||
}
|
||||
|
||||
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 isCvaIdentifier(node: ts.Expression) {
|
||||
return ts.isIdentifier(node) && node.text === 'cva';
|
||||
}
|
||||
|
||||
function isClassNameTemplate(node: ts.NoSubstitutionTemplateLiteral) {
|
||||
function isStaticStringLiteral(node: ts.Node): node is ts.NoSubstitutionTemplateLiteral | ts.StringLiteral {
|
||||
return ts.isNoSubstitutionTemplateLiteral(node) || ts.isStringLiteral(node);
|
||||
}
|
||||
|
||||
function isClassNameIdentifier(name: ts.PropertyName | ts.JsxAttributeName) {
|
||||
return ts.isIdentifier(name) && name.text === 'className';
|
||||
}
|
||||
|
||||
function isClsxArgument(node: ts.Expression) {
|
||||
let current: ts.Node = node;
|
||||
|
||||
while (current.parent) {
|
||||
if (ts.isCallExpression(current.parent)) {
|
||||
return current.parent.arguments.includes(current as ts.Expression)
|
||||
&& isClsxIdentifier(current.parent.expression);
|
||||
}
|
||||
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isClassNameJsxAttributeValue(node: ts.Expression) {
|
||||
if (ts.isJsxAttribute(node.parent)) {
|
||||
return node.parent.initializer === node && isClassNameIdentifier(node.parent.name);
|
||||
}
|
||||
|
||||
return ts.isJsxExpression(node.parent)
|
||||
&& node.parent.expression === node
|
||||
&& ts.isJsxAttribute(node.parent.parent)
|
||||
&& ts.isIdentifier(node.parent.parent.name)
|
||||
&& node.parent.parent.name.text === 'className';
|
||||
&& isClassNameIdentifier(node.parent.parent.name);
|
||||
}
|
||||
|
||||
function isClassNamePropertyValue(node: ts.Expression) {
|
||||
return ts.isPropertyAssignment(node.parent)
|
||||
&& node.parent.initializer === node
|
||||
&& (
|
||||
isClassNameIdentifier(node.parent.name)
|
||||
|| (ts.isStringLiteral(node.parent.name) && node.parent.name.text === 'className')
|
||||
);
|
||||
}
|
||||
|
||||
function isInsideCvaCall(node: ts.Node) {
|
||||
let current: ts.Node | undefined = node.parent;
|
||||
|
||||
while (current) {
|
||||
if (ts.isCallExpression(current) && isCvaIdentifier(current.expression)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isClassNameWhitespaceTarget(node: ts.NoSubstitutionTemplateLiteral | ts.StringLiteral) {
|
||||
return isClsxArgument(node)
|
||||
|| isClassNameJsxAttributeValue(node)
|
||||
|| isClassNamePropertyValue(node)
|
||||
|| isInsideCvaCall(node);
|
||||
}
|
||||
|
||||
function applyTextEdits(sourceCode: string, edits: ITextEdit[]) {
|
||||
@@ -80,7 +140,7 @@ function applyTextEdits(sourceCode: string, edits: ITextEdit[]) {
|
||||
}
|
||||
|
||||
export function cleanupClassNameTemplateWhitespace(sourceCode: string, filePath: string) {
|
||||
if (!shouldProcessFile(filePath)) {
|
||||
if (!shouldProcessFile(filePath) || !mayContainCleanupTarget(sourceCode)) {
|
||||
return sourceCode;
|
||||
}
|
||||
|
||||
@@ -88,7 +148,7 @@ export function cleanupClassNameTemplateWhitespace(sourceCode: string, filePath:
|
||||
const edits: ITextEdit[] = [];
|
||||
|
||||
function visit(node: ts.Node) {
|
||||
if (ts.isNoSubstitutionTemplateLiteral(node) && (isClsxTemplate(node) || isClassNameTemplate(node))) {
|
||||
if (isStaticStringLiteral(node) && isClassNameWhitespaceTarget(node)) {
|
||||
const normalized = normalizeClassNameWhitespace(node.text);
|
||||
|
||||
if (normalized !== node.text) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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 type { UserConfig } from 'tsdown';
|
||||
|
||||
const CSS_NOOP_PLUGIN_NAME = 'univer-css-noop';
|
||||
const CSS_IMPORT_RE = /\.(?:css|less|sass|scss|styl|stylus)(?:$|\?)/;
|
||||
const CSS_NOOP_ID_PREFIX = '\0univer-css-noop:';
|
||||
const CSS_NOOP_ID_RE = /^\0univer-css-noop:/;
|
||||
|
||||
type TInputOptions = NonNullable<UserConfig['inputOptions']>;
|
||||
type TInputOptionsFunction = Extract<TInputOptions, (...args: any[]) => any>;
|
||||
type TInputOptionsObject = Exclude<TInputOptions, (...args: any[]) => any>;
|
||||
|
||||
function toPluginArray(plugins: unknown): any[] {
|
||||
return Array.isArray(plugins)
|
||||
? plugins
|
||||
: plugins
|
||||
? [plugins]
|
||||
: [];
|
||||
}
|
||||
|
||||
function withoutCssNoopPlugin(plugins: any[]) {
|
||||
return plugins.filter((plugin) => !plugin || plugin.name !== CSS_NOOP_PLUGIN_NAME);
|
||||
}
|
||||
|
||||
export function createCssNoopPlugin() {
|
||||
let nextId = 0;
|
||||
|
||||
return {
|
||||
name: CSS_NOOP_PLUGIN_NAME,
|
||||
resolveId: {
|
||||
filter: { id: CSS_IMPORT_RE },
|
||||
handler() {
|
||||
return `${CSS_NOOP_ID_PREFIX}${nextId++}`;
|
||||
},
|
||||
},
|
||||
load: {
|
||||
filter: { id: CSS_NOOP_ID_RE },
|
||||
handler() {
|
||||
return {
|
||||
code: 'export default {};',
|
||||
moduleSideEffects: false,
|
||||
moduleType: 'js',
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function prependCssNoopPlugin(plugins: any[]) {
|
||||
return [
|
||||
createCssNoopPlugin(),
|
||||
...withoutCssNoopPlugin(plugins),
|
||||
];
|
||||
}
|
||||
|
||||
export function createCssNoopInputOptions(inputOptions?: TInputOptions): TInputOptionsFunction {
|
||||
return (async (defaultOptions: { plugins?: unknown }, ...args: any[]) => {
|
||||
const defaultPlugins = toPluginArray(defaultOptions.plugins);
|
||||
|
||||
if (!inputOptions) {
|
||||
return {
|
||||
plugins: prependCssNoopPlugin(defaultPlugins),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof inputOptions === 'function') {
|
||||
const defaultOptionsWithCssNoop = {
|
||||
...defaultOptions,
|
||||
plugins: prependCssNoopPlugin(defaultPlugins),
|
||||
};
|
||||
const resolvedOptions = await (inputOptions as any)(defaultOptionsWithCssNoop, ...args);
|
||||
|
||||
if (!resolvedOptions) {
|
||||
return defaultOptionsWithCssNoop;
|
||||
}
|
||||
|
||||
return {
|
||||
...resolvedOptions,
|
||||
plugins: prependCssNoopPlugin(toPluginArray(resolvedOptions.plugins ?? defaultOptionsWithCssNoop.plugins)),
|
||||
};
|
||||
}
|
||||
|
||||
const inputOptionsObject = inputOptions as TInputOptionsObject & { plugins?: unknown };
|
||||
|
||||
return {
|
||||
...inputOptionsObject,
|
||||
plugins: prependCssNoopPlugin([
|
||||
...toPluginArray(inputOptionsObject.plugins),
|
||||
...defaultPlugins,
|
||||
]),
|
||||
};
|
||||
}) as TInputOptionsFunction;
|
||||
}
|
||||
@@ -47,11 +47,11 @@ export function Badge(props: IBadgeProps) {
|
||||
|
||||
{closable && (
|
||||
<button
|
||||
className={`
|
||||
className="
|
||||
univer-flex univer-cursor-pointer univer-items-center univer-justify-center univer-border-none
|
||||
univer-p-0 univer-outline-none univer-transition-opacity
|
||||
hover:univer-opacity-70
|
||||
`}
|
||||
"
|
||||
type="button"
|
||||
aria-label="Close badge"
|
||||
onClick={onClose}
|
||||
|
||||
Reference in New Issue
Block a user