This commit is contained in:
Huzaifa Iftikhar 2026-09-26 12:43:46 +08:00 • committed by GitHub
commit 3122bc2b44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 97 additions and 0 deletions

View file

@ -119,6 +119,23 @@ describe('is-safe-migration', async () => {
[`function Button({ variant='outline' }) {}`, 'outline'],
[`Button({ variant: "outline" })`, 'outline'],
[`Button({ variant: 'outline' })`, 'outline'],
[`Button({ variant: isActive ? "outline" : "ghost" })`, 'outline'],
[`<Button variant={first ? "default" : "outline"} />`, 'outline'],
[`<Button variant={variant ?? "outline"} />`, 'outline'],
[`<Button variant={required ? 'secondary' : 'outline'} />`, 'outline'],
[`<Button variant={isActive(foo, bar) ? "outline" : "ghost"} />`, 'outline'],
[`<Button variant={isActive(getState(foo, bar), x) ? "outline" : "ghost"} />`, 'outline'],
[`<div :variant="active ? 'ghost' : 'outline'"></div>`, 'outline'],
[`Button({ variant: (value ?? "outline") })`, 'outline'],
[`Button({ variant: theme === "dark" ? "outline" : "ghost" })`, 'outline'],
[`<div :variant="isActive({ x: 1 }) ? 'ghost' : 'outline'"></div>`, 'outline'],
[`<Button variant={{ tone: "dark" }.tone === "dark" ? "outline" : "ghost"} />`, 'outline'],
[`<Button variant={cond ? ("outline") : "ghost"} />`, 'outline'],
[`<Button variant={cond ? "ghost" : ("outline")} />`, 'outline'],
[`<Button variant={{ tone: "}" }.tone === "}" ? "outline" : "ghost"} />`, 'outline'],
[`<Button variant={{ a: { b: { c: 1 } } }.a ? "outline" : "ghost"} />`, 'outline'],
[`Button({ variant: { a: { b: 1 } }.a ? "outline" : "ghost" })`, 'outline'],
[`<Button variant={obj["state"] ? "outline" : "ghost"} />`, 'outline'],
])('does not replace classes in invalid positions #%#', async (example, candidate) => {
expect(
await migrateCandidate(designSystem, {}, candidate, {
@ -142,6 +159,23 @@ describe('is-safe-migration', async () => {
// Preact-style
[`<div enterClass="shadow"></div>`, 'shadow', 'shadow-sm'],
// A conditional class is still a class, even next to a `variant` prop
[`<div className={active ? "shadow" : "none"}></div>`, 'shadow', 'shadow-sm'],
[`<Button variant="ghost" className={active ? "shadow" : "none"} />`, 'shadow', 'shadow-sm'],
[
`Button({ variant: a ? "ghost" : "x", className: b ? "shadow" : "y" })`,
'shadow',
'shadow-sm',
],
[`<div variant="ghost" :class="active ? 'shadow' : 'none'"></div>`, 'shadow', 'shadow-sm'],
[`<Button variant={{ a: 1 }} className={b ? "shadow" : "none"} />`, 'shadow', 'shadow-sm'],
[`<Button variant={cn("ghost", active ? "shadow" : "none")} />`, 'shadow', 'shadow-sm'],
[
`<div title="variant: outline" className={b ? "shadow" : "none"}></div>`,
'shadow',
'shadow-sm',
],
])('replaces classes in valid positions #%#', async (example, candidate, expected) => {
expect(
await migrateCandidate(designSystem, {}, candidate, {

View file

@ -189,6 +189,14 @@ export function isSafeMigration(
}
}
// Heuristic: Disallow anything inside a shadcn/ui `variant` prop, because a
// conditional can sit between the prop and the string
//
// E.g.: `<Button variant={active ? "outline" : "ghost"} />`
if (isInsideVariantValue(currentLineBeforeCandidate)) {
return false
}
// Heuristic: Disallow Next.js Image `placeholder` prop
if (NEXT_PLACEHOLDER_PROP.test(currentLineBeforeCandidate)) {
return false
@ -202,6 +210,61 @@ export function isSafeMigration(
return true
}
const VARIANT_PROP = /variant\s*([:=])\s*/g
const CALLEE = /[\w$.)\]]/
// The candidate is the last thing on the line, so the `variant` value it sits in
// is the one that has not ended yet. Each form ends somewhere different:
// `variant={…}` at its matching brace, `variant="…"` at its own quote, and
// `{ variant: … }` at a comma or at the brace around it.
function isInsideVariantValue(line: string): boolean {
for (let match of line.matchAll(VARIANT_PROP)) {
// The prop itself is never inside a string, so a `variant:` that is only
// mentioned in some other attribute value does not count
if (isMiddleOfString(line.slice(0, match.index))) continue
let start = match.index + match[0].length
let char = line[start]
if (match[1] === ':') {
if (isOpenValue(line, start)) return true
} else if (char === '{') {
if (isOpenValue(line, start + 1)) return true
} else if (char === '"' || char === "'" || char === '`') {
if (!line.includes(char, start + 1)) return true
}
}
return false
}
function isOpenValue(line: string, start: number): boolean {
// Whether each open group is the argument list of a call, because an argument
// is passed to that call and is not the value itself
let groups: boolean[] = []
let quote: string | null = null
for (let i = start; i < line.length; i++) {
let char = line[i]
if (quote !== null) {
if (char === '\\') i++
else if (char === quote) quote = null
} else if (char === '"' || char === "'" || char === '`') {
quote = char
} else if (char === '(' || char === '[' || char === '{') {
groups.push(char === '(' && CALLEE.test(line[i - 1] ?? ''))
} else if (char === ')' || char === ']' || char === '}') {
if (groups.length === 0) return false
groups.pop()
} else if (char === ',' && groups.length === 0) {
return false
}
}
return !groups.includes(true)
}
// Assumptions:
// - All `<style` tags appear before the next `</style>` tag
// - All `<style` tags are closed with `</style>`