@dinesh-gamage/react-scoped-css 2.0.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.
Files changed (46) hide show
  1. package/README.md +373 -0
  2. package/dist/adapters/next.d.mts +45 -0
  3. package/dist/adapters/next.d.ts +45 -0
  4. package/dist/adapters/next.js +14465 -0
  5. package/dist/adapters/next.js.map +1 -0
  6. package/dist/adapters/next.mjs +14461 -0
  7. package/dist/adapters/next.mjs.map +1 -0
  8. package/dist/adapters/vite.d.mts +15 -0
  9. package/dist/adapters/vite.d.ts +15 -0
  10. package/dist/adapters/vite.js +14465 -0
  11. package/dist/adapters/vite.js.map +1 -0
  12. package/dist/adapters/vite.mjs +14461 -0
  13. package/dist/adapters/vite.mjs.map +1 -0
  14. package/dist/adapters/webpack.d.mts +25 -0
  15. package/dist/adapters/webpack.d.ts +25 -0
  16. package/dist/adapters/webpack.js +14440 -0
  17. package/dist/adapters/webpack.js.map +1 -0
  18. package/dist/adapters/webpack.mjs +14436 -0
  19. package/dist/adapters/webpack.mjs.map +1 -0
  20. package/dist/babel/index.d.mts +14 -0
  21. package/dist/babel/index.d.ts +14 -0
  22. package/dist/babel/index.js +14392 -0
  23. package/dist/babel/index.js.map +1 -0
  24. package/dist/babel/index.mjs +14386 -0
  25. package/dist/babel/index.mjs.map +1 -0
  26. package/dist/cli/init.d.mts +1 -0
  27. package/dist/cli/init.d.ts +1 -0
  28. package/dist/cli/init.js +122 -0
  29. package/dist/cli/init.js.map +1 -0
  30. package/dist/cli/init.mjs +99 -0
  31. package/dist/cli/init.mjs.map +1 -0
  32. package/dist/index.d.mts +16 -0
  33. package/dist/index.d.ts +16 -0
  34. package/dist/index.js +43 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/index.mjs +17 -0
  37. package/dist/index.mjs.map +1 -0
  38. package/dist/options-DBNexJk6.d.mts +25 -0
  39. package/dist/options-DBNexJk6.d.ts +25 -0
  40. package/dist/postcss/index.d.mts +17 -0
  41. package/dist/postcss/index.d.ts +17 -0
  42. package/dist/postcss/index.js +113 -0
  43. package/dist/postcss/index.js.map +1 -0
  44. package/dist/postcss/index.mjs +83 -0
  45. package/dist/postcss/index.mjs.map +1 -0
  46. package/package.json +102 -0
package/README.md ADDED
@@ -0,0 +1,373 @@
1
+ # react-scoped-css
2
+
3
+ CSS scoping for React with zero code changes. Write your JSX and CSS exactly as you always have — class names get a per-file hash appended at build time so they never collide.
4
+
5
+ ```tsx
6
+ // You write this
7
+ <div className="container">
8
+ <button className="btn">Save</button>
9
+ </div>
10
+ ```
11
+
12
+ ```scss
13
+ // You write this
14
+ .container { padding: 16px; }
15
+ .btn { background: blue; }
16
+ ```
17
+
18
+ ```tsx
19
+ // Build output (invisible to you)
20
+ <div className="container-a3f9b2c1">
21
+ <button className="btn-a3f9b2c1">Save</button>
22
+ </div>
23
+ ```
24
+
25
+ ```css
26
+ /* Build output (invisible to you) */
27
+ .container-a3f9b2c1 { padding: 16px; }
28
+ .btn-a3f9b2c1 { background: blue; }
29
+ ```
30
+
31
+ The hash is derived from the file path — same hash in JSX and CSS, unique per file, identical on every developer machine and in CI.
32
+
33
+ ---
34
+
35
+ ## Overview
36
+
37
+ react-scoped-css is a build-time CSS scoping tool for React. It has two parts that work together:
38
+
39
+ **Babel plugin** — transforms every `className` JSX attribute at compile time, appending a per-file hash to each class name token. Static strings are rewritten inline (zero runtime cost). Dynamic expressions are wrapped with a small `scopeClass()` runtime helper, imported automatically only in files that need it.
40
+
41
+ **PostCSS plugin** — transforms every CSS/SCSS class selector at build time, appending the same per-file hash. SCSS syntax is handled automatically (no extra config). Files matching `*.module.*` are skipped.
42
+
43
+ Both plugins derive the hash the same way: `MD5(relativeFilePath + salt).slice(0, 8)`, where the file extension is stripped so `Card.tsx` and `Card.scss` always produce the same hash. The path is relative to the project root (nearest `package.json`), so the hash is identical on every machine and in CI. The salt defaults to the `name` field in `package.json`, making hashes globally unique across apps in the same monorepo or deployment without any extra configuration.
44
+
45
+ The result: `.container` in `Card.tsx` and `.container` in `UserProfile.tsx` each get different hashes, ending up as `.container-a3f9b2c1` and `.container-b4c8d1e2` — the same code, zero collisions, zero code changes from the developer.
46
+
47
+ ---
48
+
49
+ ## Why not CSS Modules or CSS-in-JS?
50
+
51
+ Every existing solution requires you to change how you write code:
52
+
53
+ | Tool | What you have to change |
54
+ |---|---|
55
+ | CSS Modules | Rename every import; use `styles.className` everywhere |
56
+ | styled-components / Emotion | Entirely different syntax |
57
+ | babel-plugin-react-css-modules | Rename `className` to `styleName` |
58
+
59
+ This tool requires no changes. Add it to your build config once, and existing code is scoped automatically.
60
+
61
+ ---
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ npm install @dinesh-gamage/react-scoped-css
67
+ ```
68
+
69
+ Then run the init command to get the config snippet for your bundler:
70
+
71
+ ```bash
72
+ npx @dinesh-gamage/react-scoped-css init
73
+ ```
74
+
75
+ ---
76
+
77
+ ## Setup
78
+
79
+ ### Vite
80
+
81
+ ```ts
82
+ // vite.config.ts
83
+ import { defineConfig } from 'vite';
84
+ import react from '@vitejs/plugin-react';
85
+ import { scopedCss } from '@dinesh-gamage/react-scoped-css/vite';
86
+
87
+ export default defineConfig({
88
+ plugins: [
89
+ react(),
90
+ scopedCss({ exclude: ['global-'] }),
91
+ ],
92
+ });
93
+ ```
94
+
95
+ ### Next.js
96
+
97
+ ```js
98
+ // next.config.js
99
+ const { withScopedCss } = require('@dinesh-gamage/react-scoped-css/next');
100
+
101
+ module.exports = withScopedCss({
102
+ exclude: ['global-'],
103
+ })({
104
+ // ...your existing Next.js config
105
+ });
106
+ ```
107
+
108
+ ### webpack
109
+
110
+ ```js
111
+ // webpack.config.js
112
+ const { scopedCssWebpack } = require('@dinesh-gamage/react-scoped-css/webpack');
113
+ const { babelPlugin, postcssPlugin } = scopedCssWebpack({
114
+ exclude: ['global-'],
115
+ });
116
+
117
+ module.exports = {
118
+ module: {
119
+ rules: [
120
+ {
121
+ test: /\.[jt]sx?$/,
122
+ use: {
123
+ loader: 'babel-loader',
124
+ options: {
125
+ plugins: [babelPlugin],
126
+ },
127
+ },
128
+ },
129
+ {
130
+ test: /\.css$/,
131
+ use: [
132
+ 'style-loader',
133
+ 'css-loader',
134
+ {
135
+ loader: 'postcss-loader',
136
+ options: {
137
+ postcssOptions: {
138
+ plugins: [postcssPlugin],
139
+ },
140
+ },
141
+ },
142
+ ],
143
+ },
144
+ ],
145
+ },
146
+ };
147
+ ```
148
+
149
+ ### Manual (Babel + PostCSS directly)
150
+
151
+ If you configure Babel and PostCSS yourself:
152
+
153
+ ```js
154
+ // babel.config.js
155
+ const { default: scopedCssBabel } = require('@dinesh-gamage/react-scoped-css/babel');
156
+
157
+ module.exports = {
158
+ plugins: [
159
+ [scopedCssBabel, { exclude: ['global-'] }],
160
+ ],
161
+ };
162
+ ```
163
+
164
+ ```js
165
+ // postcss.config.js
166
+ const { scopedCssPostcss } = require('@dinesh-gamage/react-scoped-css/postcss');
167
+
168
+ module.exports = {
169
+ plugins: [
170
+ scopedCssPostcss({ exclude: ['global-'] }),
171
+ ],
172
+ };
173
+ ```
174
+
175
+ ---
176
+
177
+ ## Excluding class names
178
+
179
+ Use `exclude` to list class name prefixes that should never be scoped. This is the main mechanism for component library overrides — you want to write `.uxp-button { color: red }` to override a library style, not `.uxp-button-a3f9b2c1`.
180
+
181
+ ```ts
182
+ scopedCss({
183
+ exclude: ['uxp-', 'mantine-', 'global-', 'app-'],
184
+ })
185
+ ```
186
+
187
+ Any class name whose string value starts with an excluded prefix is left exactly as written, in both JSX output and CSS output.
188
+
189
+ ```tsx
190
+ // exclude: ['uxp-']
191
+
192
+ <div className="container uxp-button">
193
+ // ^^^^^^^^^^^^^^^^^^^
194
+ // "container" → "container-a3f9b2c1"
195
+ // "uxp-button" → "uxp-button" (untouched)
196
+ ```
197
+
198
+ ```scss
199
+ .container { padding: 16px; } // → .container-a3f9b2c1
200
+ .uxp-button { font-weight: bold; } // → .uxp-button (untouched)
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Configuration
206
+
207
+ ```ts
208
+ interface ScopedCssOptions {
209
+ /**
210
+ * Class name prefixes to leave unscoped.
211
+ * Default: []
212
+ */
213
+ exclude?: string[];
214
+
215
+ /**
216
+ * Override the salt used in hash generation.
217
+ * Defaults to the `name` field from the nearest package.json.
218
+ * Override in monorepos or multi-app deployments to guarantee
219
+ * globally unique class names across apps.
220
+ */
221
+ salt?: string;
222
+
223
+ /**
224
+ * Number of hex characters in the hash suffix.
225
+ * Default: 8
226
+ */
227
+ hashLength?: number;
228
+ }
229
+ ```
230
+
231
+ ---
232
+
233
+ ## How it works
234
+
235
+ **Babel plugin** (`@dinesh-gamage/react-scoped-css/babel`) — visits every `className` JSX attribute and appends `-{hash}` to each class name token at compile time. Handles all real-world patterns:
236
+
237
+ | Pattern | Input | Output |
238
+ |---|---|---|
239
+ | String literal | `className="foo bar"` | `className="foo-a3f9b2c1 bar-a3f9b2c1"` |
240
+ | String expression | `className={"foo"}` | `className={"foo-a3f9b2c1"}` |
241
+ | Template literal (static) | `` className={`foo`} `` | `` className={`foo-a3f9b2c1`} `` |
242
+ | Template literal (dynamic) | `` className={`foo ${x}`} `` | `` className={`foo-a3f9b2c1 ${scopeClass(x, "a3f9b2c1")}`} `` |
243
+ | Variable | `className={myClass}` | `className={scopeClass(myClass, "a3f9b2c1")}` |
244
+ | classNames() call | `className={classNames("foo", {bar: x})}` | `className={classNames("foo-a3f9b2c1", {"bar-a3f9b2c1": x})}` |
245
+ | Ternary | `className={x ? "a" : "b"}` | `className={x ? "a-a3f9b2c1" : "b-a3f9b2c1"}` |
246
+ | Logical | `className={x && "foo"}` | `className={x && "foo-a3f9b2c1"}` |
247
+ | Excluded prefix | `className="uxp-button"` | `className="uxp-button"` |
248
+
249
+ Static string literals are transformed entirely at compile time — no runtime cost, no import added. Dynamic expressions use `scopeClass()`, a small runtime helper that is imported automatically only in files that need it.
250
+
251
+ **PostCSS plugin** (`@dinesh-gamage/react-scoped-css/postcss`) — walks every CSS rule selector and appends `-{hash}` to each class token, matching what the Babel plugin produces. SCSS and Less are supported via `postcss-scss` (bundled). Files matching `*.module.*` are skipped — they are already scoped by CSS Modules.
252
+
253
+ **Hash** — `MD5(relativeFilePath + salt).slice(0, 8)`. The path is relative to the nearest `package.json`, so the hash is identical on every developer machine and in CI regardless of where the repo is cloned. The salt defaults to the `name` field from `package.json`, which makes hashes globally unique across different apps without any configuration.
254
+
255
+ ---
256
+
257
+ ## Known limitations
258
+
259
+ **Dynamic class names set outside JSX** — `element.className = 'foo'` and `document.createElement` calls are not transformed. The Babel plugin only processes JSX `className` attributes. Workaround: use `scopeClass` from `react-scoped-css` directly:
260
+
261
+ ```ts
262
+ import { scopeClass } from '@dinesh-gamage/react-scoped-css';
263
+ // you need to supply the hash manually — get it from the build output
264
+ ```
265
+
266
+ For most React codebases this is not an issue.
267
+
268
+ **Template literals with nested `classNames()` calls** — `` className={`wrapper ${classNames({active: x})}`} `` — the outer template literal is processed but the inner `classNames()` call is not recursively transformed. Workaround: move the `classNames()` call outside the template literal.
269
+
270
+ **Third-party components that accept `className`** — A library component that uses your scoped class name internally (not just forwards it to a DOM element) may not match your CSS. The `exclude` list handles top-level library class names. Internal library classes are unaffected.
271
+
272
+ **React compiler (experimental)** — Untested with the React 19 compiler. The Babel plugin runs before the React compiler in the standard pipeline, but verify in your specific setup.
273
+
274
+ ---
275
+
276
+ ## Contributing
277
+
278
+ Contributions are welcome. Here is what the project needs most:
279
+
280
+ - **e2e tests** — full build integration tests for Vite and webpack (`tests/e2e/`) — currently empty
281
+ - **Less support** — `postcss-less` is not yet bundled; `.less` files are not auto-detected in the PostCSS plugin
282
+ - **React 19 / React compiler verification** — the Babel plugin is untested with the React compiler; someone with a React 19 + compiler project should validate it
283
+ - **Rollup adapter** — `src/adapters/rollup.ts` following the same pattern as the Vite adapter
284
+ - **Nested `classNames()` inside template literals** — known limitation, see [Known limitations](#known-limitations)
285
+ - **Bug reports with minimal reproductions** — open an issue with the smallest possible code that shows the problem
286
+
287
+ ### Setup
288
+
289
+ ```bash
290
+ git clone https://github.com/dinesh-gamage/react-scoped-css
291
+ cd react-scoped-css
292
+ npm install
293
+ npm test # 38 tests, should all pass
294
+ npm run build # builds dist/
295
+ ```
296
+
297
+ ### Project structure
298
+
299
+ ```
300
+ src/
301
+ babel/index.ts JSX className transform — all 9 patterns, AST-based
302
+ postcss/index.ts CSS class selector transform, SCSS auto-detected
303
+ cli/init.ts npx @dinesh-gamage/react-scoped-css init — detect bundler, print snippet
304
+ adapters/
305
+ vite.ts Vite plugin (wires up both automatically)
306
+ next.ts Next.js withScopedCss() wrapper
307
+ webpack.ts webpack {babelPlugin, postcssPlugin} helper
308
+ shared/
309
+ hash.ts MD5(relPathWithoutExt + salt).slice(0,8)
310
+ exclude.ts prefix-based exclusion check
311
+ options.ts ScopedCssOptions interface
312
+ classNames.ts scopeClass() runtime helper
313
+ index.ts package root export
314
+
315
+ tests/
316
+ shared/ hash and exclude unit tests
317
+ babel/ Babel plugin tests — one per className pattern
318
+ postcss/ PostCSS plugin tests
319
+ e2e/ (empty — needs full Vite/webpack integration tests)
320
+ ```
321
+
322
+ ### Key design decisions
323
+
324
+ **Hash = `MD5(relPathWithoutExt + salt)`** — extension is stripped so `Card.tsx` and `Card.scss` produce the same hash. Without this, the Babel plugin (processing `.tsx`) and the PostCSS plugin (processing `.scss`) would generate different hashes for the same component.
325
+
326
+ **Babel over regex** — className transformation uses Babel AST, not regex. This is correct for all edge cases (template literals, ternaries, variables, classNames() calls) where regex silently produces wrong output.
327
+
328
+ **PostCSS over string replacement** — CSS transformation uses PostCSS rule walking, not string replacement. Handles nested SCSS, multi-selector rules, and pseudo-classes correctly.
329
+
330
+ **`scopeClass()` import injected per-file** — the runtime helper is only imported in files that contain dynamic className expressions. Static-only files get no import, no runtime cost.
331
+
332
+ **`exclude` is prefix-based** — any class starting with an excluded prefix is left completely unchanged in both JSX and CSS. Intended for component library overrides (e.g. `uxp-`, `mantine-`).
333
+
334
+ ### Adding a new adapter
335
+
336
+ Follow the pattern in `src/adapters/vite.ts`:
337
+
338
+ 1. Import `scopedCssPostcss` from `../postcss/index` and `reactScopedCssBabelPlugin` from `../babel/index`
339
+ 2. Wire up both plugins for the target bundler
340
+ 3. Export a named function with the bundler's conventional API shape
341
+ 4. Add the entry to `tsup.config.ts` and `package.json` exports
342
+
343
+ ### Submitting a PR
344
+
345
+ - Keep PRs focused — one concern per PR
346
+ - Add or update tests for any behaviour change
347
+ - `npm test` must pass with no failures
348
+ - `npm run build` must succeed with no type errors (`tsc --noEmit`)
349
+ - For bug fixes: include a test that fails before your fix and passes after
350
+
351
+ Open an issue first for anything large (new adapters, new configuration options, behaviour changes) so we can agree on the approach before you write the code.
352
+
353
+ ---
354
+
355
+ ## Migrating from react-scoped-css-loader (v1)
356
+
357
+ v1 (`react-scoped-css-loader`) and v2 (`@dinesh-gamage/react-scoped-css`) are separate packages. v1 remains on npm unchanged.
358
+
359
+ To migrate:
360
+
361
+ ```bash
362
+ npm uninstall react-scoped-css-loader
363
+ npm install @dinesh-gamage/react-scoped-css
364
+ npx @dinesh-gamage/react-scoped-css init
365
+ ```
366
+
367
+ Then replace the v1 webpack loader config with the v2 config snippet printed by `init`.
368
+
369
+ ---
370
+
371
+ ## License
372
+
373
+ MIT
@@ -0,0 +1,45 @@
1
+ import { S as ScopedCssOptions } from '../options-DBNexJk6.mjs';
2
+
3
+ type NextConfig = Record<string, unknown> & {
4
+ webpack?: (config: WebpackConfig, options: NextWebpackOptions) => WebpackConfig;
5
+ experimental?: {
6
+ turbo?: unknown;
7
+ };
8
+ };
9
+ type WebpackConfig = {
10
+ module?: {
11
+ rules?: WebpackRule[];
12
+ };
13
+ [key: string]: unknown;
14
+ };
15
+ type WebpackRule = {
16
+ test?: RegExp;
17
+ use?: WebpackUse | WebpackUse[];
18
+ [key: string]: unknown;
19
+ };
20
+ type WebpackUse = {
21
+ loader?: string;
22
+ options?: {
23
+ plugins?: unknown[];
24
+ presets?: unknown[];
25
+ postcssOptions?: {
26
+ plugins?: unknown[];
27
+ };
28
+ [key: string]: unknown;
29
+ };
30
+ [key: string]: unknown;
31
+ };
32
+ type NextWebpackOptions = {
33
+ isServer: boolean;
34
+ [key: string]: unknown;
35
+ };
36
+ /**
37
+ * Next.js config wrapper — injects Babel + PostCSS plugins automatically.
38
+ *
39
+ * Usage (next.config.js):
40
+ * const { withScopedCss } = require('react-scoped-css/next');
41
+ * module.exports = withScopedCss({ exclude: ['uxp-'] })({ ... next config ... });
42
+ */
43
+ declare function withScopedCss(opts?: ScopedCssOptions): (nextConfig?: NextConfig) => NextConfig;
44
+
45
+ export { withScopedCss };
@@ -0,0 +1,45 @@
1
+ import { S as ScopedCssOptions } from '../options-DBNexJk6.js';
2
+
3
+ type NextConfig = Record<string, unknown> & {
4
+ webpack?: (config: WebpackConfig, options: NextWebpackOptions) => WebpackConfig;
5
+ experimental?: {
6
+ turbo?: unknown;
7
+ };
8
+ };
9
+ type WebpackConfig = {
10
+ module?: {
11
+ rules?: WebpackRule[];
12
+ };
13
+ [key: string]: unknown;
14
+ };
15
+ type WebpackRule = {
16
+ test?: RegExp;
17
+ use?: WebpackUse | WebpackUse[];
18
+ [key: string]: unknown;
19
+ };
20
+ type WebpackUse = {
21
+ loader?: string;
22
+ options?: {
23
+ plugins?: unknown[];
24
+ presets?: unknown[];
25
+ postcssOptions?: {
26
+ plugins?: unknown[];
27
+ };
28
+ [key: string]: unknown;
29
+ };
30
+ [key: string]: unknown;
31
+ };
32
+ type NextWebpackOptions = {
33
+ isServer: boolean;
34
+ [key: string]: unknown;
35
+ };
36
+ /**
37
+ * Next.js config wrapper — injects Babel + PostCSS plugins automatically.
38
+ *
39
+ * Usage (next.config.js):
40
+ * const { withScopedCss } = require('react-scoped-css/next');
41
+ * module.exports = withScopedCss({ exclude: ['uxp-'] })({ ... next config ... });
42
+ */
43
+ declare function withScopedCss(opts?: ScopedCssOptions): (nextConfig?: NextConfig) => NextConfig;
44
+
45
+ export { withScopedCss };