fix: ui prefix breaking sidebar

This commit is contained in:
Rotimi Best
2026-06-12 01:03:17 +01:00
parent 6c918329cb
commit 8fd80e9dc5
12 changed files with 161 additions and 28 deletions
+123 -4
View File
@@ -28,6 +28,33 @@ const REPO_ROOT = path.join(__dirname, '../..');
const UI_SRC_DIR = path.join(__dirname, 'src');
const UI_SOURCE_EXTENSIONS = new Set(['.svelte', '.ts', '.tsx', '.js', '.jsx']);
// Variant/prop literals — never Tailwind class strings (e.g. side === 'left', variant: 'outline')
const SEMANTIC_LITERALS = new Set([
'left',
'right',
'top',
'bottom',
'center',
'start',
'end',
'outline',
'ghost',
'default',
'destructive',
'secondary',
'link',
'sm',
'md',
'lg',
'xl',
'2xl',
'icon',
'dot',
'line',
'page',
'none'
]);
// Collapse duplicate ui: markers (e.g. ui:hover:ui:bg-accent → ui:hover:bg-accent)
function normalizeUiClass(className) {
if (!className.includes(UI_PREFIX)) {
@@ -38,8 +65,36 @@ function normalizeUiClass(className) {
return `${UI_PREFIX}${stripped}`;
}
function shouldProcessAsClassString(value) {
if (!value || value.trim() === '') {
return false;
}
if (SEMANTIC_LITERALS.has(value)) {
return false;
}
const tokens = value.split(/\s+/).filter(Boolean);
if (tokens.length === 0) {
return false;
}
if (tokens.some((token) => token.startsWith(UI_PREFIX))) {
return true;
}
const tailwindTokenCount = tokens.filter((token) => isTailwindClass(token)).length;
// Avoid treating prose in // comments (e.g. dialog's … sticky footer) as class strings.
return tailwindTokenCount >= Math.max(1, Math.ceil(tokens.length * 0.5));
}
// Check if a class looks like a Tailwind utility class
function isTailwindClass(className) {
if (SEMANTIC_LITERALS.has(className)) {
return false;
}
// Skip if already prefixed (including malformed mid-chain ui: after a variant)
if (className.startsWith(UI_PREFIX) || className.includes(':ui:')) {
return false;
@@ -156,8 +211,28 @@ function processClassString(classString, scopedClasses = new Set()) {
return processClassTokens(classString, scopedClasses);
}
function repairCommentSwallowedClassStrings(content) {
// cn(..., // comment.'ui:classes', ...) — class string got glued into a line comment
return content.replace(
/^(\s*\/\/[^\n]*?)\.(['"]ui:[^'"]+['"],?\s*)$/gm,
(_, commentLine, classLiteral) => `${commentLine}.\n${classLiteral}`
);
}
function repairCorruptedQuotedPrefixes(content) {
return content.replace(/ui:'([^']*)'/g, "'ui:$1'").replace(/ui:"([^"]*)"/g, '"ui:$1"');
let repaired = content.replace(/ui:'([^']*)'/g, "'ui:$1'").replace(/ui:"([^"]*)"/g, '"ui:$1"');
for (const literal of SEMANTIC_LITERALS) {
const escaped = literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
repaired = repaired.replace(new RegExp(`===\\s*['"]${UI_PREFIX}${escaped}['"]`, 'g'), `=== '${literal}'`);
repaired = repaired.replace(new RegExp(`!==\\s*['"]${UI_PREFIX}${escaped}['"]`, 'g'), `!== '${literal}'`);
repaired = repaired.replace(
new RegExp(`(variant|side|align|size|indicator|density)\\s*:\\s*['"]${UI_PREFIX}${escaped}['"]`, 'g'),
`$1: '${literal}'`
);
}
return repaired;
}
function processInterpolatedSegments(value, scopedClasses, expressionPattern) {
@@ -191,11 +266,19 @@ function processQuotedClassStrings(content, scopedClasses = new Set()) {
let processed = content;
processed = processed.replace(/'([^']*)'/g, (match, quoted) => {
if (!shouldProcessAsClassString(quoted)) {
return match;
}
const result = processClassString(quoted, scopedClasses);
return result !== quoted ? `'${result}'` : match;
});
processed = processed.replace(/"([^"]*)"/g, (match, quoted) => {
if (!shouldProcessAsClassString(quoted)) {
return match;
}
const result = processClassString(quoted, scopedClasses);
return result !== quoted ? `"${result}"` : match;
});
@@ -203,9 +286,22 @@ function processQuotedClassStrings(content, scopedClasses = new Set()) {
return processed;
}
function processMultilineCnArgs(args, scopedClasses = new Set()) {
return args
.split('\n')
.map((line) => {
if (line.trimStart().startsWith('//')) {
return line;
}
return processQuotedClassStrings(line, scopedClasses);
})
.join('\n');
}
// Process file content
function processFileContent(content, scopedClasses = new Set()) {
let modified = repairCorruptedQuotedPrefixes(content);
let modified = repairCommentSwallowedClassStrings(repairCorruptedQuotedPrefixes(content));
let changes = modified !== content ? 1 : 0;
// Helper to process and count changes
@@ -312,18 +408,30 @@ function processFileContent(content, scopedClasses = new Set()) {
// Process double-quoted strings
processedObj = processedObj.replace(/"([^"]*)"/g, (match, value) => {
if (!shouldProcessAsClassString(value)) {
return match;
}
const processed = processClassString(value, scopedClasses);
return processed !== value ? `"${processed}"` : match;
});
// Process single-quoted strings
processedObj = processedObj.replace(/'([^']*)'/g, (match, value) => {
if (!shouldProcessAsClassString(value)) {
return match;
}
const processed = processClassString(value, scopedClasses);
return processed !== value ? `'${processed}'` : match;
});
// Process template literals
processedObj = processedObj.replace(/`([^`]*)`/g, (match, value) => {
if (!shouldProcessAsClassString(value)) {
return match;
}
const processed = processClassString(value, scopedClasses);
return processed !== value ? `\`${processed}\`` : match;
});
@@ -334,13 +442,13 @@ function processFileContent(content, scopedClasses = new Set()) {
// Pattern 8: class: cn(...) patterns (like in derived props)
// Matches: class: cn('string1', 'string2', ...)
processAndReplace(/class:\s*cn\s*\(([^)]+)\)/g, (match, args) => {
const processedArgs = processQuotedClassStrings(args, scopedClasses);
const processedArgs = processMultilineCnArgs(args, scopedClasses);
return processedArgs !== args ? `class: cn(${processedArgs})` : match;
});
// Pattern 8b: class={cn(...)} in Svelte markup (supports embedded quotes in class strings)
processAndReplace(/class=\{cn\s*\(([\s\S]*?)\)\}/g, (match, args) => {
const processedArgs = processQuotedClassStrings(args, scopedClasses);
const processedArgs = processMultilineCnArgs(args, scopedClasses);
return processedArgs !== args ? `class={cn(${processedArgs})}` : match;
});
@@ -470,6 +578,17 @@ const AUDIT_PATTERNS = [
id: 'inverted-variant-prefix',
label: 'variant:ui:utility (ui: after variant instead of at start)',
regex: /(?:^|[\s"'`{])(?:hover|focus|active|disabled|dark|sm|md|lg|xl|2xl|group-hover|peer-focus):ui:/
},
{
id: 'semantic-literal-prefix',
label: "Semantic prop/compare literals prefixed (e.g. === 'ui:left', side: 'ui:right')",
regex:
/===\s*['"]ui:(?:left|right|top|bottom|outline|ghost|default|center|start|end)['"]|(variant|side|align|size|indicator|density)\s*:\s*['"]ui:(?:left|right|top|bottom|outline|ghost|default)['"]/
},
{
id: 'comment-swallowed-class',
label: 'Class string glued into a // comment (dialog/chart cn() corruption)',
regex: /\/\/[^\n]*\.'ui:[^'\n]+'/
}
];
@@ -46,7 +46,12 @@
// of the other series, this overrides that
'ui:[&_.lc-area-path]:opacity-100 ui:[&_.lc-highlight-line]:opacity-100 ui:[&_.lc-highlight-point]:opacity-100 ui:[&_.lc-spline-path]:opacity-100 ui:[&_.lc-text]:text-xs ui:[&_.lc-text-svg]:overflow-visible',
// We don't want the little tick lines between the axis labels and the chart, so we remove // the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every // chart.'ui:[&_.lc-axis-tick]:stroke-0', // We don't want to display the rule on the x/y axis, as there is already going to be
// We don't want the little tick lines between the axis labels and the chart, so we remove
// the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every
// chart.
'ui:[&_.lc-axis-tick]:stroke-0',
// We don't want to display the rule on the x/y axis, as there is already going to be
// a grid line there and rule ends up overlapping the marks because it is rendered after
// the marks
'ui:[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 ui:[&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0',
@@ -26,8 +26,10 @@
data-slot="dialog-content"
class={cn(
// `has-[[data-slot=dialog-footer][data-sticky=true]]:pb-0` drops the
// dialog's own ui:bottom padding when a ui:sticky footer is rendered — // the footer's own `py-4` provides the bottom spacing instead, and
// it pins flush with the dialog's ui:bottom edge.'ui:bg-background ui:border ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-95 ui:fixed ui:left-[50%] ui:top-[50%] ui:z-200 ui:grid ui:w-full ui:max-w-[calc(100%-2rem)] ui:translate-x-[-50%] ui:translate-y-[-50%] ui:gap-4 ui:rounded-lg ui:p-6 ui:[&:has([data-slot=dialog-footer][data-sticky=true])]:pb-0 ui:shadow-lg ui:duration-200 ui:sm:max-w-lg',
// dialog's own bottom padding when a sticky footer is rendered —
// the footer's own `py-4` provides the bottom spacing instead, and
// it pins flush with the dialog's bottom edge.
'ui:bg-background ui:border ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-95 ui:fixed ui:left-[50%] ui:top-[50%] ui:z-200 ui:grid ui:w-full ui:max-w-[calc(100%-2rem)] ui:translate-x-[-50%] ui:translate-y-[-50%] ui:gap-4 ui:rounded-lg ui:p-6 ui:[&:has([data-slot=dialog-footer][data-sticky=true])]:pb-0 ui:shadow-lg ui:duration-200 ui:sm:max-w-lg',
className
)}
{...restProps}
@@ -29,7 +29,7 @@
data-active={isActive}
class={cn(
buttonVariants({
variant: isActive ? 'ui:outline' : 'ghost',
variant: isActive ? 'outline' : 'ghost',
size
}),
className
@@ -13,7 +13,7 @@
}
},
defaultVariants: {
side: 'ui:right'
side: 'right'
}
});
+1 -1
View File
@@ -78,7 +78,7 @@
data-slot="sidebar-container"
class={cn(
'ui:w-(--sidebar-width) ui:fixed ui:inset-y-0 ui:z-100 ui:hidden ui:max-h-svh ui:transition-[left,right,width] ui:duration-200 ui:ease-linear ui:md:flex',
side === 'ui:left'
side === 'left'
? 'ui:left-0 ui:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'ui:right-0 ui:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
@@ -143,9 +143,9 @@
class={cn(
'ui:relative ui:my-2 ui:flex ui:flex-col ui:rounded-md ui:border-2 ui:border-transparent',
selected ? 'ui:border-muted-foreground' : '',
node.attrs.align === 'ui:left' && 'ui:left-0 ui:-translate-x-0',
node.attrs.align === 'left' && 'ui:left-0 ui:-translate-x-0',
node.attrs.align === 'center' && 'ui:left-1/2 ui:-translate-x-1/2',
node.attrs.align === 'ui:right' && 'ui:left-full ui:-translate-x-full'
node.attrs.align === 'right' && 'ui:left-full ui:-translate-x-full'
)}
style={`width: ${node.attrs.width}`}
>
@@ -197,7 +197,7 @@
>
<Button
variant="ghost"
class={cn('ui:size-6 ui:p-0', node.attrs.align === 'ui:left' && 'ui:bg-muted')}
class={cn('ui:size-6 ui:p-0', node.attrs.align === 'left' && 'ui:bg-muted')}
onclick={() => updateAttributes({ align: 'left' })}
title="Align Left"
>
@@ -213,7 +213,7 @@
</Button>
<Button
variant="ghost"
class={cn('ui:size-6 ui:p-0', node.attrs.align === 'ui:right' && 'ui:bg-muted')}
class={cn('ui:size-6 ui:p-0', node.attrs.align === 'right' && 'ui:bg-muted')}
onclick={() => updateAttributes({ align: 'right' })}
title="Align Right"
>