@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.
@@ -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);