Skip to content

Commit 158b440

Browse files
authored
feat(svelte-scoped): handle clsx like classes (#4758)
1 parent 879d539 commit 158b440

File tree

14 files changed

+550
-28
lines changed

14 files changed

+550
-28
lines changed

docs/integrations/svelte-scoped.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,15 @@ A regular UnoCSS/Tailwind CSS setup places utility styles in a global CSS file w
4242

4343
Because Svelte Scoped rewrites your utility class names, you are limited in where you can write them:
4444

45-
| Supported Syntax | Example |
46-
| ------------------------- | -------------------------------- |
47-
| Class attribute | `<div class="mb-1" />` |
48-
| Class directive | `<div class:mb-1={condition} />` |
49-
| Class directive shorthand | `<div class:logo />` |
50-
| Class prop | `<Button class="mb-1" />` |
51-
52-
Svelte Scoped is designed to be a drop-in replacement for a project that uses utility styles. As such, expressions found within class attributes are also supported (e.g. `<div class="mb-1 {foo ? 'mr-1' : 'mr-2'}" />`) but we recommend you use the class directive syntax moving forward. Note also that if you've used class names in other ways like placing them in a `<script>` block or using attributify mode then you'll need to take additional steps before using Svelte Scoped. You can utilize the `safelist` option and also check the [presets](#presets-support) section below for more tips.
45+
| Supported Syntax | Example |
46+
| ------------------------- | ------------------------------------------------------------------------------------------- |
47+
| Class attribute | `<div class="mb-1" />` |
48+
| Class directive | `<div class:mb-1={condition} />` |
49+
| Class directive shorthand | `<div class:logo />` |
50+
| Class prop | `<Button class="mb-1" />` |
51+
| `clsx` like | `<div class={["mb-1", { logo, 'font-bold': isBold() }, isUnderlined() && 'underline' ]} />` |
52+
53+
Svelte Scoped is designed to be a drop-in replacement for a project that uses utility styles. As such, expressions found within class attributes are also supported (e.g. `<div class="mb-1 {foo ? 'mr-1' : 'mr-2'}" />`) but we recommend you use the `clsx` syntax moving forward. Note also that if you've used class names in other ways like placing them in a `<script>` block or using attributify mode then you'll need to take additional steps before using Svelte Scoped. You can utilize the `safelist` option and also check the [presets](#presets-support) section below for more tips.
5354

5455
### Context aware
5556

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script lang="ts">
2+
import Button from './Button.svelte';
3+
4+
let underline = $state(false);
5+
let bold = $state(false);
6+
let italic = $state(false);
7+
let red = $state(false);
8+
</script>
9+
10+
<p class={['p-3', { underline, 'font-bold': bold }, italic && 'italic']} class:c-red={red}>
11+
Demo text using clsx classes and <code>`class:`</code> directive
12+
</p>
13+
14+
<Button onclick={() => (underline = !underline)}>toggle underline</Button>
15+
<Button onclick={() => (bold = !bold)}>toggle bold</Button>
16+
<Button onclick={() => (italic = !italic)}>toggle italic</Button>
17+
<Button onclick={() => (red = !red)}>toggle red</Button>

examples/sveltekit-scoped/src/routes/+page.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import Button from "$lib/Button.svelte";
3+
import ClsxDemo from "$lib/ClsxDemo.svelte";
34
import Counter from "$lib/Counter.svelte";
45
import Forms from "$lib/Forms.svelte";
56
import Logos from "./Logos.svelte";
@@ -45,10 +46,14 @@
4546

4647
<Counter />
4748

48-
<div class="my-5 border" />
49+
<div class="my-5 border"></div>
4950

5051
<Forms />
5152

53+
<div class="my-5 border"></div>
54+
55+
<ClsxDemo />
56+
5257
<div class="corner">Fixed</div>
5358
</div>
5459

packages-integrations/svelte-scoped/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,10 @@
6060
"@unocss/config": "workspace:*",
6161
"@unocss/preset-uno": "workspace:*",
6262
"@unocss/reset": "workspace:*",
63+
"acorn": "catalog:",
6364
"css-tree": "catalog:",
64-
"magic-string": "catalog:"
65+
"magic-string": "catalog:",
66+
"zimmerframe": "catalog:"
6567
},
6668
"devDependencies": {
6769
"prettier-plugin-svelte": "catalog:",

packages-integrations/svelte-scoped/src/_preprocess/transformClasses/findClasses.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,141 @@ describe(findClasses, () => {
7272
]
7373
expect(result).toEqual(expected)
7474
})
75+
76+
it('finds clsx like classes (singleton array)', () => {
77+
const code = '<span class={["abc"]} />'
78+
const result = findClasses(code)
79+
const expected: FoundClass[] = [
80+
{
81+
body: 'abc',
82+
start: 15,
83+
end: 18,
84+
type: 'regular',
85+
},
86+
]
87+
expect(result).toEqual(expected)
88+
})
89+
90+
it('finds clsx like classes (simple array)', () => {
91+
// eslint-disable-next-line no-template-curly-in-string
92+
const code = '<span class={["abc def", `ghi`, `button-${dynamic}`]} />'
93+
const result = findClasses(code)
94+
const expected: FoundClass[] = [
95+
{
96+
body: 'abc def',
97+
start: 15,
98+
end: 22,
99+
type: 'regular',
100+
},
101+
{
102+
body: 'ghi',
103+
start: 26,
104+
end: 29,
105+
type: 'regular',
106+
},
107+
]
108+
expect(result).toEqual(expected)
109+
})
110+
111+
it('finds clsx like classes (conditional array)', () => {
112+
const code = '<span class={["abc def", true && `ghi`, false ? \'jkl\' : "mno"]} />'
113+
const result = findClasses(code)
114+
const expected: FoundClass[] = [
115+
{
116+
body: 'abc def',
117+
start: 15,
118+
end: 22,
119+
type: 'regular',
120+
},
121+
{
122+
body: 'ghi',
123+
start: 34,
124+
end: 37,
125+
type: 'regular',
126+
},
127+
{
128+
body: 'jkl',
129+
start: 49,
130+
end: 52,
131+
type: 'regular',
132+
},
133+
{
134+
body: 'mno',
135+
start: 57,
136+
end: 60,
137+
type: 'regular',
138+
},
139+
]
140+
expect(result).toEqual(expected)
141+
})
142+
143+
it('finds clsx like classes (object)', () => {
144+
const code = '<span class={{ "abc def": true, "ghi": !bool, show }} />'
145+
const result = findClasses(code)
146+
const expected: FoundClass[] = [
147+
{
148+
body: 'abc def',
149+
start: 15,
150+
end: 24,
151+
type: 'clsxObject',
152+
},
153+
{
154+
body: 'ghi',
155+
start: 32,
156+
end: 37,
157+
type: 'clsxObject',
158+
},
159+
{
160+
body: 'show',
161+
start: 46,
162+
end: 50,
163+
type: 'clsxObjectShorthand',
164+
},
165+
]
166+
expect(result).toEqual(expected)
167+
})
168+
169+
it('finds clsx like classes (complex array)', () => {
170+
const code = '<span class={[{ "abc def": true, "ghi": !bool, show }, "jkl", ["mno"], { pqr: 0 }]} />'
171+
const result = findClasses(code)
172+
const expected: FoundClass[] = [
173+
{
174+
body: 'abc def',
175+
start: 16,
176+
end: 25,
177+
type: 'clsxObject',
178+
},
179+
{
180+
body: 'ghi',
181+
start: 33,
182+
end: 38,
183+
type: 'clsxObject',
184+
},
185+
{
186+
body: 'show',
187+
start: 47,
188+
end: 51,
189+
type: 'clsxObjectShorthand',
190+
},
191+
{
192+
body: 'jkl',
193+
start: 56,
194+
end: 59,
195+
type: 'regular',
196+
},
197+
{
198+
body: 'mno',
199+
start: 64,
200+
end: 67,
201+
type: 'regular',
202+
},
203+
{
204+
body: 'pqr',
205+
start: 73,
206+
end: 76,
207+
type: 'clsxObject',
208+
},
209+
]
210+
expect(result).toEqual(expected)
211+
})
75212
})

packages-integrations/svelte-scoped/src/_preprocess/transformClasses/findClasses.ts

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,36 @@
1+
import * as acorn from 'acorn'
2+
import { walk } from 'zimmerframe'
3+
14
export interface FoundClass {
25
body: string
36
start: number
47
end: number
58
type: ClassForms
69
}
710

8-
type ClassForms = 'regular' | 'directive' | 'directiveShorthand'
11+
type ClassForms = 'regular' | 'directive' | 'directiveShorthand' | 'clsxObject' | 'clsxObjectShorthand'
912

1013
const classesRE = /class=(["'`])([\s\S]*?)\1/g // class="mb-1"
1114
const classDirectivesRE = /class:(\S+?)="?\{/g // class:mb-1={foo} and class:mb-1="{foo}"
1215
const classDirectivesShorthandRE = /class:([^=>\s/]+)[{>\s/]/g // class:logo (compiled to class:uno-1hashz={logo})
16+
const classClsxRE = /(?<prefix>class=\{\w*)[[{(]/g // class={[...]}, class={{...}} and class={clsx(...)} (in fact any function call)
1317

1418
export function findClasses(code: string) {
1519
const matchedClasses = [...code.matchAll(classesRE)]
1620
const matchedClassDirectives = [...code.matchAll(classDirectivesRE)]
1721
const matchedClassDirectivesShorthand = [...code.matchAll(classDirectivesShorthandRE)]
22+
const matchedClassClsx = [...code.matchAll(classClsxRE)]
1823

1924
const classes = parseMatches(matchedClasses, 'regular', 'class="'.length)
2025
const classDirectives = parseMatches(matchedClassDirectives, 'directive', 'class:'.length)
2126
const classDirectivesShorthand = parseMatches(matchedClassDirectivesShorthand, 'directiveShorthand', 'class:'.length)
27+
const classClsx = parseMatchesWithAcorn(matchedClassClsx, code)
2228

23-
return [...classes, ...classDirectives, ...classDirectivesShorthand]
29+
return [...classes, ...classDirectives, ...classDirectivesShorthand, ...classClsx]
2430
}
2531

2632
function parseMatches(matches: RegExpMatchArray[], type: ClassForms, prefixLength: number) {
27-
return matches.map((match) => {
33+
return matches.map((match): FoundClass => {
2834
const body = match[type === 'regular' ? 2 : 1]
2935
const start = match.index! + prefixLength
3036
return {
@@ -39,3 +45,85 @@ function parseMatches(matches: RegExpMatchArray[], type: ClassForms, prefixLengt
3945
function hasBody(foundClass: FoundClass) {
4046
return foundClass.body
4147
}
48+
49+
function parseMatchesWithAcorn(matches: RegExpMatchArray[], code: string) {
50+
return matches.flatMap((match): FoundClass[] => {
51+
const start = match.index! + match.groups!.prefix.length
52+
53+
const ast = acorn.parseExpressionAt(code, start, {
54+
sourceType: 'module',
55+
ecmaVersion: 16,
56+
locations: true,
57+
})
58+
59+
const classes: FoundClass[] = []
60+
61+
function fromProperty(body: string, node: acorn.AnyNode, property: acorn.Property | acorn.AssignmentProperty): FoundClass {
62+
return {
63+
body,
64+
start: node.start,
65+
end: node.end,
66+
type: property.shorthand ? 'clsxObjectShorthand' : 'clsxObject',
67+
}
68+
}
69+
70+
function fromString(body: string, node: acorn.AnyNode): FoundClass {
71+
return {
72+
body,
73+
start: node.start + 1,
74+
end: node.end - 1,
75+
type: 'regular',
76+
}
77+
}
78+
79+
walk(
80+
ast as acorn.AnyNode,
81+
{ property: undefined as acorn.Property | acorn.AssignmentProperty | undefined },
82+
{
83+
Property(node, { visit }) {
84+
visit(node.key, { property: node })
85+
},
86+
87+
Identifier(node, { state, next }) {
88+
if (state.property) {
89+
classes.push(fromProperty(node.name, node, state.property))
90+
}
91+
92+
next()
93+
},
94+
95+
Literal(node, { state, next }) {
96+
if (typeof node.value === 'string') {
97+
const body = node.value
98+
99+
if (state.property) {
100+
classes.push(fromProperty(body, node, state.property))
101+
}
102+
else {
103+
classes.push(fromString(body, node))
104+
}
105+
}
106+
107+
next()
108+
},
109+
110+
TemplateLiteral(node, { state, next }) {
111+
if (node.expressions.length === 0 && node.quasis.length === 1) {
112+
const body = node.quasis[0].value.raw
113+
114+
if (state.property) {
115+
classes.push(fromProperty(body, node, state.property))
116+
}
117+
else {
118+
classes.push(fromString(body, node))
119+
}
120+
}
121+
122+
next()
123+
},
124+
},
125+
)
126+
127+
return classes
128+
}).filter(hasBody)
129+
}

packages-integrations/svelte-scoped/src/_preprocess/transformClasses/processClasses.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { UnoGenerator } from '@unocss/core'
22
import type { TransformClassesOptions } from '../types'
33
import type { FoundClass } from './findClasses'
44
import { processClassBody } from './processClassBody'
5+
import { processClsx } from './processClsx.js'
56
import { processDirective } from './processDirective'
67

78
export interface ProcessResult {
@@ -28,25 +29,26 @@ export async function processClasses(classes: FoundClass[], options: TransformCl
2829
}
2930

3031
for (const foundClass of classes) {
31-
if (foundClass.type === 'regular') {
32-
const { rulesToGenerate, codeUpdate } = await processClassBody(foundClass, options, uno, filename)
32+
const { rulesToGenerate, codeUpdate } = await processClass(foundClass, options, uno, filename)
3333

34-
if (rulesToGenerate)
35-
Object.assign(result.rulesToGenerate, rulesToGenerate)
34+
if (rulesToGenerate)
35+
Object.assign(result.rulesToGenerate, rulesToGenerate)
3636

37-
if (codeUpdate)
38-
result.codeUpdates.push(codeUpdate)
39-
}
40-
else {
41-
const { rulesToGenerate, codeUpdate } = await processDirective(foundClass, options, uno, filename) || {}
37+
if (codeUpdate)
38+
result.codeUpdates.push(codeUpdate)
39+
}
40+
41+
return result
42+
}
4243

43-
if (rulesToGenerate)
44-
Object.assign(result.rulesToGenerate, rulesToGenerate)
44+
async function processClass(foundClass: FoundClass, options: TransformClassesOptions, uno: UnoGenerator, filename: string): Promise<Partial<ProcessResult>> {
45+
if (foundClass.type === 'regular') {
46+
return await processClassBody(foundClass, options, uno, filename)
47+
}
4548

46-
if (codeUpdate)
47-
result.codeUpdates.push(codeUpdate)
48-
}
49+
if (foundClass.type === 'clsxObject' || foundClass.type === 'clsxObjectShorthand') {
50+
return await processClsx(foundClass, options, uno, filename) ?? {}
4951
}
5052

51-
return result
53+
return await processDirective(foundClass, options, uno, filename) ?? {}
5254
}

0 commit comments

Comments
 (0)