Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
176 lines
5.0 KiB
TypeScript
176 lines
5.0 KiB
TypeScript
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 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 isCvaIdentifier(node: ts.Expression) {
|
|
return ts.isIdentifier(node) && node.text === 'cva';
|
|
}
|
|
|
|
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)
|
|
&& 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[]) {
|
|
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) || !mayContainCleanupTarget(sourceCode)) {
|
|
return sourceCode;
|
|
}
|
|
|
|
const sourceFile = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true, getScriptKind(filePath));
|
|
const edits: ITextEdit[] = [];
|
|
|
|
function visit(node: ts.Node) {
|
|
if (isStaticStringLiteral(node) && isClassNameWhitespaceTarget(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,
|
|
};
|
|
},
|
|
};
|
|
}
|