@greenfinity/rescript-typed-css-modules 0.1.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 +204 -0
- package/__tests__/.gitkeep +0 -0
- package/dist/css-to-rescript.js +26902 -0
- package/package.json +58 -0
- package/rescript.json +30 -0
- package/src/CssToRescript.bs.mjs +335 -0
- package/src/CssToRescript.res +381 -0
- package/src/require-shim.mjs +45 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// CSS Modules to ReScript converter
|
|
2
|
+
// Extracts class names from .module.css/.module.scss and .global.css/.global.scss files
|
|
3
|
+
// and generates ReScript bindings
|
|
4
|
+
|
|
5
|
+
module Meow = {
|
|
6
|
+
type flag = {
|
|
7
|
+
@as("type") type_: string,
|
|
8
|
+
shortFlag?: string,
|
|
9
|
+
default?: bool,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type flags = {"watch": flag, "outputDir": flag, "skipInitial": flag}
|
|
13
|
+
|
|
14
|
+
type importMeta
|
|
15
|
+
|
|
16
|
+
type options = {
|
|
17
|
+
importMeta: importMeta,
|
|
18
|
+
flags: flags,
|
|
19
|
+
allowUnknownFlags?: bool,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type result = {
|
|
23
|
+
input: array<string>,
|
|
24
|
+
flags: {"watch": bool, "outputDir": option<string>, "skipInitial": bool},
|
|
25
|
+
showHelp: unit => unit,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@module("meow") external make: (string, options) => result = "default"
|
|
29
|
+
|
|
30
|
+
@val external importMeta: importMeta = "import.meta"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Chokidar bindings for file watching
|
|
34
|
+
module Chokidar = {
|
|
35
|
+
type watcher
|
|
36
|
+
|
|
37
|
+
@module("chokidar")
|
|
38
|
+
external watch: (array<string>, {"ignored": string => bool, "persistent": bool}) => watcher =
|
|
39
|
+
"watch"
|
|
40
|
+
|
|
41
|
+
@send external on: (watcher, string, string => unit) => watcher = "on"
|
|
42
|
+
@send external onReady: (watcher, @as("ready") _, unit => unit) => watcher = "on"
|
|
43
|
+
@send external close: watcher => promise<unit> = "close"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module PostCss = {
|
|
47
|
+
type plugin
|
|
48
|
+
type syntax
|
|
49
|
+
type t
|
|
50
|
+
type processOptions = {from: string, syntax?: syntax}
|
|
51
|
+
|
|
52
|
+
@module("postcss")
|
|
53
|
+
external make: array<plugin> => t = "default"
|
|
54
|
+
|
|
55
|
+
@send external process: (t, string, processOptions) => promise<t> = "process"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module PostCssScss = {
|
|
59
|
+
@module("postcss-scss")
|
|
60
|
+
external syntax: PostCss.syntax = "default"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module PostCssImport = {
|
|
64
|
+
@module("postcss-import")
|
|
65
|
+
external make: unit => PostCss.plugin = "default"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module PostCssModules = {
|
|
69
|
+
type pluginOptions = {getJSON: (string, Dict.t<string>) => unit, exportGlobals?: bool}
|
|
70
|
+
|
|
71
|
+
@module("postcss-modules")
|
|
72
|
+
external make: pluginOptions => PostCss.plugin = "default"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Extract class names from CSS/SCSS content
|
|
76
|
+
// Uses postcss-import to resolve @imported files
|
|
77
|
+
// ~from is required for the source map generation
|
|
78
|
+
let extractClassNames = async (cssContent, ~from) =>
|
|
79
|
+
(
|
|
80
|
+
await Promise.make((resolve, _) => {
|
|
81
|
+
let classNames = ref([])
|
|
82
|
+
PostCss.make([
|
|
83
|
+
PostCssImport.make(),
|
|
84
|
+
PostCssModules.make({
|
|
85
|
+
getJSON: (_, json) => {classNames := json->Dict.keysToArray},
|
|
86
|
+
exportGlobals: true,
|
|
87
|
+
}),
|
|
88
|
+
])
|
|
89
|
+
->PostCss.process(cssContent, {from, syntax: PostCssScss.syntax})
|
|
90
|
+
->Promise.thenResolve(_ => classNames.contents->resolve)
|
|
91
|
+
->ignore
|
|
92
|
+
})
|
|
93
|
+
)->Array.toSorted(String.compare)
|
|
94
|
+
|
|
95
|
+
type importType = Module | Global
|
|
96
|
+
|
|
97
|
+
// Generate ReScript binding's for CSS module
|
|
98
|
+
let generateReScriptBindings = (baseName, importType, classNames) => {
|
|
99
|
+
let recordFields = classNames->Array.map(className => ` "${className}": string`)
|
|
100
|
+
let prelude = `// Generated from ${baseName}
|
|
101
|
+
// Do not edit manually
|
|
102
|
+
|
|
103
|
+
type t = {
|
|
104
|
+
${recordFields->Array.join(",\n")}
|
|
105
|
+
}
|
|
106
|
+
`
|
|
107
|
+
switch importType {
|
|
108
|
+
| Module =>
|
|
109
|
+
// CSS Module import will get access to the object mapping returned
|
|
110
|
+
// by the import. Hashing will happen automatically.
|
|
111
|
+
prelude +
|
|
112
|
+
`@module("./${baseName}") external css: t = "default"
|
|
113
|
+
|
|
114
|
+
// Access class names from the fields of the css object.
|
|
115
|
+
// For scoped classses, the hashed class name is returned.
|
|
116
|
+
// For :global() classes, the class name is returned as-is: no scoping.
|
|
117
|
+
// Classes from @import are also available.
|
|
118
|
+
|
|
119
|
+
@module("./${baseName}") external _imported: t = "default"
|
|
120
|
+
@new external proxy: ('a, 'b) => 'c = "Proxy"
|
|
121
|
+
%%private(
|
|
122
|
+
external toDict: t => dict<string> = "%identity"
|
|
123
|
+
let withProxy = (obj: t): t =>
|
|
124
|
+
proxy(
|
|
125
|
+
obj,
|
|
126
|
+
{
|
|
127
|
+
// "get": (_b, _c): string => %raw("_b[_c] || _c"),
|
|
128
|
+
"get": (base, className) =>
|
|
129
|
+
switch base->toDict->Dict.get(className) {
|
|
130
|
+
| Some(className) => className
|
|
131
|
+
| None => className
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
let css = withProxy(css)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
`
|
|
140
|
+
| Global =>
|
|
141
|
+
// Global css will return the css class name as-is: no scoping.
|
|
142
|
+
// Import is not done.
|
|
143
|
+
prelude + `
|
|
144
|
+
// Access class names from the fields of the css object.
|
|
145
|
+
// Import is not done, the css has to be manually imported
|
|
146
|
+
// from the top of the component hierarchy.
|
|
147
|
+
// For all classes, the class name is returned as-is: no scoping.
|
|
148
|
+
// Classes from @import are also available.
|
|
149
|
+
|
|
150
|
+
@new external proxy: ('a, 'b) => 'c = "Proxy"
|
|
151
|
+
type empty = {}
|
|
152
|
+
%%private(
|
|
153
|
+
let withProxy = (obj: empty): t =>
|
|
154
|
+
proxy(
|
|
155
|
+
obj,
|
|
156
|
+
{
|
|
157
|
+
"get": (_: empty, className: string): string => className,
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
let css = withProxy({})
|
|
162
|
+
`
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Determines the base name and import type of a CSS file
|
|
167
|
+
// Card.module.scss -> ("Card", Module)
|
|
168
|
+
// Card.global.scss -> ("Card", Global)
|
|
169
|
+
let getBaseNameAndImportType = cssFilePath => {
|
|
170
|
+
let baseName = cssFilePath->NodeJs.Path.basename
|
|
171
|
+
(
|
|
172
|
+
baseName,
|
|
173
|
+
if /\.module\.(css|scss)$/->RegExp.test(baseName) {
|
|
174
|
+
Module
|
|
175
|
+
} else {
|
|
176
|
+
Global
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Generate output filename from CSS module/global path
|
|
182
|
+
// ("Card", Module) -> "Card_CssModule.res"
|
|
183
|
+
// ("Card", Global) -> "Card_CssGlobal.res"
|
|
184
|
+
let getOutputFileName = (baseName, importType) => {
|
|
185
|
+
switch importType {
|
|
186
|
+
| Module => baseName->String.replaceRegExp(/\.module\.(css|scss)$/, "_CssModule") ++ ".res"
|
|
187
|
+
| Global => baseName->String.replaceRegExp(/\.global\.(css|scss)$/, "_CssGlobal") ++ ".res"
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Process a single CSS module file
|
|
192
|
+
let processFile = async (cssFilePath, outputDir) => {
|
|
193
|
+
let content = NodeJs.Fs.readFileSync(cssFilePath)->NodeJs.Buffer.toString
|
|
194
|
+
Console.log(`Processing file: ${cssFilePath}`)
|
|
195
|
+
let classNames = await extractClassNames(content, ~from=cssFilePath)
|
|
196
|
+
|
|
197
|
+
if classNames->Array.length == 0 {
|
|
198
|
+
Console.log(`⚠️ No classes found in ${cssFilePath}`)
|
|
199
|
+
None
|
|
200
|
+
} else {
|
|
201
|
+
let (baseName, importType) = cssFilePath->getBaseNameAndImportType
|
|
202
|
+
let outputFileName = getOutputFileName(baseName, importType)
|
|
203
|
+
let bindings = generateReScriptBindings(baseName, importType, classNames)
|
|
204
|
+
let outputPath = NodeJs.Path.join2(
|
|
205
|
+
outputDir->Option.getOr(cssFilePath->NodeJs.Path.dirname),
|
|
206
|
+
outputFileName,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
NodeJs.Fs.writeFileSync(outputPath, NodeJs.Buffer.fromString(bindings))
|
|
210
|
+
Console.log(`✅ Generated ${outputPath} (${classNames->Array.length->Int.toString} classes)`)
|
|
211
|
+
|
|
212
|
+
(outputPath, classNames)->Some
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Find all CSS module and global files recursively
|
|
217
|
+
let rec findCssFiles = dir => {
|
|
218
|
+
let entries = NodeJs.Fs.readdirSync(dir)
|
|
219
|
+
|
|
220
|
+
entries->Array.flatMap(entry => {
|
|
221
|
+
let fullPath = NodeJs.Path.join2(dir, entry)
|
|
222
|
+
let stat = NodeJs.Fs.lstatSync(#String(fullPath))
|
|
223
|
+
if stat->NodeJs.Fs.Stats.isDirectory {
|
|
224
|
+
findCssFiles(fullPath)
|
|
225
|
+
} else if /\.(module|global)\.(css|scss)$/->RegExp.test(entry) {
|
|
226
|
+
[fullPath]
|
|
227
|
+
} else {
|
|
228
|
+
[]
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Watch directories for CSS module and global file changes
|
|
234
|
+
let watchDirectories = async (dirs, outputDir, ~skipInitial) => {
|
|
235
|
+
Console.log(
|
|
236
|
+
`👀 Watching ${dirs
|
|
237
|
+
->Array.length
|
|
238
|
+
->Int.toString} directories for CSS module/global changes...`,
|
|
239
|
+
)
|
|
240
|
+
dirs->Array.forEach(dir => Console.log(` ${dir}`))
|
|
241
|
+
if skipInitial {
|
|
242
|
+
Console.log(`Skipping initial compilation.`)
|
|
243
|
+
}
|
|
244
|
+
Console.log(`Press Ctrl+C to stop.\n`)
|
|
245
|
+
|
|
246
|
+
// Set up chokidar watcher for CSS module and global files
|
|
247
|
+
let isIgnored = path => {
|
|
248
|
+
// Ignore dotfiles and non-CSS module/global files
|
|
249
|
+
let isDotfile = /(^|[\/\\])\./->RegExp.test(path)
|
|
250
|
+
let isCssFile = /\.(module|global)\.(css|scss)$/->RegExp.test(path)
|
|
251
|
+
let isDir =
|
|
252
|
+
NodeJs.Fs.existsSync(path) && NodeJs.Fs.lstatSync(#String(path))->NodeJs.Fs.Stats.isDirectory
|
|
253
|
+
isDotfile || (!isCssFile && !isDir)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let ready = ref(false)
|
|
257
|
+
|
|
258
|
+
Chokidar.watch(dirs, {"ignored": isIgnored, "persistent": true})
|
|
259
|
+
->Chokidar.onReady(() => {
|
|
260
|
+
ready := true
|
|
261
|
+
Console.log(`Ready for changes.`)
|
|
262
|
+
})
|
|
263
|
+
->Chokidar.on("change", path => {
|
|
264
|
+
Console.log(`\nChanged: ${path}`)
|
|
265
|
+
processFile(path, outputDir)->ignore
|
|
266
|
+
})
|
|
267
|
+
->Chokidar.on("add", path => {
|
|
268
|
+
// Skip initial files if skipInitial is set
|
|
269
|
+
if skipInitial && !ready.contents {
|
|
270
|
+
()
|
|
271
|
+
} else {
|
|
272
|
+
Console.log(`\nAdded: ${path}`)
|
|
273
|
+
processFile(path, outputDir)->ignore
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
->Chokidar.on("unlink", path => {
|
|
277
|
+
Console.log(`\n🗑️ Deleted: ${path}`)
|
|
278
|
+
})
|
|
279
|
+
->ignore
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let helpText = `
|
|
283
|
+
Usage
|
|
284
|
+
$ css-to-rescript <file.module.css|scss|global.css|scss...>
|
|
285
|
+
$ css-to-rescript <directory...>
|
|
286
|
+
|
|
287
|
+
Generates ReScript bindings from CSS module and global files:
|
|
288
|
+
*.module.css|scss -> *_CssModule.res
|
|
289
|
+
*.global.css|scss -> *_CssGlobal.res
|
|
290
|
+
|
|
291
|
+
Options
|
|
292
|
+
--watch, -w Watch for changes and regenerate bindings (directories only)
|
|
293
|
+
--skip-initial, -s Skip initial compilation in watch mode
|
|
294
|
+
--output-dir, -o Directory to write generated .res files
|
|
295
|
+
(multiple files or single directory only)
|
|
296
|
+
|
|
297
|
+
Examples
|
|
298
|
+
$ css-to-rescript src/Card.module.scss
|
|
299
|
+
$ css-to-rescript src/Theme.global.css
|
|
300
|
+
$ css-to-rescript src/Button.module.css src/Card.module.scss -o src/bindings
|
|
301
|
+
$ css-to-rescript src/components
|
|
302
|
+
$ css-to-rescript src/components src/pages --watch
|
|
303
|
+
`
|
|
304
|
+
|
|
305
|
+
let main = async () => {
|
|
306
|
+
let cli = Meow.make(
|
|
307
|
+
helpText,
|
|
308
|
+
{
|
|
309
|
+
importMeta: Meow.importMeta,
|
|
310
|
+
flags: {
|
|
311
|
+
"watch": {Meow.type_: "boolean", shortFlag: "w", default: false},
|
|
312
|
+
"outputDir": {Meow.type_: "string", shortFlag: "o"},
|
|
313
|
+
"skipInitial": {Meow.type_: "boolean", shortFlag: "s", default: false},
|
|
314
|
+
},
|
|
315
|
+
allowUnknownFlags: false,
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
let inputPaths = cli.input
|
|
320
|
+
|
|
321
|
+
if inputPaths->Array.length == 0 {
|
|
322
|
+
cli.showHelp()
|
|
323
|
+
NodeJs.Process.process->NodeJs.Process.exitWithCode(1)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let outputDir = cli.flags["outputDir"]
|
|
327
|
+
let watchMode = cli.flags["watch"]
|
|
328
|
+
let skipInitial = cli.flags["skipInitial"]
|
|
329
|
+
|
|
330
|
+
// Classify inputs as files or directories
|
|
331
|
+
let (files, dirs) = inputPaths->Array.reduce(([], []), ((files, dirs), path) => {
|
|
332
|
+
let stat = NodeJs.Fs.lstatSync(#String(path))
|
|
333
|
+
if stat->NodeJs.Fs.Stats.isFile {
|
|
334
|
+
(files->Array.concat([path]), dirs)
|
|
335
|
+
} else if stat->NodeJs.Fs.Stats.isDirectory {
|
|
336
|
+
(files, dirs->Array.concat([path]))
|
|
337
|
+
} else {
|
|
338
|
+
(files, dirs)
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// Validation: watch mode only supports directories
|
|
343
|
+
if watchMode && files->Array.length > 0 {
|
|
344
|
+
Console.error(`Error: Watch mode only supports directories, not files.`)
|
|
345
|
+
NodeJs.Process.process->NodeJs.Process.exitWithCode(1)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Validation: output-dir only supports multiple files OR single directory
|
|
349
|
+
if (
|
|
350
|
+
outputDir->Option.isSome &&
|
|
351
|
+
(dirs->Array.length > 1 || (files->Array.length > 0 && dirs->Array.length > 0))
|
|
352
|
+
) {
|
|
353
|
+
Console.error(`Error: --output-dir only supports multiple files or a single directory, not mixed inputs or multiple directories.`)
|
|
354
|
+
NodeJs.Process.process->NodeJs.Process.exitWithCode(1)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Process files
|
|
358
|
+
if files->Array.length > 0 {
|
|
359
|
+
await files->Array.reduce(Promise.resolve(), async (acc, file) => {
|
|
360
|
+
await acc
|
|
361
|
+
(await processFile(file, outputDir))->ignore
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Process directories
|
|
366
|
+
if dirs->Array.length > 0 {
|
|
367
|
+
if watchMode {
|
|
368
|
+
await watchDirectories(dirs, outputDir, ~skipInitial)
|
|
369
|
+
} else {
|
|
370
|
+
let moduleFiles = dirs->Array.flatMap(findCssFiles)
|
|
371
|
+
Console.log(`Found ${moduleFiles->Array.length->Int.toString} CSS module files\n`)
|
|
372
|
+
|
|
373
|
+
await moduleFiles->Array.reduce(Promise.resolve(), async (acc, file) => {
|
|
374
|
+
await acc
|
|
375
|
+
(await processFile(file, outputDir))->ignore
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
main()->ignore
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
|
|
3
|
+
# The Problem
|
|
4
|
+
|
|
5
|
+
The `@greenfinity/rescript-typed-css-modules` package provides a CLI tool (`css-to-rescript`) that generates ReScript bindings from CSS module files. This tool depends on `postcss`, `postcss-modules`, and `postcss-import` for parsing CSS.
|
|
6
|
+
|
|
7
|
+
**Goal**: Bundle ALL dependencies into a single executable file, so consuming projects don't need to install postcss (or Rescript core libraries)as a dependency. They just install the package and run the CLI.
|
|
8
|
+
|
|
9
|
+
# The Challenge
|
|
10
|
+
|
|
11
|
+
1. **ReScript compiles to ESM** - The `.bs.mjs` files use `import.meta` and ES module syntax, requiring `--format=esm`
|
|
12
|
+
2. **postcss uses CommonJS** - It has dynamic `require()` calls that don't work in ESM bundles
|
|
13
|
+
3. **These two requirements conflict** - ESM bundles can't handle CommonJS `require()` calls
|
|
14
|
+
|
|
15
|
+
# The Solution
|
|
16
|
+
|
|
17
|
+
Use `createRequire` to provide a working `require` function in the ESM bundle.
|
|
18
|
+
|
|
19
|
+
**esbuild command**:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
esbuild src/CssToRescript.bs.mjs \
|
|
23
|
+
--bundle \
|
|
24
|
+
--platform=node \
|
|
25
|
+
--format=esm \
|
|
26
|
+
--outfile=dist/css-to-rescript.js \
|
|
27
|
+
--banner:js="#!/usr/bin/env node" \
|
|
28
|
+
--inject:./src/require-shim.mjs
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Key flags:
|
|
32
|
+
|
|
33
|
+
- `--bundle` - Bundle all dependencies into one file
|
|
34
|
+
- `--platform=node` - Target Node.js runtime
|
|
35
|
+
- `--format=esm` - Required because ReScript outputs ESM
|
|
36
|
+
- `--inject:./src/require-shim.mjs` - Inject the shim at the top of the bundle, providing `require` for bundled CommonJS code
|
|
37
|
+
|
|
38
|
+
# Why --inject instead of --banner
|
|
39
|
+
|
|
40
|
+
Initially tried putting the shim code in `--banner:js`, but shell interpretation in package.json scripts caused errors with newlines. Using `--inject` with a separate file avoids shell escaping issues.
|
|
41
|
+
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { createRequire } from 'module';
|
|
45
|
+
globalThis.require = createRequire(import.meta.url);
|