effect-start 0.12.0 → 0.13.1

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/README.md CHANGED
@@ -52,27 +52,24 @@ src/routes
52
52
 
53
53
  ### Tailwind CSS Support
54
54
 
55
- Install Tailwind plugin:
55
+ Effect Start comes with Tailwind plugin that is lightweight and
56
+ works with minimal configuration.
57
+
58
+ First, install official Tailwind package:
56
59
 
57
60
  ```sh
58
- bun add tailwindcss bun-plugin-tailwind
61
+ bun add -D tailwindcss
59
62
  ```
60
63
 
61
- In `bunfig.toml`:
64
+ Then, register a plugin in `bunfig.toml`:
62
65
 
63
66
  ```toml
64
67
  [serve.static]
65
- plugins = ["bun-plugin-tailwind"]
68
+ plugins = ["effect-start/x/tailwind/plugin"]
66
69
  ```
67
70
 
68
- Finally, include it in your `src/app.html`:
71
+ Finally, include it in your `src/app.css`:
69
72
 
70
73
  ```html
71
- <!doctype html>
72
- <html>
73
- <head>
74
- <link rel="stylesheet" href="tailwindcss" />
75
- </head>
76
- <!-- the rest of your HTML... -->
77
- </html>
74
+ @import "tailwindcss";
78
75
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-start",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "exports": {
@@ -16,6 +16,7 @@
16
16
  "./jsx-dev-runtime": "./src/jsx-runtime.ts",
17
17
  "./hyper": "./src/hyper/index.ts",
18
18
  "./x/*": "./src/x/*/index.ts",
19
+ "./x/tailwind/plugin": "./src/x/tailwind/plugin.ts",
19
20
  "./middlewares/BasicAuthMiddleware": "./src/middlewares/BasicAuthMiddleware.ts",
20
21
  "./assets.d.ts": "./src/assets.d.ts"
21
22
  },
@@ -31,23 +32,18 @@
31
32
  "peerDependencies": {
32
33
  "typescript": "^5.9.3"
33
34
  },
34
- "peerDependenciesMeta": {
35
- "@tailwindcss/node": {
36
- "optional": true
37
- }
38
- },
39
35
  "devDependencies": {
36
+ "dprint-cli": "^0.4.1",
37
+ "dprint-markup": "nounder/dprint-markup",
40
38
  "@dprint/json": "^0.21.0",
41
39
  "@dprint/markdown": "^0.20.0",
42
40
  "@dprint/typescript": "^0.95.13",
43
41
  "@effect/language-service": "^0.61.0",
44
- "@tailwindcss/node": "^4.1.17",
45
42
  "@types/bun": "^1.3.4",
46
43
  "@types/react": "^19.2.7",
47
44
  "@types/react-dom": "^19.2.3",
48
- "dprint-cli": "^0.4.1",
49
- "dprint-markup": "nounder/dprint-markup",
50
45
  "effect-memfs": "^0.8.0",
46
+ "tailwindcss": "^4.1.18",
51
47
  "ts-namespace-import": "nounder/ts-namespace-import#140c405"
52
48
  },
53
49
  "files": [
@@ -0,0 +1,23 @@
1
+ import * as NFS from "node:fs/promises"
2
+ import * as NPath from "node:path"
3
+
4
+ export const findClosestPackageJson = async (
5
+ path: string,
6
+ ): Promise<string | undefined> => {
7
+ const resolved = NPath.resolve(path)
8
+ const stat = await NFS.stat(resolved).catch(() => undefined)
9
+ let dir = stat?.isDirectory() ? resolved : NPath.dirname(resolved)
10
+ const root = NPath.parse(dir).root
11
+
12
+ while (dir !== root) {
13
+ const candidate = NPath.join(dir, "package.json")
14
+ try {
15
+ await NFS.access(candidate)
16
+ return candidate
17
+ } catch {
18
+ dir = NPath.dirname(dir)
19
+ }
20
+ }
21
+
22
+ return undefined
23
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Bun adapter for enhanced-resolve
3
+ *
4
+ * This module provides a drop-in replacement for `enhanced-resolve` that uses
5
+ * Bun's built-in resolver. It implements the subset of the enhanced-resolve API
6
+ * used for Tailwind CSS.
7
+ */
8
+ import fs from "node:fs"
9
+ import path from "node:path"
10
+
11
+ type ErrorWithDetail =
12
+ & Error
13
+ & {
14
+ details?: string
15
+ }
16
+
17
+ interface ResolveRequest {
18
+ path: string | false
19
+ context?: object
20
+ descriptionFilePath?: string
21
+ descriptionFileRoot?: string
22
+ descriptionFileData?: Record<string, unknown>
23
+ relativePath?: string
24
+ ignoreSymlinks?: boolean
25
+ fullySpecified?: boolean
26
+ }
27
+
28
+ interface ResolveContext {
29
+ contextDependencies?: { add: (item: string) => void }
30
+ fileDependencies?: { add: (item: string) => void }
31
+ missingDependencies?: { add: (item: string) => void }
32
+ stack?: Set<string>
33
+ log?: (str: string) => void
34
+ yield?: (request: ResolveRequest) => void
35
+ }
36
+
37
+ interface ResolveOptions {
38
+ extensions?: string[]
39
+ mainFields?: (string | string[])[]
40
+ conditionNames?: string[]
41
+ fileSystem?: unknown
42
+ useSyncFileSystemCalls?: boolean
43
+ modules?: string | string[]
44
+ }
45
+
46
+ export interface Resolver {
47
+ resolve(
48
+ context: object,
49
+ path: string,
50
+ request: string,
51
+ resolveContext: ResolveContext,
52
+ callback: (
53
+ err: null | ErrorWithDetail,
54
+ res?: string | false,
55
+ req?: ResolveRequest,
56
+ ) => void,
57
+ ): void
58
+ }
59
+
60
+ export class CachedInputFileSystem {
61
+ constructor(
62
+ _fileSystem: unknown,
63
+ _duration: number,
64
+ ) {}
65
+ }
66
+
67
+ export const ResolverFactory = {
68
+ createResolver(options: ResolveOptions): Resolver {
69
+ const extensions = options.extensions ?? []
70
+ const mainFields = (options.mainFields ?? []).flatMap((f) =>
71
+ Array.isArray(f) ? f : [f]
72
+ )
73
+ const conditionNames = options.conditionNames ?? []
74
+
75
+ return {
76
+ resolve(
77
+ _context: object,
78
+ basePath: string,
79
+ id: string,
80
+ _resolveContext: ResolveContext,
81
+ callback: (
82
+ err: null | ErrorWithDetail,
83
+ res?: string | false,
84
+ req?: ResolveRequest,
85
+ ) => void,
86
+ ): void {
87
+ try {
88
+ const result = resolveSync(id, basePath, {
89
+ extensions,
90
+ mainFields,
91
+ conditionNames,
92
+ })
93
+ callback(null, result)
94
+ } catch (err) {
95
+ callback(err instanceof Error ? err : new Error(String(err)))
96
+ }
97
+ },
98
+ }
99
+ },
100
+ }
101
+
102
+ interface ResolveInternalOptions {
103
+ extensions: string[]
104
+ mainFields: string[]
105
+ conditionNames: string[]
106
+ }
107
+
108
+ function resolveSync(
109
+ id: string,
110
+ base: string,
111
+ options: ResolveInternalOptions,
112
+ ): string | undefined {
113
+ if (id.startsWith(".") || id.startsWith("/")) {
114
+ for (const ext of ["", ...options.extensions]) {
115
+ const fullPath = path.resolve(base, id + ext)
116
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
117
+ return fullPath
118
+ }
119
+ }
120
+ return undefined
121
+ }
122
+
123
+ const packagePath = resolvePackagePath(id, base)
124
+ if (!packagePath) return undefined
125
+
126
+ const packageJsonPath = path.join(packagePath, "package.json")
127
+ if (!fs.existsSync(packageJsonPath)) return undefined
128
+
129
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
130
+
131
+ for (const field of options.mainFields) {
132
+ if (typeof packageJson[field] === "string") {
133
+ const resolved = path.resolve(packagePath, packageJson[field])
134
+ if (fs.existsSync(resolved)) return resolved
135
+ }
136
+ }
137
+
138
+ if (packageJson.exports) {
139
+ const resolved = resolveExports(
140
+ packageJson.exports,
141
+ packagePath,
142
+ options.conditionNames,
143
+ )
144
+ if (resolved && fs.existsSync(resolved)) return resolved
145
+ }
146
+
147
+ try {
148
+ return Bun.resolveSync(id, base)
149
+ } catch {
150
+ return undefined
151
+ }
152
+ }
153
+
154
+ function resolvePackagePath(id: string, base: string): string | undefined {
155
+ const parts = id.split("/")
156
+ const packageName = id.startsWith("@")
157
+ ? parts.slice(0, 2).join("/")
158
+ : parts[0]
159
+
160
+ let dir = base
161
+ while (dir !== path.dirname(dir)) {
162
+ const candidate = path.join(dir, "node_modules", packageName)
163
+ if (fs.existsSync(candidate)) return candidate
164
+ dir = path.dirname(dir)
165
+ }
166
+ return undefined
167
+ }
168
+
169
+ function resolveExports(
170
+ exports: unknown,
171
+ packagePath: string,
172
+ conditionNames: string[],
173
+ ): string | undefined {
174
+ if (typeof exports === "string") {
175
+ return path.resolve(packagePath, exports)
176
+ }
177
+
178
+ if (exports && typeof exports === "object" && !Array.isArray(exports)) {
179
+ const exportsObj = exports as Record<string, unknown>
180
+
181
+ if ("." in exportsObj) {
182
+ return resolveExports(exportsObj["."], packagePath, conditionNames)
183
+ }
184
+
185
+ for (const condition of conditionNames) {
186
+ if (condition in exportsObj) {
187
+ return resolveExports(
188
+ exportsObj[condition],
189
+ packagePath,
190
+ conditionNames,
191
+ )
192
+ }
193
+ }
194
+
195
+ if ("default" in exportsObj) {
196
+ return resolveExports(exportsObj["default"], packagePath, conditionNames)
197
+ }
198
+ }
199
+
200
+ return undefined
201
+ }
package/src/bun/index.ts CHANGED
@@ -2,4 +2,3 @@ export * as BunBundle from "./BunBundle.ts"
2
2
  export * as BunHttpServer from "./BunHttpServer.ts"
3
3
  export * as BunImportTrackerPlugin from "./BunImportTrackerPlugin.ts"
4
4
  export * as BunRoute from "./BunRoute.ts"
5
- export * as BunTailwindPlugin from "./BunTailwindPlugin.ts"
@@ -1,6 +1,6 @@
1
1
  import * as t from "bun:test"
2
2
 
3
- import { extractClassNames } from "./BunTailwindPlugin.ts"
3
+ import { extractClassNames } from "./TailwindPlugin.ts"
4
4
 
5
5
  // Keep the old broad implementation for comparison tests
6
6
  function extractClassNamesBroad(source: string): Set<string> {
@@ -1,18 +1,8 @@
1
- import type * as Tailwind from "@tailwindcss/node"
2
1
  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>
2
+ import * as NPath from "node:path"
3
+ import * as Tailwind from "./compile.ts"
15
4
 
5
+ export const make = (opts?: {
16
6
  /**
17
7
  * Pattern to match component and HTML files for class name extraction.
18
8
  */
@@ -30,26 +20,22 @@ export const make = (opts: {
30
20
  *
31
21
  * This option scans the provided path and ensures that class names found under this path
32
22
  * are includedd, even if they are not part of the import graph.
23
+ * Useful when we want to scan clientside code which is not imported directly on serverside.
33
24
  */
34
25
  scanPath?: string
35
- } = {}): BunPlugin => {
26
+
27
+ target?: "browser" | "bun" | "node"
28
+ }): BunPlugin => {
36
29
  const {
37
30
  filesPattern = /\.(jsx?|tsx?|html|svelte|vue|astro)$/,
38
31
  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
32
+ target = "browser",
33
+ } = opts ?? {}
46
34
 
47
35
  return {
48
- name: "Bun Tailwind.css plugin",
49
- target: "browser",
36
+ name: "Tailwind.css plugin",
37
+ target,
50
38
  async setup(builder) {
51
- const Tailwind = await importer()
52
-
53
39
  const scannedCandidates = new Set<string>()
54
40
  // (file) -> (class names)
55
41
  const classNameCandidates = new Map<string, Set<string>>()
@@ -58,51 +44,58 @@ export const make = (opts: {
58
44
  // (imported path) -> (importer paths)
59
45
  const importDescendants = new Map<string, Set<string>>()
60
46
 
61
- if (opts.scanPath) {
47
+ if (opts?.scanPath) {
62
48
  const candidates = await scanFiles(opts.scanPath)
63
49
 
64
50
  candidates.forEach(candidate => scannedCandidates.add(candidate))
65
51
  }
66
52
 
67
53
  /**
68
- * Track import relationships.
69
- * We do this to scope all class name candidates to tailwind entrypoints
54
+ * Track import relationships when dynamically scanning
55
+ * from tailwind entrypoints.
56
+ *
57
+ * As of Bun 1.3 this pathway break for Bun Full-Stack server.
58
+ * Better to pass scanPath explicitly.
59
+ * @see https://github.com/oven-sh/bun/issues/20877
70
60
  */
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())
61
+ if (!opts?.scanPath) {
62
+ builder.onResolve({
63
+ filter: /.*/,
64
+ }, (args) => {
65
+ const fullPath = Bun.resolveSync(args.path, args.resolveDir)
66
+ const importer = args.importer
67
+
68
+ if (fullPath.includes("/node_modules/")) {
69
+ return undefined
86
70
  }
87
71
 
88
- if (!importDescendants.has(fullPath)) {
89
- importDescendants.set(fullPath, new Set())
90
- }
72
+ /**
73
+ * Register every visited module.
74
+ */
75
+ {
76
+ if (!importAncestors.has(fullPath)) {
77
+ importAncestors.set(fullPath, new Set())
78
+ }
91
79
 
92
- if (!importAncestors.has(args.importer)) {
93
- importAncestors.set(args.importer, new Set())
94
- }
80
+ if (!importDescendants.has(fullPath)) {
81
+ importDescendants.set(fullPath, new Set())
82
+ }
83
+
84
+ if (!importAncestors.has(importer)) {
85
+ importAncestors.set(args.importer, new Set())
86
+ }
95
87
 
96
- if (!importDescendants.has(args.importer)) {
97
- importDescendants.set(args.importer, new Set())
88
+ if (!importDescendants.has(importer)) {
89
+ importDescendants.set(importer, new Set())
90
+ }
98
91
  }
99
- }
100
92
 
101
- importAncestors.get(fullPath)!.add(args.importer)
102
- importDescendants.get(args.importer)!.add(fullPath)
93
+ importAncestors.get(fullPath)!.add(importer)
94
+ importDescendants.get(importer)!.add(fullPath)
103
95
 
104
- return undefined
105
- })
96
+ return undefined
97
+ })
98
+ }
106
99
 
107
100
  /**
108
101
  * Scan for class name candidates in component files.
@@ -133,14 +126,11 @@ export const make = (opts: {
133
126
  }
134
127
 
135
128
  const compiler = await Tailwind.compile(source, {
136
- base: NodePath.dirname(args.path),
137
- shouldRewriteUrls: true,
129
+ base: NPath.dirname(args.path),
138
130
  onDependency: (path) => {},
139
131
  })
140
132
 
141
133
  // 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
134
  await args.defer()
145
135
 
146
136
  const candidates = new Set<string>()
@@ -189,81 +179,78 @@ export const make = (opts: {
189
179
  }
190
180
 
191
181
  const CSS_IMPORT_REGEX = /@import\s+(?:url\()?["']?([^"')]+)["']?\)?\s*[^;]*;/
182
+ const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g
183
+ const TEMPLATE_EXPRESSION_REGEX = /\$\{[^}]*\}/g
184
+ const TAILWIND_CLASS_REGEX = /^[a-zA-Z0-9_:-]+(\[[^\]]*\])?$/
185
+ const CLASS_NAME_PATTERNS = [
186
+ // HTML class attributes with double quotes: <div class="bg-blue-500 text-white">
187
+ "<[^>]*?\\sclass\\s*=\\s*\"([^\"]+)\"",
192
188
 
193
- function hasCssImport(css: string, specifier?: string): boolean {
194
- const [, importPath] = css.match(CSS_IMPORT_REGEX) ?? []
189
+ // HTML class attributes with single quotes: <div class='bg-blue-500 text-white'>
190
+ "<[^>]*?\\sclass\\s*=\\s*'([^']+)'",
195
191
 
196
- if (!importPath) return false
192
+ // JSX className attributes with double quotes: <div className="bg-blue-500 text-white">
193
+ "<[^>]*?\\sclassName\\s*=\\s*\"([^\"]+)\"",
197
194
 
198
- return specifier === undefined
199
- || importPath.includes(specifier)
200
- }
201
-
202
- export function extractClassNames(source: string): Set<string> {
203
- const candidates = new Set<string>()
195
+ // JSX className attributes with single quotes: <div className='bg-blue-500 text-white'>
196
+ "<[^>]*?\\sclassName\\s*=\\s*'([^']+)'",
204
197
 
205
- // Remove HTML comments to avoid false matches
206
- const sourceWithoutComments = source.replace(/<!--[\s\S]*?-->/g, "")
198
+ // JSX className with braces and double quotes: <div className={"bg-blue-500 text-white"}>
199
+ "<[^>]*?\\sclassName\\s*=\\s*\\{\\s*\"([^\"]+)\"\\s*\\}",
207
200
 
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*\"([^\"]+)\"",
201
+ // JSX className with braces and single quotes: <div className={'bg-blue-500 text-white'}>
202
+ "<[^>]*?\\sclassName\\s*=\\s*\\{\\s*'([^']+)'\\s*\\}",
212
203
 
213
- // HTML class attributes with single quotes: <div class='bg-blue-500 text-white'>
214
- "<[^>]*?\\sclass\\s*=\\s*'([^']+)'",
204
+ // JSX className with template literals: <div className={`bg-blue-500 ${variable}`}>
205
+ "<[^>]*?\\sclassName\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
215
206
 
216
- // JSX className attributes with double quotes: <div className="bg-blue-500 text-white">
217
- "<[^>]*?\\sclassName\\s*=\\s*\"([^\"]+)\"",
207
+ // HTML class with template literals: <div class={`bg-blue-500 ${variable}`}>
208
+ "<[^>]*?\\sclass\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
218
209
 
219
- // JSX className attributes with single quotes: <div className='bg-blue-500 text-white'>
220
- "<[^>]*?\\sclassName\\s*=\\s*'([^']+)'",
210
+ // HTML class at start of tag with double quotes: <div class="bg-blue-500">
211
+ "<\\w+\\s+class\\s*=\\s*\"([^\"]+)\"",
221
212
 
222
- // JSX className with braces and double quotes: <div className={"bg-blue-500 text-white"}>
223
- "<[^>]*?\\sclassName\\s*=\\s*\\{\\s*\"([^\"]+)\"\\s*\\}",
213
+ // HTML class at start of tag with single quotes: <div class='bg-blue-500'>
214
+ "<\\w+\\s+class\\s*=\\s*'([^']+)'",
224
215
 
225
- // JSX className with braces and single quotes: <div className={'bg-blue-500 text-white'}>
226
- "<[^>]*?\\sclassName\\s*=\\s*\\{\\s*'([^']+)'\\s*\\}",
216
+ // JSX className at start of tag with double quotes: <div className="bg-blue-500">
217
+ "<\\w+\\s+className\\s*=\\s*\"([^\"]+)\"",
227
218
 
228
- // JSX className with template literals: <div className={`bg-blue-500 ${variable}`}>
229
- "<[^>]*?\\sclassName\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
219
+ // JSX className at start of tag with single quotes: <div className='bg-blue-500'>
220
+ "<\\w+\\s+className\\s*=\\s*'([^']+)'",
230
221
 
231
- // HTML class with template literals: <div class={`bg-blue-500 ${variable}`}>
232
- "<[^>]*?\\sclass\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
222
+ // JSX className at start with braces and double quotes: <div className={"bg-blue-500"}>
223
+ "<\\w+\\s+className\\s*=\\s*\\{\\s*\"([^\"]+)\"\\s*\\}",
233
224
 
234
- // HTML class at start of tag with double quotes: <div class="bg-blue-500">
235
- "<\\w+\\s+class\\s*=\\s*\"([^\"]+)\"",
225
+ // JSX className at start with braces and single quotes: <div className={'bg-blue-500'}>
226
+ "<\\w+\\s+className\\s*=\\s*\\{\\s*'([^']+)'\\s*\\}",
236
227
 
237
- // HTML class at start of tag with single quotes: <div class='bg-blue-500'>
238
- "<\\w+\\s+class\\s*=\\s*'([^']+)'",
228
+ // JSX className at start with template literals: <div className={`bg-blue-500 ${variable}`}>
229
+ "<\\w+\\s+className\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
239
230
 
240
- // JSX className at start of tag with double quotes: <div className="bg-blue-500">
241
- "<\\w+\\s+className\\s*=\\s*\"([^\"]+)\"",
231
+ // HTML class at start with template literals: <div class={`bg-blue-500 ${variable}`}>
232
+ "<\\w+\\s+class\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
233
+ ]
242
234
 
243
- // JSX className at start of tag with single quotes: <div className='bg-blue-500'>
244
- "<\\w+\\s+className\\s*=\\s*'([^']+)'",
235
+ const CLASS_NAME_REGEX = new RegExp(
236
+ CLASS_NAME_PATTERNS.map(pattern => `(?:${pattern})`).join("|"),
237
+ "g",
238
+ )
245
239
 
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*\\}",
240
+ function hasCssImport(css: string, specifier?: string): boolean {
241
+ const [, importPath] = css.match(CSS_IMPORT_REGEX) ?? []
254
242
 
255
- // HTML class at start with template literals: <div class={`bg-blue-500 ${variable}`}>
256
- "<\\w+\\s+class\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
257
- ]
243
+ if (!importPath) return false
258
244
 
259
- // Combine all patterns into one regex using alternation
260
- const combinedPattern = patterns
261
- .map(pattern => `(?:${pattern})`)
262
- .join("|")
245
+ return specifier === undefined
246
+ || importPath.includes(specifier)
247
+ }
263
248
 
264
- const combinedRegex = new RegExp(combinedPattern, "g")
249
+ export function extractClassNames(source: string): Set<string> {
250
+ const candidates = new Set<string>()
251
+ const sourceWithoutComments = source.replace(HTML_COMMENT_REGEX, "")
265
252
 
266
- for (const match of sourceWithoutComments.matchAll(combinedRegex)) {
253
+ for (const match of sourceWithoutComments.matchAll(CLASS_NAME_REGEX)) {
267
254
  // Find the first non-undefined capture group (skip match[0] which is full match)
268
255
  let classString = ""
269
256
  for (let i = 1; i < match.length; i++) {
@@ -277,18 +264,14 @@ export function extractClassNames(source: string): Set<string> {
277
264
  continue
278
265
  }
279
266
 
280
- // Only apply complex processing if the string contains characters that require it
281
267
  if (classString.includes("${")) {
282
- // Split by ${...} expressions and process each static part
283
- const staticParts = classString.split(/\$\{[^}]*\}/g)
268
+ const staticParts = classString.split(TEMPLATE_EXPRESSION_REGEX)
284
269
 
285
270
  for (const part of staticParts) {
286
271
  const names = part.trim().split(/\s+/).filter(name => {
287
272
  if (name.length === 0) return false
288
- // Don't extract incomplete classes like "bg-" or "-500"
289
273
  if (name.endsWith("-") || name.startsWith("-")) return false
290
- // Basic Tailwind class pattern validation
291
- return /^[a-zA-Z0-9_:-]+(\[[^\]]*\])?$/.test(name)
274
+ return TAILWIND_CLASS_REGEX.test(name)
292
275
  })
293
276
  names.forEach(name => candidates.add(name))
294
277
  }
@@ -0,0 +1,243 @@
1
+ import fsPromises from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { pathToFileURL } from "node:url"
4
+ import {
5
+ compile as _compile,
6
+ compileAst as _compileAst,
7
+ Features,
8
+ Polyfills,
9
+ } from "tailwindcss"
10
+ import * as BunEnhancedResolve from "../../bun/_BunEnhancedResolve"
11
+
12
+ type AstNode = Parameters<typeof _compileAst>[0][number]
13
+
14
+ export {
15
+ Features,
16
+ Polyfills,
17
+ }
18
+
19
+ export type Resolver = (
20
+ id: string,
21
+ base: string,
22
+ ) => Promise<string | false | undefined>
23
+
24
+ export interface CompileOptions {
25
+ base: string
26
+ from?: string
27
+ onDependency: (path: string) => void
28
+ polyfills?: Polyfills
29
+
30
+ customCssResolver?: Resolver
31
+ customJsResolver?: Resolver
32
+ }
33
+
34
+ function createCompileOptions({
35
+ base,
36
+ from,
37
+ polyfills,
38
+ onDependency,
39
+
40
+ customCssResolver,
41
+ customJsResolver,
42
+ }: CompileOptions) {
43
+ return {
44
+ base,
45
+ polyfills,
46
+ from,
47
+ async loadModule(id: string, base: string) {
48
+ return loadModule(id, base, onDependency, customJsResolver)
49
+ },
50
+ async loadStylesheet(id: string, sheetBase: string) {
51
+ let sheet = await loadStylesheet(
52
+ id,
53
+ sheetBase,
54
+ onDependency,
55
+ customCssResolver,
56
+ )
57
+
58
+ return sheet
59
+ },
60
+ }
61
+ }
62
+
63
+ async function ensureSourceDetectionRootExists(compiler: {
64
+ root: Awaited<ReturnType<typeof compile>>["root"]
65
+ }) {
66
+ // Verify if the `source(…)` path exists (until the glob pattern starts)
67
+ if (compiler.root && compiler.root !== "none") {
68
+ let globSymbols = /[*{]/
69
+ let basePath: string[] = []
70
+ for (let segment of compiler.root.pattern.split("/")) {
71
+ if (globSymbols.test(segment)) {
72
+ break
73
+ }
74
+
75
+ basePath.push(segment)
76
+ }
77
+
78
+ let exists = await fsPromises
79
+ .stat(path.resolve(compiler.root.base, basePath.join("/")))
80
+ .then((stat) => stat.isDirectory())
81
+ .catch(() => false)
82
+
83
+ if (!exists) {
84
+ throw new Error(
85
+ `The \`source(${compiler.root.pattern})\` does not exist or is not a directory.`,
86
+ )
87
+ }
88
+ }
89
+ }
90
+
91
+ export async function compileAst(ast: AstNode[], options: CompileOptions) {
92
+ let compiler = await _compileAst(ast, createCompileOptions(options))
93
+ await ensureSourceDetectionRootExists(compiler)
94
+ return compiler
95
+ }
96
+
97
+ export async function compile(css: string, options: CompileOptions) {
98
+ let compiler = await _compile(css, createCompileOptions(options))
99
+ await ensureSourceDetectionRootExists(compiler)
100
+ return compiler
101
+ }
102
+
103
+ export async function loadModule(
104
+ id: string,
105
+ base: string,
106
+ _onDependency: (path: string) => void,
107
+ customJsResolver?: Resolver,
108
+ ) {
109
+ if (id[0] !== ".") {
110
+ let resolvedPath = await resolveJsId(id, base, customJsResolver)
111
+ if (!resolvedPath) {
112
+ throw new Error(`Could not resolve '${id}' from '${base}'`)
113
+ }
114
+
115
+ let module = await importModule(pathToFileURL(resolvedPath).href)
116
+ return {
117
+ path: resolvedPath,
118
+ base: path.dirname(resolvedPath),
119
+ module: module.default ?? module,
120
+ }
121
+ }
122
+
123
+ let resolvedPath = await resolveJsId(id, base, customJsResolver)
124
+ if (!resolvedPath) {
125
+ throw new Error(`Could not resolve '${id}' from '${base}'`)
126
+ }
127
+
128
+ let [module] = await Promise.all([
129
+ importModule(pathToFileURL(resolvedPath).href + "?id=" + Date.now()),
130
+ ])
131
+
132
+ return {
133
+ path: resolvedPath,
134
+ base: path.dirname(resolvedPath),
135
+ module: module.default ?? module,
136
+ }
137
+ }
138
+
139
+ async function loadStylesheet(
140
+ id: string,
141
+ base: string,
142
+ onDependency: (path: string) => void,
143
+ cssResolver?: Resolver,
144
+ ) {
145
+ let resolvedPath = await resolveCssId(id, base, cssResolver)
146
+ if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${base}'`)
147
+
148
+ onDependency(resolvedPath)
149
+
150
+ let file = await fsPromises.readFile(resolvedPath, "utf-8")
151
+ return {
152
+ path: resolvedPath,
153
+ base: path.dirname(resolvedPath),
154
+ content: file,
155
+ }
156
+ }
157
+
158
+ async function importModule(path: string): Promise<any> {
159
+ if (typeof globalThis.__tw_load === "function") {
160
+ let module = await globalThis.__tw_load(path)
161
+ if (module) {
162
+ return module
163
+ }
164
+ }
165
+
166
+ return await import(path)
167
+ }
168
+
169
+ const cssResolver = BunEnhancedResolve.ResolverFactory.createResolver({
170
+ extensions: [".css"],
171
+ mainFields: ["style"],
172
+ conditionNames: ["style"],
173
+ })
174
+ async function resolveCssId(
175
+ id: string,
176
+ base: string,
177
+ customCssResolver?: Resolver,
178
+ ): Promise<string | false | undefined> {
179
+ if (typeof globalThis.__tw_resolve === "function") {
180
+ let resolved = globalThis.__tw_resolve(id, base)
181
+ if (resolved) {
182
+ return Promise.resolve(resolved)
183
+ }
184
+ }
185
+
186
+ if (customCssResolver) {
187
+ let customResolution = await customCssResolver(id, base)
188
+ if (customResolution) {
189
+ return customResolution
190
+ }
191
+ }
192
+
193
+ return runResolver(cssResolver, id, base)
194
+ }
195
+
196
+ const esmResolver = BunEnhancedResolve.ResolverFactory.createResolver({
197
+ extensions: [".js", ".json", ".node", ".ts"],
198
+ conditionNames: ["node", "import"],
199
+ mainFields: ["module", "main"],
200
+ })
201
+
202
+ const cjsResolver = BunEnhancedResolve.ResolverFactory.createResolver({
203
+ extensions: [".js", ".json", ".node", ".ts"],
204
+ conditionNames: ["node", "require"],
205
+ mainFields: ["main"],
206
+ })
207
+
208
+ async function resolveJsId(
209
+ id: string,
210
+ base: string,
211
+ customJsResolver?: Resolver,
212
+ ): Promise<string | false | undefined> {
213
+ if (typeof globalThis.__tw_resolve === "function") {
214
+ let resolved = globalThis.__tw_resolve(id, base)
215
+ if (resolved) {
216
+ return Promise.resolve(resolved)
217
+ }
218
+ }
219
+
220
+ if (customJsResolver) {
221
+ let customResolution = await customJsResolver(id, base)
222
+ if (customResolution) {
223
+ return customResolution
224
+ }
225
+ }
226
+
227
+ return runResolver(esmResolver, id, base).catch(() =>
228
+ runResolver(cjsResolver, id, base)
229
+ )
230
+ }
231
+
232
+ function runResolver(
233
+ resolver: BunEnhancedResolve.Resolver,
234
+ id: string,
235
+ base: string,
236
+ ): Promise<string | false | undefined> {
237
+ return new Promise((resolve, reject) =>
238
+ resolver.resolve({}, base, id, {}, (err, result) => {
239
+ if (err) return reject(err)
240
+ resolve(result)
241
+ })
242
+ )
243
+ }
@@ -0,0 +1,17 @@
1
+ import * as NPath from "node:path"
2
+ import * as NodeUtils from "../../NodeUtils.ts"
3
+ import * as TailwindPlugin from "./TailwindPlugin.ts"
4
+
5
+ // Append `?dir=` to module identifier to pass custom directory to scan
6
+ const dirParam = URL.parse(import.meta.url)?.searchParams.get("dir")
7
+ const packageJson = await NodeUtils.findClosestPackageJson(process.cwd())
8
+ const scanPath = dirParam
9
+ ? NPath.resolve(process.cwd(), dirParam)
10
+ : packageJson
11
+ ? NPath.dirname(packageJson)
12
+ : process.cwd()
13
+
14
+ // Export as default to be used in bunfig.toml
15
+ export default TailwindPlugin.make({
16
+ scanPath,
17
+ })