effect-start 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/package.json +57 -0
- package/src/Bundle.ts +167 -0
- package/src/BundleFiles.ts +174 -0
- package/src/BundleHttp.test.ts +160 -0
- package/src/BundleHttp.ts +259 -0
- package/src/Commander.test.ts +1378 -0
- package/src/Commander.ts +672 -0
- package/src/Datastar.test.ts +267 -0
- package/src/Datastar.ts +68 -0
- package/src/Effect_HttpRouter.test.ts +570 -0
- package/src/EncryptedCookies.test.ts +427 -0
- package/src/EncryptedCookies.ts +451 -0
- package/src/FileHttpRouter.test.ts +207 -0
- package/src/FileHttpRouter.ts +122 -0
- package/src/FileRouter.ts +405 -0
- package/src/FileRouterCodegen.test.ts +598 -0
- package/src/FileRouterCodegen.ts +251 -0
- package/src/FileRouter_files.test.ts +64 -0
- package/src/FileRouter_path.test.ts +132 -0
- package/src/FileRouter_tree.test.ts +126 -0
- package/src/FileSystemExtra.ts +102 -0
- package/src/HttpAppExtra.ts +127 -0
- package/src/Hyper.ts +194 -0
- package/src/HyperHtml.test.ts +90 -0
- package/src/HyperHtml.ts +139 -0
- package/src/HyperNode.ts +37 -0
- package/src/JsModule.test.ts +14 -0
- package/src/JsModule.ts +116 -0
- package/src/PublicDirectory.test.ts +280 -0
- package/src/PublicDirectory.ts +108 -0
- package/src/Route.test.ts +873 -0
- package/src/Route.ts +992 -0
- package/src/Router.ts +80 -0
- package/src/SseHttpResponse.ts +55 -0
- package/src/Start.ts +133 -0
- package/src/StartApp.ts +43 -0
- package/src/StartHttp.ts +42 -0
- package/src/StreamExtra.ts +146 -0
- package/src/TestHttpClient.test.ts +54 -0
- package/src/TestHttpClient.ts +100 -0
- package/src/bun/BunBundle.test.ts +277 -0
- package/src/bun/BunBundle.ts +309 -0
- package/src/bun/BunBundle_imports.test.ts +50 -0
- package/src/bun/BunFullstackServer.ts +45 -0
- package/src/bun/BunFullstackServer_httpServer.ts +541 -0
- package/src/bun/BunImportTrackerPlugin.test.ts +77 -0
- package/src/bun/BunImportTrackerPlugin.ts +97 -0
- package/src/bun/BunTailwindPlugin.test.ts +335 -0
- package/src/bun/BunTailwindPlugin.ts +322 -0
- package/src/bun/BunVirtualFilesPlugin.ts +59 -0
- package/src/bun/index.ts +4 -0
- package/src/client/Overlay.ts +34 -0
- package/src/client/ScrollState.ts +120 -0
- package/src/client/index.ts +101 -0
- package/src/index.ts +24 -0
- package/src/jsx-datastar.d.ts +63 -0
- package/src/jsx-runtime.ts +23 -0
- package/src/jsx.d.ts +4402 -0
- package/src/testing.ts +55 -0
- package/src/x/cloudflare/CloudflareTunnel.ts +110 -0
- package/src/x/cloudflare/index.ts +1 -0
- package/src/x/datastar/Datastar.test.ts +267 -0
- package/src/x/datastar/Datastar.ts +68 -0
- package/src/x/datastar/index.ts +4 -0
- package/src/x/datastar/jsx-datastar.d.ts +63 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import * as t from "bun:test"
|
|
2
|
+
|
|
3
|
+
import { extractClassNames } from "./BunTailwindPlugin.ts"
|
|
4
|
+
|
|
5
|
+
// Keep the old broad implementation for comparison tests
|
|
6
|
+
function extractClassNamesBroad(source: string): Set<string> {
|
|
7
|
+
// Old broad implementation
|
|
8
|
+
const CLASS_NAME_REGEX = /^[^"'`\s]+$/
|
|
9
|
+
const classTokenRegex =
|
|
10
|
+
/\b[a-zA-Z][a-zA-Z0-9_:-]*(?:\[[^\]]*\])?(?:\/[0-9]+)?/g
|
|
11
|
+
|
|
12
|
+
return new Set(
|
|
13
|
+
Array
|
|
14
|
+
.from(source.matchAll(classTokenRegex))
|
|
15
|
+
.map(match => match[0])
|
|
16
|
+
.filter(token => CLASS_NAME_REGEX.test(token)),
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
t.describe("extractClassNames", () => {
|
|
21
|
+
t.test("Basic HTML class attributes", () => {
|
|
22
|
+
const source = `<div class="bg-red-500 text-white">Hello</div>`
|
|
23
|
+
const result = extractClassNames(source)
|
|
24
|
+
|
|
25
|
+
t
|
|
26
|
+
.expect([...result].sort())
|
|
27
|
+
.toEqual(["bg-red-500", "text-white"])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
t.test("Basic JSX className attributes", () => {
|
|
31
|
+
const source =
|
|
32
|
+
`<div className="flex items-center justify-between">Content</div>`
|
|
33
|
+
const result = extractClassNames(source)
|
|
34
|
+
|
|
35
|
+
t
|
|
36
|
+
.expect([...result].sort())
|
|
37
|
+
.toEqual(["flex", "items-center", "justify-between"])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
t.test("Single quotes", () => {
|
|
41
|
+
const source = `<div class='bg-blue-500 hover:bg-blue-600'>Button</div>`
|
|
42
|
+
const result = extractClassNames(source)
|
|
43
|
+
|
|
44
|
+
t
|
|
45
|
+
.expect([...result].sort())
|
|
46
|
+
.toEqual(["bg-blue-500", "hover:bg-blue-600"])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
t.test("Template literals in JSX", () => {
|
|
50
|
+
const source = `<div className={\`bg-\${color} text-lg\`}>Dynamic</div>`
|
|
51
|
+
const result = extractClassNames(source)
|
|
52
|
+
|
|
53
|
+
// Should extract valid static class names from template literals
|
|
54
|
+
t
|
|
55
|
+
.expect([...result].sort())
|
|
56
|
+
.toEqual(["text-lg"])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
t.test("JSX with quoted strings", () => {
|
|
60
|
+
const source = `<div className={"p-4 m-2"}>Static in braces</div>`
|
|
61
|
+
const result = extractClassNames(source)
|
|
62
|
+
|
|
63
|
+
t
|
|
64
|
+
.expect([...result].sort())
|
|
65
|
+
.toEqual(["m-2", "p-4"])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
t.test("Multi-line attributes", () => {
|
|
69
|
+
const source = `<div
|
|
70
|
+
className="
|
|
71
|
+
grid
|
|
72
|
+
grid-cols-3
|
|
73
|
+
gap-4
|
|
74
|
+
"
|
|
75
|
+
>Grid</div>`
|
|
76
|
+
const result = extractClassNames(source)
|
|
77
|
+
|
|
78
|
+
t
|
|
79
|
+
.expect([...result].sort())
|
|
80
|
+
.toEqual(["gap-4", "grid", "grid-cols-3"])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
t.test("Whitespace variations around equals", () => {
|
|
84
|
+
const cases = [
|
|
85
|
+
`<div class="text-sm">Normal</div>`,
|
|
86
|
+
`<div class ="text-md">Space before</div>`,
|
|
87
|
+
`<div class= "text-lg">Space after</div>`,
|
|
88
|
+
`<div class = "text-xl">Spaces both</div>`,
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
for (const source of cases) {
|
|
92
|
+
const result = extractClassNames(source)
|
|
93
|
+
|
|
94
|
+
t
|
|
95
|
+
.expect(result.size)
|
|
96
|
+
.toBe(1)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
t.test("Arbitrary value classes", () => {
|
|
101
|
+
const source =
|
|
102
|
+
`<div className="w-[32px] bg-[#ff0000] text-[1.5rem]">Arbitrary</div>`
|
|
103
|
+
const result = extractClassNames(source)
|
|
104
|
+
|
|
105
|
+
t
|
|
106
|
+
.expect([...result].sort())
|
|
107
|
+
.toEqual(["bg-[#ff0000]", "text-[1.5rem]", "w-[32px]"])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
t.test("Fraction classes", () => {
|
|
111
|
+
const source = `<div className="w-1/2 h-3/4">Fractions</div>`
|
|
112
|
+
const result = extractClassNames(source)
|
|
113
|
+
|
|
114
|
+
t
|
|
115
|
+
.expect([...result].sort())
|
|
116
|
+
.toEqual(["h-3/4", "w-1/2"])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
t.test("Complex Tailwind classes", () => {
|
|
120
|
+
const source =
|
|
121
|
+
`<div className="sm:w-1/2 md:w-1/3 lg:w-1/4 hover:bg-gray-100 focus:ring-2">Responsive</div>`
|
|
122
|
+
const result = extractClassNames(source)
|
|
123
|
+
|
|
124
|
+
t
|
|
125
|
+
.expect([...result].sort())
|
|
126
|
+
.toEqual([
|
|
127
|
+
"focus:ring-2",
|
|
128
|
+
"hover:bg-gray-100",
|
|
129
|
+
"lg:w-1/4",
|
|
130
|
+
"md:w-1/3",
|
|
131
|
+
"sm:w-1/2",
|
|
132
|
+
])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
t.test("Should ignore similar attribute names", () => {
|
|
136
|
+
const source =
|
|
137
|
+
`<div data-class="should-ignore" myclass="also-ignore" class="keep-this">Test</div>`
|
|
138
|
+
const result = extractClassNames(source)
|
|
139
|
+
|
|
140
|
+
t
|
|
141
|
+
.expect([...result])
|
|
142
|
+
.toEqual(["keep-this"])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
t.test("Should handle case sensitivity", () => {
|
|
146
|
+
const source =
|
|
147
|
+
`<div Class="uppercase-class" class="lowercase-class">Mixed case</div>`
|
|
148
|
+
const result = extractClassNames(source)
|
|
149
|
+
|
|
150
|
+
// Our current implementation only matches lowercase 'class'
|
|
151
|
+
t
|
|
152
|
+
.expect([...result])
|
|
153
|
+
.toEqual(["lowercase-class"])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
t.test("Empty class attributes", () => {
|
|
157
|
+
const source = `<div class="" className=''>Empty</div>`
|
|
158
|
+
const result = extractClassNames(source)
|
|
159
|
+
|
|
160
|
+
t
|
|
161
|
+
.expect(result.size)
|
|
162
|
+
.toBe(0)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
t.test("Classes with special characters", () => {
|
|
166
|
+
const source =
|
|
167
|
+
`<div className="group-hover:text-blue-500 peer-focus:ring-2">Special chars</div>`
|
|
168
|
+
const result = extractClassNames(source)
|
|
169
|
+
|
|
170
|
+
t
|
|
171
|
+
.expect([...result].sort())
|
|
172
|
+
.toEqual(["group-hover:text-blue-500", "peer-focus:ring-2"])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
t.test("Should not match classes in comments", () => {
|
|
176
|
+
const source = `
|
|
177
|
+
<!-- <div class="commented-out">Should not match</div> -->
|
|
178
|
+
<div class="real-class">Should match</div>
|
|
179
|
+
`
|
|
180
|
+
const result = extractClassNames(source)
|
|
181
|
+
|
|
182
|
+
t
|
|
183
|
+
.expect([...result])
|
|
184
|
+
.toEqual(["real-class"])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
t.test("Should not match classes in strings", () => {
|
|
188
|
+
const source = `
|
|
189
|
+
const message = "This class='fake-class' should not match";
|
|
190
|
+
<div class="real-class">Real element</div>
|
|
191
|
+
`
|
|
192
|
+
const result = extractClassNames(source)
|
|
193
|
+
|
|
194
|
+
t
|
|
195
|
+
.expect([...result])
|
|
196
|
+
.toEqual(["real-class"])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
t.test("Complex JSX expressions should be ignored", () => {
|
|
200
|
+
const source = `
|
|
201
|
+
<div className={condition ? "conditional-class" : "other-class"}>Conditional</div>
|
|
202
|
+
<div className={\`template-\${variable}\`}>Template</div>
|
|
203
|
+
<div className={getClasses()}>Function call</div>
|
|
204
|
+
<div className="static-class">Static</div>
|
|
205
|
+
`
|
|
206
|
+
const result = extractClassNames(source)
|
|
207
|
+
|
|
208
|
+
// Only the static class should match with our strict implementation
|
|
209
|
+
t
|
|
210
|
+
.expect([...result])
|
|
211
|
+
.toEqual(["static-class"])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
t.test("Vue.js class bindings should be ignored", () => {
|
|
215
|
+
const source = `
|
|
216
|
+
<div :class="{ 'active': isActive }">Vue object</div>
|
|
217
|
+
<div :class="['base', condition && 'active']">Vue array</div>
|
|
218
|
+
<div class="static-vue-class">Static Vue</div>
|
|
219
|
+
`
|
|
220
|
+
const result = extractClassNames(source)
|
|
221
|
+
|
|
222
|
+
// Only static class should match
|
|
223
|
+
t
|
|
224
|
+
.expect([...result])
|
|
225
|
+
.toEqual(["static-vue-class"])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
t.test("Svelte class directives should be ignored", () => {
|
|
229
|
+
const source = `
|
|
230
|
+
<div class:active={condition}>Svelte directive</div>
|
|
231
|
+
<div class="static-svelte-class">Static Svelte</div>
|
|
232
|
+
`
|
|
233
|
+
const result = extractClassNames(source)
|
|
234
|
+
|
|
235
|
+
t
|
|
236
|
+
.expect([...result])
|
|
237
|
+
.toEqual(["static-svelte-class"])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
t.test("Escaped quotes should be handled", () => {
|
|
241
|
+
const source =
|
|
242
|
+
`<div class="text-sm before:content-['Hello']">Escaped quotes</div>`
|
|
243
|
+
const result = extractClassNames(source)
|
|
244
|
+
|
|
245
|
+
t
|
|
246
|
+
.expect([...result].sort())
|
|
247
|
+
.toEqual(["before:content-['Hello']", "text-sm"])
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
t.test("Current broad implementation comparison", () => {
|
|
251
|
+
const source = `
|
|
252
|
+
<div class="bg-red-500 text-white">Element</div>
|
|
253
|
+
<p>Some random-text-with-hyphens in content</p>
|
|
254
|
+
const variable = "some-string";
|
|
255
|
+
`
|
|
256
|
+
|
|
257
|
+
const broadResult = extractClassNamesBroad(source)
|
|
258
|
+
const strictResult = extractClassNames(source)
|
|
259
|
+
|
|
260
|
+
// Broad should pick up more tokens
|
|
261
|
+
t
|
|
262
|
+
.expect(broadResult.size)
|
|
263
|
+
.toBeGreaterThan(strictResult.size)
|
|
264
|
+
|
|
265
|
+
// Strict should only have the actual class names
|
|
266
|
+
t
|
|
267
|
+
.expect([...strictResult].sort())
|
|
268
|
+
.toEqual(["bg-red-500", "text-white"])
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
t.test("Component names with dots", () => {
|
|
272
|
+
const source =
|
|
273
|
+
`<Toast.Toast class="toast toast-top toast-center fixed top-8 z-10">Content</Toast.Toast>`
|
|
274
|
+
const result = extractClassNames(source)
|
|
275
|
+
|
|
276
|
+
t
|
|
277
|
+
.expect([...result].sort())
|
|
278
|
+
.toEqual(["fixed", "toast", "toast-center", "toast-top", "top-8", "z-10"])
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
t.test("Complex component names and attributes", () => {
|
|
282
|
+
const source = `
|
|
283
|
+
<My.Component.Name className="flex items-center">Content</My.Component.Name>
|
|
284
|
+
<Component-with-dashes class="bg-red-500">Content</Component-with-dashes>
|
|
285
|
+
<Component123 className="text-lg">Content</Component123>
|
|
286
|
+
<namespace:element class="border-2">XML style</namespace:element>
|
|
287
|
+
`
|
|
288
|
+
const result = extractClassNames(source)
|
|
289
|
+
|
|
290
|
+
t
|
|
291
|
+
.expect([...result].sort())
|
|
292
|
+
.toEqual([
|
|
293
|
+
"bg-red-500",
|
|
294
|
+
"border-2",
|
|
295
|
+
"flex",
|
|
296
|
+
"items-center",
|
|
297
|
+
"text-lg",
|
|
298
|
+
])
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
t.test("Conditional JSX with Toast component", () => {
|
|
302
|
+
const source = `{toastParam !== undefined && (
|
|
303
|
+
<Toast.Toast class="toast toast-top toast-center fixed top-8 z-10">
|
|
304
|
+
<div class="alert alert-success">
|
|
305
|
+
<span>
|
|
306
|
+
{toastParam}
|
|
307
|
+
</span>
|
|
308
|
+
</div>
|
|
309
|
+
</Toast.Toast>
|
|
310
|
+
)}`
|
|
311
|
+
const result = extractClassNames(source)
|
|
312
|
+
|
|
313
|
+
t
|
|
314
|
+
.expect([...result].sort())
|
|
315
|
+
.toEqual([
|
|
316
|
+
"alert",
|
|
317
|
+
"alert-success",
|
|
318
|
+
"fixed",
|
|
319
|
+
"toast",
|
|
320
|
+
"toast-center",
|
|
321
|
+
"toast-top",
|
|
322
|
+
"top-8",
|
|
323
|
+
"z-10",
|
|
324
|
+
])
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
t.test("Template literals with expressions", () => {
|
|
328
|
+
const source = `<div class={\`toast \${props.class ?? ""}\`}>Content</div>`
|
|
329
|
+
const result = extractClassNames(source)
|
|
330
|
+
|
|
331
|
+
t
|
|
332
|
+
.expect([...result].sort())
|
|
333
|
+
.toEqual(["toast"])
|
|
334
|
+
})
|
|
335
|
+
})
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type * as Tailwind from "@tailwindcss/node"
|
|
2
|
+
import type { BunPlugin } from "bun"
|
|
3
|
+
import * as NodePath from "node:path"
|
|
4
|
+
|
|
5
|
+
type Compiler = Awaited<ReturnType<typeof Tailwind.compile>>
|
|
6
|
+
|
|
7
|
+
export const make = (opts: {
|
|
8
|
+
/**
|
|
9
|
+
* Custom importer function to load Tailwind.
|
|
10
|
+
* By default, it imports from '@tailwindcss/node'.
|
|
11
|
+
* If you want to use a different version or a custom implementation,
|
|
12
|
+
* provide your own importer.
|
|
13
|
+
*/
|
|
14
|
+
importer?: () => Promise<typeof Tailwind>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pattern to match component and HTML files for class name extraction.
|
|
18
|
+
*/
|
|
19
|
+
filesPattern?: RegExp
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pattern to match CSS files that import Tailwind.
|
|
23
|
+
*/
|
|
24
|
+
cssPattern?: RegExp
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Scan a path for candidates.
|
|
28
|
+
* By default, only class names found in files that are part of the import graph
|
|
29
|
+
* that imports tailwind are considered.
|
|
30
|
+
*
|
|
31
|
+
* This option scans the provided path and ensures that class names found under this path
|
|
32
|
+
* are includedd, even if they are not part of the import graph.
|
|
33
|
+
*/
|
|
34
|
+
scanPath?: string
|
|
35
|
+
} = {}): BunPlugin => {
|
|
36
|
+
const {
|
|
37
|
+
filesPattern = /\.(jsx?|tsx?|html|svelte|vue|astro)$/,
|
|
38
|
+
cssPattern = /\.css$/,
|
|
39
|
+
importer = () =>
|
|
40
|
+
import("@tailwindcss/node").catch(err => {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"Tailwind not found: install @tailwindcss/node or provide custom importer option",
|
|
43
|
+
)
|
|
44
|
+
}),
|
|
45
|
+
} = opts
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
name: "Bun Tailwind.css plugin",
|
|
49
|
+
target: "browser",
|
|
50
|
+
async setup(builder) {
|
|
51
|
+
const Tailwind = await importer()
|
|
52
|
+
|
|
53
|
+
const scannedCandidates = new Set<string>()
|
|
54
|
+
// (file) -> (class names)
|
|
55
|
+
const classNameCandidates = new Map<string, Set<string>>()
|
|
56
|
+
// (importer path) -> (imported paths)
|
|
57
|
+
const importAncestors = new Map<string, Set<string>>()
|
|
58
|
+
// (imported path) -> (importer paths)
|
|
59
|
+
const importDescendants = new Map<string, Set<string>>()
|
|
60
|
+
|
|
61
|
+
if (opts.scanPath) {
|
|
62
|
+
const candidates = await scanFiles(opts.scanPath)
|
|
63
|
+
|
|
64
|
+
candidates.forEach(candidate => scannedCandidates.add(candidate))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Track import relationships.
|
|
69
|
+
* We do this to scope all class name candidates to tailwind entrypoints
|
|
70
|
+
*/
|
|
71
|
+
builder.onResolve({
|
|
72
|
+
filter: /.*/,
|
|
73
|
+
}, (args) => {
|
|
74
|
+
const fullPath = Bun.resolveSync(args.path, args.resolveDir)
|
|
75
|
+
|
|
76
|
+
if (fullPath.includes("/node_modules/")) {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register every visited module.
|
|
82
|
+
*/
|
|
83
|
+
{
|
|
84
|
+
if (!importAncestors.has(fullPath)) {
|
|
85
|
+
importAncestors.set(fullPath, new Set())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!importDescendants.has(fullPath)) {
|
|
89
|
+
importDescendants.set(fullPath, new Set())
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!importAncestors.has(args.importer)) {
|
|
93
|
+
importAncestors.set(args.importer, new Set())
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!importDescendants.has(args.importer)) {
|
|
97
|
+
importDescendants.set(args.importer, new Set())
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
importAncestors.get(fullPath)!.add(args.importer)
|
|
102
|
+
importDescendants.get(args.importer)!.add(fullPath)
|
|
103
|
+
|
|
104
|
+
return undefined
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Scan for class name candidates in component files.
|
|
109
|
+
*/
|
|
110
|
+
builder.onLoad({
|
|
111
|
+
filter: filesPattern,
|
|
112
|
+
}, async (args) => {
|
|
113
|
+
const contents = await Bun.file(args.path).text()
|
|
114
|
+
const classNames = extractClassNames(contents)
|
|
115
|
+
|
|
116
|
+
if (classNames.size > 0) {
|
|
117
|
+
classNameCandidates.set(args.path, classNames)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return undefined
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compile tailwind entrypoints.
|
|
125
|
+
*/
|
|
126
|
+
builder.onLoad({
|
|
127
|
+
filter: cssPattern,
|
|
128
|
+
}, async (args) => {
|
|
129
|
+
const source = await Bun.file(args.path).text()
|
|
130
|
+
|
|
131
|
+
if (!hasCssImport(source, "tailwindcss")) {
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const compiler = await Tailwind.compile(source, {
|
|
136
|
+
base: NodePath.dirname(args.path),
|
|
137
|
+
shouldRewriteUrls: true,
|
|
138
|
+
onDependency: (path) => {},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// wait for other files to be loaded so we can collect class name candidates
|
|
142
|
+
// NOTE: at currently processed css won't be in import graph because
|
|
143
|
+
// we haven't returned its contents yet.
|
|
144
|
+
await args.defer()
|
|
145
|
+
|
|
146
|
+
const candidates = new Set<string>()
|
|
147
|
+
|
|
148
|
+
scannedCandidates.forEach(candidate => candidates.add(candidate))
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
const pendingModules = [
|
|
152
|
+
// get class name candidates from all modules that import this one
|
|
153
|
+
...(importAncestors.get(args.path) ?? []),
|
|
154
|
+
]
|
|
155
|
+
const visitedModules = new Set<string>()
|
|
156
|
+
|
|
157
|
+
while (pendingModules.length > 0) {
|
|
158
|
+
const currentPath = pendingModules.shift()!
|
|
159
|
+
|
|
160
|
+
if (visitedModules.has(currentPath)) {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const moduleImports = importDescendants.get(currentPath)
|
|
165
|
+
|
|
166
|
+
moduleImports?.forEach(moduleImport => {
|
|
167
|
+
const moduleCandidates = classNameCandidates.get(moduleImport)
|
|
168
|
+
|
|
169
|
+
moduleCandidates?.forEach(candidate => candidates.add(candidate))
|
|
170
|
+
|
|
171
|
+
pendingModules.push(moduleImport)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
visitedModules.add(currentPath)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const contents = compiler.build([
|
|
179
|
+
...candidates,
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
contents,
|
|
184
|
+
loader: "css",
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const CSS_IMPORT_REGEX = /@import\s+(?:url\()?["']?([^"')]+)["']?\)?\s*[^;]*;/
|
|
192
|
+
|
|
193
|
+
function hasCssImport(css: string, specifier?: string): boolean {
|
|
194
|
+
const [, importPath] = css.match(CSS_IMPORT_REGEX) ?? []
|
|
195
|
+
|
|
196
|
+
if (!importPath) return false
|
|
197
|
+
|
|
198
|
+
return specifier === undefined
|
|
199
|
+
|| importPath.includes(specifier)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function extractClassNames(source: string): Set<string> {
|
|
203
|
+
const candidates = new Set<string>()
|
|
204
|
+
|
|
205
|
+
// Remove HTML comments to avoid false matches
|
|
206
|
+
const sourceWithoutComments = source.replace(/<!--[\s\S]*?-->/g, "")
|
|
207
|
+
|
|
208
|
+
// Array of pattern strings for different class/className attribute formats
|
|
209
|
+
const patterns = [
|
|
210
|
+
// HTML class attributes with double quotes: <div class="bg-blue-500 text-white">
|
|
211
|
+
"<[^>]*?\\sclass\\s*=\\s*\"([^\"]+)\"",
|
|
212
|
+
|
|
213
|
+
// HTML class attributes with single quotes: <div class='bg-blue-500 text-white'>
|
|
214
|
+
"<[^>]*?\\sclass\\s*=\\s*'([^']+)'",
|
|
215
|
+
|
|
216
|
+
// JSX className attributes with double quotes: <div className="bg-blue-500 text-white">
|
|
217
|
+
"<[^>]*?\\sclassName\\s*=\\s*\"([^\"]+)\"",
|
|
218
|
+
|
|
219
|
+
// JSX className attributes with single quotes: <div className='bg-blue-500 text-white'>
|
|
220
|
+
"<[^>]*?\\sclassName\\s*=\\s*'([^']+)'",
|
|
221
|
+
|
|
222
|
+
// JSX className with braces and double quotes: <div className={"bg-blue-500 text-white"}>
|
|
223
|
+
"<[^>]*?\\sclassName\\s*=\\s*\\{\\s*\"([^\"]+)\"\\s*\\}",
|
|
224
|
+
|
|
225
|
+
// JSX className with braces and single quotes: <div className={'bg-blue-500 text-white'}>
|
|
226
|
+
"<[^>]*?\\sclassName\\s*=\\s*\\{\\s*'([^']+)'\\s*\\}",
|
|
227
|
+
|
|
228
|
+
// JSX className with template literals: <div className={`bg-blue-500 ${variable}`}>
|
|
229
|
+
"<[^>]*?\\sclassName\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
|
|
230
|
+
|
|
231
|
+
// HTML class with template literals: <div class={`bg-blue-500 ${variable}`}>
|
|
232
|
+
"<[^>]*?\\sclass\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
|
|
233
|
+
|
|
234
|
+
// HTML class at start of tag with double quotes: <div class="bg-blue-500">
|
|
235
|
+
"<\\w+\\s+class\\s*=\\s*\"([^\"]+)\"",
|
|
236
|
+
|
|
237
|
+
// HTML class at start of tag with single quotes: <div class='bg-blue-500'>
|
|
238
|
+
"<\\w+\\s+class\\s*=\\s*'([^']+)'",
|
|
239
|
+
|
|
240
|
+
// JSX className at start of tag with double quotes: <div className="bg-blue-500">
|
|
241
|
+
"<\\w+\\s+className\\s*=\\s*\"([^\"]+)\"",
|
|
242
|
+
|
|
243
|
+
// JSX className at start of tag with single quotes: <div className='bg-blue-500'>
|
|
244
|
+
"<\\w+\\s+className\\s*=\\s*'([^']+)'",
|
|
245
|
+
|
|
246
|
+
// JSX className at start with braces and double quotes: <div className={"bg-blue-500"}>
|
|
247
|
+
"<\\w+\\s+className\\s*=\\s*\\{\\s*\"([^\"]+)\"\\s*\\}",
|
|
248
|
+
|
|
249
|
+
// JSX className at start with braces and single quotes: <div className={'bg-blue-500'}>
|
|
250
|
+
"<\\w+\\s+className\\s*=\\s*\\{\\s*'([^']+)'\\s*\\}",
|
|
251
|
+
|
|
252
|
+
// JSX className at start with template literals: <div className={`bg-blue-500 ${variable}`}>
|
|
253
|
+
"<\\w+\\s+className\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
|
|
254
|
+
|
|
255
|
+
// HTML class at start with template literals: <div class={`bg-blue-500 ${variable}`}>
|
|
256
|
+
"<\\w+\\s+class\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
// Combine all patterns into one regex using alternation
|
|
260
|
+
const combinedPattern = patterns
|
|
261
|
+
.map(pattern => `(?:${pattern})`)
|
|
262
|
+
.join("|")
|
|
263
|
+
|
|
264
|
+
const combinedRegex = new RegExp(combinedPattern, "g")
|
|
265
|
+
|
|
266
|
+
for (const match of sourceWithoutComments.matchAll(combinedRegex)) {
|
|
267
|
+
// Find the first non-undefined capture group (skip match[0] which is full match)
|
|
268
|
+
let classString = ""
|
|
269
|
+
for (let i = 1; i < match.length; i++) {
|
|
270
|
+
if (match[i] !== undefined) {
|
|
271
|
+
classString = match[i]
|
|
272
|
+
break
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!classString) {
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Only apply complex processing if the string contains characters that require it
|
|
281
|
+
if (classString.includes("${")) {
|
|
282
|
+
// Split by ${...} expressions and process each static part
|
|
283
|
+
const staticParts = classString.split(/\$\{[^}]*\}/g)
|
|
284
|
+
|
|
285
|
+
for (const part of staticParts) {
|
|
286
|
+
const names = part.trim().split(/\s+/).filter(name => {
|
|
287
|
+
if (name.length === 0) return false
|
|
288
|
+
// Don't extract incomplete classes like "bg-" or "-500"
|
|
289
|
+
if (name.endsWith("-") || name.startsWith("-")) return false
|
|
290
|
+
// Basic Tailwind class pattern validation
|
|
291
|
+
return /^[a-zA-Z0-9_:-]+(\[[^\]]*\])?$/.test(name)
|
|
292
|
+
})
|
|
293
|
+
names.forEach(name => candidates.add(name))
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
// Simple case: regular class string without expressions
|
|
297
|
+
const names = classString.split(/\s+/).filter(name => name.length > 0)
|
|
298
|
+
names.forEach(name => candidates.add(name))
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return candidates
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function scanFiles(dir: string): Promise<Set<string>> {
|
|
306
|
+
const candidates = new Set<string>()
|
|
307
|
+
const glob = new Bun.Glob("**/*.{js,jsx,ts,tsx,html,vue,svelte,astro}")
|
|
308
|
+
|
|
309
|
+
for await (
|
|
310
|
+
const filePath of glob.scan({
|
|
311
|
+
cwd: dir,
|
|
312
|
+
absolute: true,
|
|
313
|
+
})
|
|
314
|
+
) {
|
|
315
|
+
const contents = await Bun.file(filePath).text()
|
|
316
|
+
const classNames = extractClassNames(contents)
|
|
317
|
+
|
|
318
|
+
classNames.forEach((className) => candidates.add(className))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return candidates
|
|
322
|
+
}
|