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 +9 -12
- package/package.json +5 -9
- package/src/NodeUtils.ts +23 -0
- package/src/bun/_BunEnhancedResolve.ts +201 -0
- package/src/bun/index.ts +0 -1
- package/src/{bun/BunTailwindPlugin.test.ts → x/tailwind/TailwindPlugin.test.ts} +1 -1
- package/src/{bun/BunTailwindPlugin.ts → x/tailwind/TailwindPlugin.ts} +103 -120
- package/src/x/tailwind/compile.ts +243 -0
- package/src/x/tailwind/plugin.ts +17 -0
package/README.md
CHANGED
|
@@ -52,27 +52,24 @@ src/routes
|
|
|
52
52
|
|
|
53
53
|
### Tailwind CSS Support
|
|
54
54
|
|
|
55
|
-
|
|
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
|
|
61
|
+
bun add -D tailwindcss
|
|
59
62
|
```
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
Then, register a plugin in `bunfig.toml`:
|
|
62
65
|
|
|
63
66
|
```toml
|
|
64
67
|
[serve.static]
|
|
65
|
-
plugins = ["
|
|
68
|
+
plugins = ["effect-start/x/tailwind/plugin"]
|
|
66
69
|
```
|
|
67
70
|
|
|
68
|
-
Finally, include it in your `src/app.
|
|
71
|
+
Finally, include it in your `src/app.css`:
|
|
69
72
|
|
|
70
73
|
```html
|
|
71
|
-
|
|
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.
|
|
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": [
|
package/src/NodeUtils.ts
ADDED
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as t from "bun:test"
|
|
2
2
|
|
|
3
|
-
import { extractClassNames } from "./
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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: "
|
|
49
|
-
target
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Register every visited module.
|
|
74
|
+
*/
|
|
75
|
+
{
|
|
76
|
+
if (!importAncestors.has(fullPath)) {
|
|
77
|
+
importAncestors.set(fullPath, new Set())
|
|
78
|
+
}
|
|
91
79
|
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
88
|
+
if (!importDescendants.has(importer)) {
|
|
89
|
+
importDescendants.set(importer, new Set())
|
|
90
|
+
}
|
|
98
91
|
}
|
|
99
|
-
}
|
|
100
92
|
|
|
101
|
-
|
|
102
|
-
|
|
93
|
+
importAncestors.get(fullPath)!.add(importer)
|
|
94
|
+
importDescendants.get(importer)!.add(fullPath)
|
|
103
95
|
|
|
104
|
-
|
|
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:
|
|
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
|
-
|
|
194
|
-
|
|
189
|
+
// HTML class attributes with single quotes: <div class='bg-blue-500 text-white'>
|
|
190
|
+
"<[^>]*?\\sclass\\s*=\\s*'([^']+)'",
|
|
195
191
|
|
|
196
|
-
|
|
192
|
+
// JSX className attributes with double quotes: <div className="bg-blue-500 text-white">
|
|
193
|
+
"<[^>]*?\\sclassName\\s*=\\s*\"([^\"]+)\"",
|
|
197
194
|
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
//
|
|
206
|
-
|
|
198
|
+
// JSX className with braces and double quotes: <div className={"bg-blue-500 text-white"}>
|
|
199
|
+
"<[^>]*?\\sclassName\\s*=\\s*\\{\\s*\"([^\"]+)\"\\s*\\}",
|
|
207
200
|
|
|
208
|
-
//
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
204
|
+
// JSX className with template literals: <div className={`bg-blue-500 ${variable}`}>
|
|
205
|
+
"<[^>]*?\\sclassName\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
|
|
215
206
|
|
|
216
|
-
|
|
217
|
-
|
|
207
|
+
// HTML class with template literals: <div class={`bg-blue-500 ${variable}`}>
|
|
208
|
+
"<[^>]*?\\sclass\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
|
|
218
209
|
|
|
219
|
-
|
|
220
|
-
|
|
210
|
+
// HTML class at start of tag with double quotes: <div class="bg-blue-500">
|
|
211
|
+
"<\\w+\\s+class\\s*=\\s*\"([^\"]+)\"",
|
|
221
212
|
|
|
222
|
-
|
|
223
|
-
|
|
213
|
+
// HTML class at start of tag with single quotes: <div class='bg-blue-500'>
|
|
214
|
+
"<\\w+\\s+class\\s*=\\s*'([^']+)'",
|
|
224
215
|
|
|
225
|
-
|
|
226
|
-
|
|
216
|
+
// JSX className at start of tag with double quotes: <div className="bg-blue-500">
|
|
217
|
+
"<\\w+\\s+className\\s*=\\s*\"([^\"]+)\"",
|
|
227
218
|
|
|
228
|
-
|
|
229
|
-
|
|
219
|
+
// JSX className at start of tag with single quotes: <div className='bg-blue-500'>
|
|
220
|
+
"<\\w+\\s+className\\s*=\\s*'([^']+)'",
|
|
230
221
|
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
235
|
+
const CLASS_NAME_REGEX = new RegExp(
|
|
236
|
+
CLASS_NAME_PATTERNS.map(pattern => `(?:${pattern})`).join("|"),
|
|
237
|
+
"g",
|
|
238
|
+
)
|
|
245
239
|
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
256
|
-
"<\\w+\\s+class\\s*=\\s*\\{\\s*`([^`]*)`\\s*\\}",
|
|
257
|
-
]
|
|
243
|
+
if (!importPath) return false
|
|
258
244
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
.join("|")
|
|
245
|
+
return specifier === undefined
|
|
246
|
+
|| importPath.includes(specifier)
|
|
247
|
+
}
|
|
263
248
|
|
|
264
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|