@openmrs/carbon-css-guard 10.0.1-pre.5378

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.
File without changes
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A webpack/rspack plugin that fails a build whose emitted CSS restyles the page as a whole rather
3
+ * than the module's own markup.
4
+ *
5
+ * Carbon's stylesheet is delivered exactly once, by the app shell (frontend RFC 0033). What separates
6
+ * a supported override from a page-wide one is the selector's anchor. `.myThing :global(.cds--btn)`
7
+ * scopes the override to this module and is how a visual fix through Carbon's classes gets made; a
8
+ * selector anchored on Carbon alone — `:global(.cds--btn)`, `[dir=rtl] :global(.cds--css-grid-column)`
9
+ * — reaches every Carbon component on the page, including other modules'. A selector with no class or
10
+ * id to anchor it at all, such as the `html, body, div { … }` of Carbon's reset, is broader still.
11
+ *
12
+ * These arrive by `@use`-ing `@carbon/styles`, one of its resets, or an individual component's
13
+ * stylesheet, rather than just Carbon's tokens, mixins, and functions, which emit no CSS.
14
+ *
15
+ * The scanner reads emitted, minified CSS rather than source, because that is what actually ships and
16
+ * because the many valid ways to import a Carbon token defeat checking imports at the source level.
17
+ */
18
+ /**
19
+ * The subset of a compilation this plugin needs. Declaring it structurally, rather than importing
20
+ * either bundler's types, is what lets one implementation serve both — and makes a bundler that stops
21
+ * supplying these a compile error here rather than a guard that silently sees nothing.
22
+ */
23
+ interface Compilation {
24
+ errors: Array<Error>;
25
+ hooks: {
26
+ processAssets: {
27
+ tap(options: {
28
+ name: string;
29
+ stage: number;
30
+ }, callback: (assets: Record<string, Source>) => void): void;
31
+ };
32
+ };
33
+ }
34
+ interface Source {
35
+ source(): string | Buffer;
36
+ }
37
+ interface Compiler {
38
+ /** Both bundlers expose their own namespace here; rspack sets `webpack` too, for compatibility. */
39
+ rspack?: {
40
+ Compilation: {
41
+ PROCESS_ASSETS_STAGE_REPORT: number;
42
+ };
43
+ };
44
+ webpack?: {
45
+ Compilation: {
46
+ PROCESS_ASSETS_STAGE_REPORT: number;
47
+ };
48
+ };
49
+ hooks: {
50
+ compilation: {
51
+ tap(name: string, callback: (compilation: Compilation) => void): void;
52
+ };
53
+ };
54
+ }
55
+ /**
56
+ * Returns the distinct selectors in `css` that restyle the page rather than the module's own markup.
57
+ *
58
+ * Only rules that aren't nested inside another style rule are considered: native CSS nesting puts the
59
+ * scoping class on the parent, so `.myThing { .cds--btn { … } }` is a correctly scoped override even
60
+ * though the inner selector reads as a bare Carbon one. Rules nested in an at-rule — `@media`,
61
+ * `@supports`, `@layer` — are considered, since those don't scope anything; those inside `@scope`,
62
+ * which does, are not.
63
+ *
64
+ * @param css The contents of an emitted stylesheet
65
+ */
66
+ export declare function findGlobalCarbonRules(css: string): Array<string>;
67
+ /**
68
+ * Builds the failure reported to the developer.
69
+ *
70
+ * @param appName The module being built, named as it appears in its `package.json`
71
+ * @param offences The offending selectors found, keyed by the asset each was found in
72
+ */
73
+ export declare function buildGlobalCarbonRuleError(appName: string, offences: Map<string, Array<string>>): Error;
74
+ /**
75
+ * Fails the build if any emitted stylesheet restyles the page as a whole. Runs at the reporting stage,
76
+ * late enough to see minified output, so it checks the CSS that actually ships.
77
+ *
78
+ * Only meaningful in a build that extracts CSS: under `style-loader` there are no `.css` assets to
79
+ * read, so the shared configs add this in production only.
80
+ */
81
+ export declare class CarbonCssGuardPlugin {
82
+ private readonly appName;
83
+ /**
84
+ * @param appName The module being built, named as it appears in its `package.json`
85
+ */
86
+ constructor(appName: string);
87
+ apply(compiler: Compiler): void;
88
+ }
89
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,303 @@
1
+ "use strict";
2
+ /**
3
+ * A webpack/rspack plugin that fails a build whose emitted CSS restyles the page as a whole rather
4
+ * than the module's own markup.
5
+ *
6
+ * Carbon's stylesheet is delivered exactly once, by the app shell (frontend RFC 0033). What separates
7
+ * a supported override from a page-wide one is the selector's anchor. `.myThing :global(.cds--btn)`
8
+ * scopes the override to this module and is how a visual fix through Carbon's classes gets made; a
9
+ * selector anchored on Carbon alone — `:global(.cds--btn)`, `[dir=rtl] :global(.cds--css-grid-column)`
10
+ * — reaches every Carbon component on the page, including other modules'. A selector with no class or
11
+ * id to anchor it at all, such as the `html, body, div { … }` of Carbon's reset, is broader still.
12
+ *
13
+ * These arrive by `@use`-ing `@carbon/styles`, one of its resets, or an individual component's
14
+ * stylesheet, rather than just Carbon's tokens, mixins, and functions, which emit no CSS.
15
+ *
16
+ * The scanner reads emitted, minified CSS rather than source, because that is what actually ships and
17
+ * because the many valid ways to import a Carbon token defeat checking imports at the source level.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.CarbonCssGuardPlugin = void 0;
21
+ exports.findGlobalCarbonRules = findGlobalCarbonRules;
22
+ exports.buildGlobalCarbonRuleError = buildGlobalCarbonRuleError;
23
+ /**
24
+ * What can confine a rule to markup this module owns: class and id tokens (`.cds--btn`, `#main`,
25
+ * `.a\\:b`), plus an attribute selector naming a specific extension or slot
26
+ * (`[data-extension-id='my-thing']`).
27
+ *
28
+ * Extension wrappers are rendered by the framework, so an app has nowhere to put a class of its own on
29
+ * them; the attribute's value is what makes the rule specific, exactly as a class would. It has to be a
30
+ * value test — a bare `[data-extension-id]` matches every extension on the page and anchors nothing.
31
+ */
32
+ const anchorPattern = /[.#](?:[-\w]|\\.)+|\[data-extension-[\w-]*[~^|*$]?=[^\]]*\]/g;
33
+ /**
34
+ * Carbon's class prefix, matched only where a selector starts with it. The shared configs run every
35
+ * stylesheet through CSS Modules, so a Carbon class reaches the page verbatim only when the module
36
+ * wrote it inside `:global`; anything else is renamed to `.-esm-login__footer__cds--btn___1a2b3` and
37
+ * can no longer restyle the shell's Carbon.
38
+ */
39
+ const carbonPrefix = '.cds--';
40
+ /**
41
+ * Exempt from the anchorless rule. `:root` and `:host` carry custom properties, which add to the
42
+ * cascade rather than restyling anything, and are how a module contributes its own design tokens.
43
+ */
44
+ const anchorlessExemptPattern = /^:(?:root|host)\b/;
45
+ /** Ceiling on the `:is(…)`/`:where(…)` cross product, so a pathological selector can't stall a build. */
46
+ const maxSelectorExpansions = 256;
47
+ /**
48
+ * Blanks out comments and string literals so that braces, semicolons, and class-like text inside them
49
+ * can't be mistaken for CSS syntax by the (deliberately hand-rolled) scanner below.
50
+ */
51
+ function stripNoise(css) {
52
+ let out = '';
53
+ let i = 0;
54
+ while (i < css.length) {
55
+ const char = css[i];
56
+ if (char === '/' && css[i + 1] === '*') {
57
+ const end = css.indexOf('*/', i + 2);
58
+ i = end === -1 ? css.length : end + 2;
59
+ out += ' ';
60
+ }
61
+ else if (char === '"' || char === "'") {
62
+ i += 1;
63
+ while (i < css.length && css[i] !== char) {
64
+ i += css[i] === '\\' ? 2 : 1;
65
+ }
66
+ i += 1;
67
+ out += '""';
68
+ }
69
+ else {
70
+ out += char;
71
+ i += 1;
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+ /** Splits a selector list on its top-level commas, leaving those nested in `:not(…)` & friends alone. */
77
+ function splitSelectorList(list) {
78
+ const selectors = [];
79
+ let depth = 0;
80
+ let start = 0;
81
+ for (let i = 0; i < list.length; i++) {
82
+ const char = list[i];
83
+ if (char === '(' || char === '[') {
84
+ depth += 1;
85
+ }
86
+ else if (char === ')' || char === ']') {
87
+ depth -= 1;
88
+ }
89
+ else if (char === ',' && depth === 0) {
90
+ selectors.push(list.slice(start, i));
91
+ start = i + 1;
92
+ }
93
+ }
94
+ selectors.push(list.slice(start));
95
+ return selectors;
96
+ }
97
+ /** The first `:is(…)` or `:where(…)` in `selector`, or `undefined` if it holds none. */
98
+ function findSelectorListGroup(selector) {
99
+ const match = /:(?:is|where)\(/.exec(selector);
100
+ if (!match) {
101
+ return undefined;
102
+ }
103
+ let depth = 0;
104
+ for (let i = match.index + match[0].length - 1; i < selector.length; i++) {
105
+ if (selector[i] === '(') {
106
+ depth += 1;
107
+ }
108
+ else if (selector[i] === ')' && (depth -= 1) === 0) {
109
+ return { start: match.index, end: i + 1, inner: selector.slice(match.index + match[0].length, i) };
110
+ }
111
+ }
112
+ return undefined;
113
+ }
114
+ /**
115
+ * Rewrites `:is(…)`/`:where(…)` into the plain selectors they stand for, so `.a:is(.b,.c)` becomes
116
+ * `.a.b` and `.a.c`.
117
+ *
118
+ * Without this a single anchored branch covers for the rest: `:is(.cds--btn, .myThing)` would read as
119
+ * anchored even though its first branch restyles every Carbon button on the page, while the equivalent
120
+ * `.cds--btn, .myThing` is caught. It matters for what gets built as much as for what gets written —
121
+ * Lightning CSS lowers native CSS nesting into `:is()` when the browserslist targets call for it, and
122
+ * this scanner reads minified output.
123
+ *
124
+ * The expansion is a cross product, so it is bounded. No selector in real stylesheets comes close; one
125
+ * that did would be left partly expanded and read as it is today, which is the behaviour this replaces.
126
+ */
127
+ function expandSelectorLists(selector, budget = { left: maxSelectorExpansions }) {
128
+ const group = findSelectorListGroup(selector);
129
+ if (!group || budget.left <= 0) {
130
+ return [selector];
131
+ }
132
+ const branches = splitSelectorList(group.inner);
133
+ budget.left -= branches.length;
134
+ return branches.flatMap((branch) => expandSelectorLists(selector.slice(0, group.start) + branch.trim() + selector.slice(group.end), budget));
135
+ }
136
+ /**
137
+ * Removes `:not(…)` groups. A class inside a negation narrows which elements the rule skips; it never
138
+ * confines the rule to this module's markup, so `.cds--btn:not(.myThing)` is still a page-wide rule.
139
+ */
140
+ function stripNegations(selector) {
141
+ let out = '';
142
+ for (let i = 0; i < selector.length; i++) {
143
+ if (!selector.startsWith(':not(', i)) {
144
+ out += selector[i];
145
+ continue;
146
+ }
147
+ let depth = 0;
148
+ for (i += 4; i < selector.length; i++) {
149
+ if (selector[i] === '(') {
150
+ depth += 1;
151
+ }
152
+ else if (selector[i] === ')' && (depth -= 1) === 0) {
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+ function classify(prelude) {
160
+ if (!prelude.startsWith('@')) {
161
+ return 'style';
162
+ }
163
+ if (/^@(?:-\w+-)?keyframes\b/.test(prelude)) {
164
+ return 'keyframes';
165
+ }
166
+ // `@scope (.card) { … }` confines everything inside it to the subtree its root selects, so its rules
167
+ // are local by definition however they read. The scoping root itself is not checked: an app that
168
+ // scopes to Carbon's own markup would still be reaching page-wide, which this does not catch.
169
+ return /^@scope\b/.test(prelude) ? 'scope' : 'at-rule';
170
+ }
171
+ /** Whether this single (comma-free) selector restyles the page rather than the module's own markup. */
172
+ function isGlobal(selector) {
173
+ var _a;
174
+ const trimmed = selector.trim();
175
+ if (trimmed.length === 0) {
176
+ return false;
177
+ }
178
+ const anchors = (_a = stripNegations(trimmed).match(anchorPattern)) !== null && _a !== void 0 ? _a : [];
179
+ return anchors.length > 0
180
+ ? anchors.every((anchor) => anchor.startsWith(carbonPrefix))
181
+ : !anchorlessExemptPattern.test(trimmed);
182
+ }
183
+ /**
184
+ * Returns the distinct selectors in `css` that restyle the page rather than the module's own markup.
185
+ *
186
+ * Only rules that aren't nested inside another style rule are considered: native CSS nesting puts the
187
+ * scoping class on the parent, so `.myThing { .cds--btn { … } }` is a correctly scoped override even
188
+ * though the inner selector reads as a bare Carbon one. Rules nested in an at-rule — `@media`,
189
+ * `@supports`, `@layer` — are considered, since those don't scope anything; those inside `@scope`,
190
+ * which does, are not.
191
+ *
192
+ * @param css The contents of an emitted stylesheet
193
+ */
194
+ function findGlobalCarbonRules(css) {
195
+ const globals = new Set();
196
+ const blocks = [];
197
+ let prelude = '';
198
+ for (const char of stripNoise(css)) {
199
+ if (char === '{') {
200
+ const kind = classify(prelude.trim());
201
+ if (kind === 'style' && !blocks.some((block) => block !== 'at-rule')) {
202
+ for (const selector of splitSelectorList(prelude)) {
203
+ // Reported as written, not as expanded, so the message names something findable in the source.
204
+ if (expandSelectorLists(selector).some(isGlobal)) {
205
+ globals.add(selector.trim());
206
+ }
207
+ }
208
+ }
209
+ blocks.push(kind);
210
+ prelude = '';
211
+ }
212
+ else if (char === '}') {
213
+ blocks.pop();
214
+ prelude = '';
215
+ }
216
+ else if (char === ';') {
217
+ // A declaration, or a statement at-rule such as `@import`; neither introduces a selector.
218
+ prelude = '';
219
+ }
220
+ else {
221
+ prelude += char;
222
+ }
223
+ }
224
+ return [...globals];
225
+ }
226
+ const pluginName = 'CarbonCssGuardPlugin';
227
+ const maxReportedSelectors = 10;
228
+ /**
229
+ * Builds the failure reported to the developer.
230
+ *
231
+ * @param appName The module being built, named as it appears in its `package.json`
232
+ * @param offences The offending selectors found, keyed by the asset each was found in
233
+ */
234
+ function buildGlobalCarbonRuleError(appName, offences) {
235
+ const total = [...offences.values()].reduce((count, selectors) => count + selectors.length, 0);
236
+ const detail = [...offences]
237
+ .map(([asset, selectors]) => {
238
+ const shown = selectors.slice(0, maxReportedSelectors).map((selector) => ` ${selector}`);
239
+ const rest = selectors.length - shown.length;
240
+ return [` ${asset}`, ...shown, ...(rest > 0 ? [` …and ${rest} more`] : [])].join('\n');
241
+ })
242
+ .join('\n');
243
+ // The two cases have nothing to do with each other, and pointing someone who wrote `body { margin: 0 }`
244
+ // at their Carbon imports sends them looking in the wrong place entirely.
245
+ const selectors = [...offences.values()].flat();
246
+ const remedy = [
247
+ selectors.some((selector) => selector.includes(carbonPrefix))
248
+ ? 'Selectors naming Carbon classes usually come from SCSS that `@use`s `@carbon/styles`, one of its ' +
249
+ 'resets, or an individual component stylesheet, instead of only its tokens, mixins, and functions, ' +
250
+ "which emit no CSS. To override Carbon from this module, anchor the selector to one of the module's " +
251
+ 'own classes — `.myThing :global(.cds--btn)` rather than `:global(.cds--btn)`.'
252
+ : undefined,
253
+ selectors.some((selector) => !selector.includes(carbonPrefix))
254
+ ? 'Selectors with no class or id of their own — element, universal, or attribute selectors — apply to ' +
255
+ 'the whole page, including other modules. Scope them to this module, e.g. `.myThing p` rather than ' +
256
+ '`p`, or move them to the app shell if they really are meant to be global.'
257
+ : undefined,
258
+ ].filter(Boolean);
259
+ return new Error(`${appName} emits ${total} global CSS rule(s). Carbon's CSS is delivered once, by the app shell, so ` +
260
+ `frontend modules must not restyle the page as a whole.\n\n${detail}\n\n${remedy.join('\n\n')}`);
261
+ }
262
+ /**
263
+ * Fails the build if any emitted stylesheet restyles the page as a whole. Runs at the reporting stage,
264
+ * late enough to see minified output, so it checks the CSS that actually ships.
265
+ *
266
+ * Only meaningful in a build that extracts CSS: under `style-loader` there are no `.css` assets to
267
+ * read, so the shared configs add this in production only.
268
+ */
269
+ class CarbonCssGuardPlugin {
270
+ /**
271
+ * @param appName The module being built, named as it appears in its `package.json`
272
+ */
273
+ constructor(appName) {
274
+ this.appName = appName;
275
+ }
276
+ apply(compiler) {
277
+ var _a, _b, _c;
278
+ // Read off the compiler rather than imported, so this package needs a dependency on neither bundler.
279
+ const stage = (_c = (_b = ((_a = compiler.rspack) !== null && _a !== void 0 ? _a : compiler.webpack)) === null || _b === void 0 ? void 0 : _b.Compilation) === null || _c === void 0 ? void 0 : _c.PROCESS_ASSETS_STAGE_REPORT;
280
+ if (stage === undefined) {
281
+ throw new Error(`${pluginName} could not determine the asset-processing stage to run at: the compiler exposes ` +
282
+ 'neither `rspack` nor `webpack`. Refusing to register, rather than silently checking nothing.');
283
+ }
284
+ compiler.hooks.compilation.tap(pluginName, (compilation) => {
285
+ compilation.hooks.processAssets.tap({ name: pluginName, stage }, (assets) => {
286
+ const offences = new Map();
287
+ for (const [name, source] of Object.entries(assets)) {
288
+ if (!name.endsWith('.css')) {
289
+ continue;
290
+ }
291
+ const globals = findGlobalCarbonRules(source.source().toString());
292
+ if (globals.length > 0) {
293
+ offences.set(name, globals);
294
+ }
295
+ }
296
+ if (offences.size > 0) {
297
+ compilation.errors.push(buildGlobalCarbonRuleError(this.appName, offences));
298
+ }
299
+ });
300
+ });
301
+ }
302
+ }
303
+ exports.CarbonCssGuardPlugin = CarbonCssGuardPlugin;
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@openmrs/carbon-css-guard",
3
+ "version": "10.0.1-pre.5378",
4
+ "license": "MPL-2.0",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "engines": {
8
+ "node": ">= 20.0.0"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "build:development": "tsc",
13
+ "clean": "git clean -fdX -- dist",
14
+ "lint": "eslint src",
15
+ "test": "cross-env TZ=UTC vitest run",
16
+ "test:watch": "cross-env TZ=UTC vitest watch --passWithNoTests",
17
+ "coverage": "cross-env TZ=UTC vitest run --coverage",
18
+ "typescript": "tsc --noEmit --project tsconfig.test.json"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/openmrs/openmrs-esm-core.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/openmrs/openmrs-esm-core/issues"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "keywords": [
31
+ "openmrs",
32
+ "microfrontends",
33
+ "webpack",
34
+ "rspack",
35
+ "config"
36
+ ],
37
+ "homepage": "https://github.com/openmrs/openmrs-esm-core#readme",
38
+ "devDependencies": {
39
+ "cross-env": "^10.1.0",
40
+ "typescript": "^5.8.3",
41
+ "vitest": "^4.1.2"
42
+ },
43
+ "stableVersion": "10.0.0"
44
+ }
@@ -0,0 +1,254 @@
1
+ // The Carbon CSS guard fails a module's production build when its emitted stylesheets restyle the page
2
+ // as a whole (RFC 0033). It reads minified CSS with a hand-rolled scanner, and it is a hard build error
3
+ // for every module in the ecosystem, so a false positive is as costly as a miss.
4
+ import { describe, expect, it } from 'vitest';
5
+ import { buildGlobalCarbonRuleError, CarbonCssGuardPlugin, findGlobalCarbonRules } from './index';
6
+
7
+ // A module's own classes always reach the emitted CSS in this shape, since both configs set
8
+ // `localIdentName` to `${ident}__[name]__[local]___[hash:base64:5]`.
9
+ const scoped = '.-esm-login__login__inputGroup___VWJgx';
10
+
11
+ const cases: Array<{ label: string; css: string; expected: Array<string> }> = [
12
+ // Nesting: the scoping class sits on the parent, so the inner selector reads bare but is not global.
13
+ // Sass flattens this away, but a module can ship a plain `.css` file that uses native nesting.
14
+ { label: 'nested override, declarations first', css: `${scoped}{color:red;.cds--btn{color:blue}}`, expected: [] },
15
+ { label: 'nested override, no declarations', css: `${scoped}{.cds--btn{color:blue}}`, expected: [] },
16
+ { label: 'nested override using &', css: `${scoped}{color:red;& .cds--btn{color:blue}}`, expected: [] },
17
+ { label: 'nested override after url(#id)', css: `${scoped}{fill:url(#g);.cds--btn{color:red}}`, expected: [] },
18
+ { label: 'nested reset inside an override', css: `${scoped}{color:red;div{margin:0}}`, expected: [] },
19
+
20
+ // At-rules scope nothing, so a global rule inside one is still global.
21
+ { label: 'global inside @media', css: '@media (min-width:1px){.cds--btn{color:red}}', expected: ['.cds--btn'] },
22
+ { label: 'global inside @supports', css: '@supports (a:b){.cds--btn{color:red}}', expected: ['.cds--btn'] },
23
+ { label: 'global inside @layer', css: '@layer overrides{.cds--btn{color:red}}', expected: ['.cds--btn'] },
24
+ {
25
+ label: 'scoped override inside @media',
26
+ css: `@media (min-width:1px){${scoped} .cds--btn{color:red}}`,
27
+ expected: [],
28
+ },
29
+
30
+ // Carbon-anchored selectors.
31
+ { label: 'bare Carbon class', css: '.cds--btn{color:red}', expected: ['.cds--btn'] },
32
+ {
33
+ label: 'Carbon-only descendant chain',
34
+ css: '.cds--modal .cds--btn--primary{color:red}',
35
+ expected: ['.cds--modal .cds--btn--primary'],
36
+ },
37
+ {
38
+ label: ':not() narrows but does not scope',
39
+ css: '.cds--btn:not(.myLocal___a1){color:red}',
40
+ expected: ['.cds--btn:not(.myLocal___a1)'],
41
+ },
42
+ { label: 'module-scoped Carbon override', css: `${scoped} .cds--btn{color:red}`, expected: [] },
43
+ { label: 'id-scoped Carbon override', css: '#myAppRoot .cds--btn{color:red}', expected: [] },
44
+ {
45
+ label: 'a non-Carbon class anchors the override',
46
+ css: '.omrs-breakpoint-gt-tablet .cds--side-nav__link{color:red}',
47
+ expected: [],
48
+ },
49
+ {
50
+ label: 'css-loader-scoped Carbon classes are inert, not global',
51
+ css: '.-esm-login__datepicker-module__cds--layout--size-xs___rHkV0{block-size:1rem}',
52
+ expected: [],
53
+ },
54
+
55
+ // Selectors with nothing to anchor them, such as Carbon's reset.
56
+ { label: 'element-selector reset', css: 'html,body,div{margin:0}', expected: ['html', 'body', 'div'] },
57
+ { label: 'universal reset', css: '*{box-sizing:border-box}', expected: ['*'] },
58
+ { label: 'attribute-only selector', css: '[dir=rtl]{text-align:right}', expected: ['[dir=rtl]'] },
59
+ { label: ':root carries custom properties', css: ':root{--omrs-x:1}', expected: [] },
60
+
61
+ // `:is()`/`:where()` hold selector lists, so one anchored branch must not cover for an unanchored one.
62
+ // Lightning CSS also lowers native nesting into `:is()`, so these turn up in output as well as source.
63
+ {
64
+ label: ':is() with one page-wide branch',
65
+ css: `:is(.cds--btn,${scoped}){color:red}`,
66
+ expected: [`:is(.cds--btn,${scoped})`],
67
+ },
68
+ {
69
+ label: ':where() with one page-wide branch',
70
+ css: `:where(.cds--btn,${scoped}){color:red}`,
71
+ expected: [`:where(.cds--btn,${scoped})`],
72
+ },
73
+ {
74
+ label: ':is() with an anchorless branch',
75
+ css: `:is(body,${scoped}){margin:0}`,
76
+ expected: [`:is(body,${scoped})`],
77
+ },
78
+ {
79
+ label: ':is() of Carbon classes only',
80
+ css: ':is(.cds--btn,.cds--tag){color:red}',
81
+ expected: [':is(.cds--btn,.cds--tag)'],
82
+ },
83
+ { label: ':is() with every branch anchored', css: `${scoped} :is(.a___a1,.b___b2){color:red}`, expected: [] },
84
+ {
85
+ label: ':is() of pseudo-classes anchors nothing away',
86
+ css: `${scoped}:is(:hover,:focus){color:red}`,
87
+ expected: [],
88
+ },
89
+ { label: 'a scoped :is() of Carbon classes', css: `${scoped} :is(.cds--btn,.cds--tag){color:red}`, expected: [] },
90
+
91
+ // `@scope` confines its contents to a subtree, so rules inside it are local however they read.
92
+ { label: '@scope makes a bare Carbon rule local', css: `@scope (${scoped}){.cds--btn{color:red}}`, expected: [] },
93
+ { label: '@scope with a limit', css: `@scope (${scoped}) to (.inner___b2){img{border:0}}`, expected: [] },
94
+ { label: '@scope makes a bare element rule local', css: `@scope (${scoped}){p{margin:0}}`, expected: [] },
95
+ {
96
+ label: 'a rule after a @scope block is judged normally',
97
+ css: `@scope (${scoped}){p{margin:0}}body{margin:0}`,
98
+ expected: ['body'],
99
+ },
100
+
101
+ // Extension wrappers are the framework's markup, so an app has no class of its own to hang on them.
102
+ // A named extension or slot is as specific as a class; a valueless one matches every extension there is.
103
+ {
104
+ label: 'a named extension wrapper anchors the rule',
105
+ css: "[data-extension-id='sticky-notes-button']:empty{display:none}",
106
+ expected: [],
107
+ },
108
+ {
109
+ label: 'a named extension wrapper anchors a Carbon override',
110
+ css: "[data-extension-slot-name='my-slot'] .cds--btn{min-width:7rem}",
111
+ expected: [],
112
+ },
113
+ {
114
+ label: 'quotes stripped by minification still anchor',
115
+ css: '[data-extension-id=clinical-views-summary]{display:block}',
116
+ expected: [],
117
+ },
118
+ {
119
+ label: 'a valueless extension attribute anchors nothing',
120
+ css: '[data-extension-id]{display:block}',
121
+ expected: ['[data-extension-id]'],
122
+ },
123
+ {
124
+ label: 'a non-extension attribute still anchors nothing',
125
+ css: 'html[dir=rtl] .cds--side-nav{margin:0}',
126
+ expected: ['html[dir=rtl] .cds--side-nav'],
127
+ },
128
+ { label: ':host carries custom properties', css: ':host{--omrs-x:1}', expected: [] },
129
+
130
+ // Keyframe steps read like element selectors but select nothing.
131
+ {
132
+ label: 'keyframe steps',
133
+ css: '@keyframes spin{from{opacity:0}50%{opacity:.5}to{transform:rotate(1turn)}}',
134
+ expected: [],
135
+ },
136
+ {
137
+ label: 'vendor-prefixed keyframe steps',
138
+ css: '@-webkit-keyframes spin{to{opacity:1}}',
139
+ expected: [],
140
+ },
141
+
142
+ // Lexical hazards in minified output.
143
+ {
144
+ label: 'braces and semicolons inside a string',
145
+ css: `${scoped}{content:"}{;"}.cds--btn{color:red}`,
146
+ expected: ['.cds--btn'],
147
+ },
148
+ { label: 'statement at-rule', css: '@import url(x);.cds--btn{color:red}', expected: ['.cds--btn'] },
149
+ { label: 'comment between rules', css: '/* }{ .cds--x */.cds--btn{color:red}', expected: ['.cds--btn'] },
150
+ { label: '@font-face declarations', css: '@font-face{font-family:x;src:url(y)}', expected: [] },
151
+ { label: 'empty stylesheet', css: '', expected: [] },
152
+ ];
153
+
154
+ describe('the Carbon CSS guard', () => {
155
+ it.each(cases)('reports $label', ({ css, expected }) => {
156
+ expect(findGlobalCarbonRules(css)).toEqual(expected);
157
+ });
158
+
159
+ // Without this, a change that makes the scanner return nothing at all would leave every case above
160
+ // passing on the empty expectations, and the guard would become a silent no-op.
161
+ it('still fires on the case the guard exists for', () => {
162
+ const wholesaleCarbon = Array.from({ length: 50 }, (_, i) => `.cds--component-${i}{color:red}`).join('');
163
+
164
+ expect(findGlobalCarbonRules(wholesaleCarbon)).toHaveLength(50);
165
+ });
166
+
167
+ // The two ways to fail this guard have unrelated causes, so the advice can't be one-size-fits-all.
168
+ describe('the error it raises', () => {
169
+ const carbonAdvice = /`@use`s `@carbon\/styles`/;
170
+ const anchorlessAdvice = /no class or id of their own/;
171
+
172
+ it('explains Carbon imports when the offending selectors name Carbon classes', () => {
173
+ const message = buildGlobalCarbonRuleError('app', new Map([['a.css', ['.cds--btn']]])).message;
174
+
175
+ expect(message).toMatch(carbonAdvice);
176
+ expect(message).not.toMatch(anchorlessAdvice);
177
+ });
178
+
179
+ it('explains scoping when the offending selectors have no anchor of their own', () => {
180
+ const message = buildGlobalCarbonRuleError('app', new Map([['a.css', ['body']]])).message;
181
+
182
+ expect(message).toMatch(anchorlessAdvice);
183
+ expect(message).not.toMatch(carbonAdvice);
184
+ });
185
+
186
+ it('explains both when both occur', () => {
187
+ const message = buildGlobalCarbonRuleError('app', new Map([['a.css', ['.cds--btn', 'body']]])).message;
188
+
189
+ expect(message).toMatch(carbonAdvice);
190
+ expect(message).toMatch(anchorlessAdvice);
191
+ });
192
+ });
193
+
194
+ // The scanner is covered above; this is the wiring around it. The integration tests exercise the same
195
+ // path against today's bundlers, so what's worth unit-testing here is what they can't reach: the asset
196
+ // filter, and the refusal to register against a compiler exposing neither bundler's namespace.
197
+ describe('the plugin', () => {
198
+ function fakeCompiler(namespace: 'rspack' | 'webpack' | 'neither', assets: Record<string, string>) {
199
+ const compilation = {
200
+ errors: [] as Array<Error>,
201
+ hooks: {
202
+ processAssets: {
203
+ tap: (_options: unknown, callback: (a: Record<string, { source(): string }>) => void) =>
204
+ callback(Object.fromEntries(Object.entries(assets).map(([name, css]) => [name, { source: () => css }]))),
205
+ },
206
+ },
207
+ };
208
+ const bundler = { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } };
209
+
210
+ return {
211
+ compilation,
212
+ compiler: {
213
+ ...(namespace === 'neither' ? {} : { [namespace]: bundler }),
214
+ hooks: { compilation: { tap: (_name: string, cb: (c: typeof compilation) => void) => cb(compilation) } },
215
+ },
216
+ };
217
+ }
218
+
219
+ it.each(['rspack', 'webpack'] as const)('reports offences found in a %s build', (namespace) => {
220
+ const { compiler, compilation } = fakeCompiler(namespace, { 'a.css': '.cds--btn{color:red}' });
221
+
222
+ new CarbonCssGuardPlugin('@openmrs/esm-x-app').apply(compiler as never);
223
+
224
+ expect(compilation.errors).toHaveLength(1);
225
+ expect(compilation.errors[0].message).toContain('.cds--btn');
226
+ expect(compilation.errors[0].message).toContain('@openmrs/esm-x-app');
227
+ });
228
+
229
+ it('reads stylesheets only, not the JavaScript or source maps that carry the same text', () => {
230
+ const { compiler, compilation } = fakeCompiler('rspack', {
231
+ 'a.js': '.cds--btn{color:red}',
232
+ 'a.css.map': '.cds--btn{color:red}',
233
+ });
234
+
235
+ new CarbonCssGuardPlugin('app').apply(compiler as never);
236
+
237
+ expect(compilation.errors).toEqual([]);
238
+ });
239
+
240
+ it('stays quiet when nothing is global', () => {
241
+ const { compiler, compilation } = fakeCompiler('rspack', { 'a.css': `${scoped} .cds--btn{color:red}` });
242
+
243
+ new CarbonCssGuardPlugin('app').apply(compiler as never);
244
+
245
+ expect(compilation.errors).toEqual([]);
246
+ });
247
+
248
+ it('refuses to register against a compiler exposing neither bundler, rather than checking nothing', () => {
249
+ const { compiler } = fakeCompiler('neither', { 'a.css': '.cds--btn{color:red}' });
250
+
251
+ expect(() => new CarbonCssGuardPlugin('app').apply(compiler as never)).toThrow(/neither `rspack` nor `webpack`/);
252
+ });
253
+ });
254
+ });
package/src/index.ts ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * A webpack/rspack plugin that fails a build whose emitted CSS restyles the page as a whole rather
3
+ * than the module's own markup.
4
+ *
5
+ * Carbon's stylesheet is delivered exactly once, by the app shell (frontend RFC 0033). What separates
6
+ * a supported override from a page-wide one is the selector's anchor. `.myThing :global(.cds--btn)`
7
+ * scopes the override to this module and is how a visual fix through Carbon's classes gets made; a
8
+ * selector anchored on Carbon alone — `:global(.cds--btn)`, `[dir=rtl] :global(.cds--css-grid-column)`
9
+ * — reaches every Carbon component on the page, including other modules'. A selector with no class or
10
+ * id to anchor it at all, such as the `html, body, div { … }` of Carbon's reset, is broader still.
11
+ *
12
+ * These arrive by `@use`-ing `@carbon/styles`, one of its resets, or an individual component's
13
+ * stylesheet, rather than just Carbon's tokens, mixins, and functions, which emit no CSS.
14
+ *
15
+ * The scanner reads emitted, minified CSS rather than source, because that is what actually ships and
16
+ * because the many valid ways to import a Carbon token defeat checking imports at the source level.
17
+ */
18
+
19
+ /**
20
+ * What can confine a rule to markup this module owns: class and id tokens (`.cds--btn`, `#main`,
21
+ * `.a\\:b`), plus an attribute selector naming a specific extension or slot
22
+ * (`[data-extension-id='my-thing']`).
23
+ *
24
+ * Extension wrappers are rendered by the framework, so an app has nowhere to put a class of its own on
25
+ * them; the attribute's value is what makes the rule specific, exactly as a class would. It has to be a
26
+ * value test — a bare `[data-extension-id]` matches every extension on the page and anchors nothing.
27
+ */
28
+ const anchorPattern = /[.#](?:[-\w]|\\.)+|\[data-extension-[\w-]*[~^|*$]?=[^\]]*\]/g;
29
+
30
+ /**
31
+ * Carbon's class prefix, matched only where a selector starts with it. The shared configs run every
32
+ * stylesheet through CSS Modules, so a Carbon class reaches the page verbatim only when the module
33
+ * wrote it inside `:global`; anything else is renamed to `.-esm-login__footer__cds--btn___1a2b3` and
34
+ * can no longer restyle the shell's Carbon.
35
+ */
36
+ const carbonPrefix = '.cds--';
37
+
38
+ /**
39
+ * Exempt from the anchorless rule. `:root` and `:host` carry custom properties, which add to the
40
+ * cascade rather than restyling anything, and are how a module contributes its own design tokens.
41
+ */
42
+ const anchorlessExemptPattern = /^:(?:root|host)\b/;
43
+
44
+ /** Ceiling on the `:is(…)`/`:where(…)` cross product, so a pathological selector can't stall a build. */
45
+ const maxSelectorExpansions = 256;
46
+
47
+ type BlockKind = 'style' | 'at-rule' | 'keyframes' | 'scope';
48
+
49
+ /**
50
+ * The subset of a compilation this plugin needs. Declaring it structurally, rather than importing
51
+ * either bundler's types, is what lets one implementation serve both — and makes a bundler that stops
52
+ * supplying these a compile error here rather than a guard that silently sees nothing.
53
+ */
54
+ interface Compilation {
55
+ errors: Array<Error>;
56
+ hooks: {
57
+ processAssets: {
58
+ tap(options: { name: string; stage: number }, callback: (assets: Record<string, Source>) => void): void;
59
+ };
60
+ };
61
+ }
62
+
63
+ interface Source {
64
+ source(): string | Buffer;
65
+ }
66
+
67
+ interface Compiler {
68
+ /** Both bundlers expose their own namespace here; rspack sets `webpack` too, for compatibility. */
69
+ rspack?: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: number } };
70
+ webpack?: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: number } };
71
+ hooks: {
72
+ compilation: { tap(name: string, callback: (compilation: Compilation) => void): void };
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Blanks out comments and string literals so that braces, semicolons, and class-like text inside them
78
+ * can't be mistaken for CSS syntax by the (deliberately hand-rolled) scanner below.
79
+ */
80
+ function stripNoise(css: string): string {
81
+ let out = '';
82
+ let i = 0;
83
+
84
+ while (i < css.length) {
85
+ const char = css[i];
86
+
87
+ if (char === '/' && css[i + 1] === '*') {
88
+ const end = css.indexOf('*/', i + 2);
89
+ i = end === -1 ? css.length : end + 2;
90
+ out += ' ';
91
+ } else if (char === '"' || char === "'") {
92
+ i += 1;
93
+ while (i < css.length && css[i] !== char) {
94
+ i += css[i] === '\\' ? 2 : 1;
95
+ }
96
+ i += 1;
97
+ out += '""';
98
+ } else {
99
+ out += char;
100
+ i += 1;
101
+ }
102
+ }
103
+
104
+ return out;
105
+ }
106
+
107
+ /** Splits a selector list on its top-level commas, leaving those nested in `:not(…)` & friends alone. */
108
+ function splitSelectorList(list: string): Array<string> {
109
+ const selectors: Array<string> = [];
110
+ let depth = 0;
111
+ let start = 0;
112
+
113
+ for (let i = 0; i < list.length; i++) {
114
+ const char = list[i];
115
+
116
+ if (char === '(' || char === '[') {
117
+ depth += 1;
118
+ } else if (char === ')' || char === ']') {
119
+ depth -= 1;
120
+ } else if (char === ',' && depth === 0) {
121
+ selectors.push(list.slice(start, i));
122
+ start = i + 1;
123
+ }
124
+ }
125
+
126
+ selectors.push(list.slice(start));
127
+ return selectors;
128
+ }
129
+
130
+ /** The first `:is(…)` or `:where(…)` in `selector`, or `undefined` if it holds none. */
131
+ function findSelectorListGroup(selector: string) {
132
+ const match = /:(?:is|where)\(/.exec(selector);
133
+
134
+ if (!match) {
135
+ return undefined;
136
+ }
137
+
138
+ let depth = 0;
139
+
140
+ for (let i = match.index + match[0].length - 1; i < selector.length; i++) {
141
+ if (selector[i] === '(') {
142
+ depth += 1;
143
+ } else if (selector[i] === ')' && (depth -= 1) === 0) {
144
+ return { start: match.index, end: i + 1, inner: selector.slice(match.index + match[0].length, i) };
145
+ }
146
+ }
147
+
148
+ return undefined;
149
+ }
150
+
151
+ /**
152
+ * Rewrites `:is(…)`/`:where(…)` into the plain selectors they stand for, so `.a:is(.b,.c)` becomes
153
+ * `.a.b` and `.a.c`.
154
+ *
155
+ * Without this a single anchored branch covers for the rest: `:is(.cds--btn, .myThing)` would read as
156
+ * anchored even though its first branch restyles every Carbon button on the page, while the equivalent
157
+ * `.cds--btn, .myThing` is caught. It matters for what gets built as much as for what gets written —
158
+ * Lightning CSS lowers native CSS nesting into `:is()` when the browserslist targets call for it, and
159
+ * this scanner reads minified output.
160
+ *
161
+ * The expansion is a cross product, so it is bounded. No selector in real stylesheets comes close; one
162
+ * that did would be left partly expanded and read as it is today, which is the behaviour this replaces.
163
+ */
164
+ function expandSelectorLists(selector: string, budget = { left: maxSelectorExpansions }): Array<string> {
165
+ const group = findSelectorListGroup(selector);
166
+
167
+ if (!group || budget.left <= 0) {
168
+ return [selector];
169
+ }
170
+
171
+ const branches = splitSelectorList(group.inner);
172
+ budget.left -= branches.length;
173
+
174
+ return branches.flatMap((branch) =>
175
+ expandSelectorLists(selector.slice(0, group.start) + branch.trim() + selector.slice(group.end), budget),
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Removes `:not(…)` groups. A class inside a negation narrows which elements the rule skips; it never
181
+ * confines the rule to this module's markup, so `.cds--btn:not(.myThing)` is still a page-wide rule.
182
+ */
183
+ function stripNegations(selector: string): string {
184
+ let out = '';
185
+
186
+ for (let i = 0; i < selector.length; i++) {
187
+ if (!selector.startsWith(':not(', i)) {
188
+ out += selector[i];
189
+ continue;
190
+ }
191
+
192
+ let depth = 0;
193
+
194
+ for (i += 4; i < selector.length; i++) {
195
+ if (selector[i] === '(') {
196
+ depth += 1;
197
+ } else if (selector[i] === ')' && (depth -= 1) === 0) {
198
+ break;
199
+ }
200
+ }
201
+ }
202
+
203
+ return out;
204
+ }
205
+
206
+ function classify(prelude: string): BlockKind {
207
+ if (!prelude.startsWith('@')) {
208
+ return 'style';
209
+ }
210
+
211
+ if (/^@(?:-\w+-)?keyframes\b/.test(prelude)) {
212
+ return 'keyframes';
213
+ }
214
+
215
+ // `@scope (.card) { … }` confines everything inside it to the subtree its root selects, so its rules
216
+ // are local by definition however they read. The scoping root itself is not checked: an app that
217
+ // scopes to Carbon's own markup would still be reaching page-wide, which this does not catch.
218
+ return /^@scope\b/.test(prelude) ? 'scope' : 'at-rule';
219
+ }
220
+
221
+ /** Whether this single (comma-free) selector restyles the page rather than the module's own markup. */
222
+ function isGlobal(selector: string): boolean {
223
+ const trimmed = selector.trim();
224
+
225
+ if (trimmed.length === 0) {
226
+ return false;
227
+ }
228
+
229
+ const anchors = stripNegations(trimmed).match(anchorPattern) ?? [];
230
+
231
+ return anchors.length > 0
232
+ ? anchors.every((anchor) => anchor.startsWith(carbonPrefix))
233
+ : !anchorlessExemptPattern.test(trimmed);
234
+ }
235
+
236
+ /**
237
+ * Returns the distinct selectors in `css` that restyle the page rather than the module's own markup.
238
+ *
239
+ * Only rules that aren't nested inside another style rule are considered: native CSS nesting puts the
240
+ * scoping class on the parent, so `.myThing { .cds--btn { … } }` is a correctly scoped override even
241
+ * though the inner selector reads as a bare Carbon one. Rules nested in an at-rule — `@media`,
242
+ * `@supports`, `@layer` — are considered, since those don't scope anything; those inside `@scope`,
243
+ * which does, are not.
244
+ *
245
+ * @param css The contents of an emitted stylesheet
246
+ */
247
+ export function findGlobalCarbonRules(css: string): Array<string> {
248
+ const globals = new Set<string>();
249
+ const blocks: Array<BlockKind> = [];
250
+ let prelude = '';
251
+
252
+ for (const char of stripNoise(css)) {
253
+ if (char === '{') {
254
+ const kind = classify(prelude.trim());
255
+
256
+ if (kind === 'style' && !blocks.some((block) => block !== 'at-rule')) {
257
+ for (const selector of splitSelectorList(prelude)) {
258
+ // Reported as written, not as expanded, so the message names something findable in the source.
259
+ if (expandSelectorLists(selector).some(isGlobal)) {
260
+ globals.add(selector.trim());
261
+ }
262
+ }
263
+ }
264
+
265
+ blocks.push(kind);
266
+ prelude = '';
267
+ } else if (char === '}') {
268
+ blocks.pop();
269
+ prelude = '';
270
+ } else if (char === ';') {
271
+ // A declaration, or a statement at-rule such as `@import`; neither introduces a selector.
272
+ prelude = '';
273
+ } else {
274
+ prelude += char;
275
+ }
276
+ }
277
+
278
+ return [...globals];
279
+ }
280
+
281
+ const pluginName = 'CarbonCssGuardPlugin';
282
+ const maxReportedSelectors = 10;
283
+
284
+ /**
285
+ * Builds the failure reported to the developer.
286
+ *
287
+ * @param appName The module being built, named as it appears in its `package.json`
288
+ * @param offences The offending selectors found, keyed by the asset each was found in
289
+ */
290
+ export function buildGlobalCarbonRuleError(appName: string, offences: Map<string, Array<string>>): Error {
291
+ const total = [...offences.values()].reduce((count, selectors) => count + selectors.length, 0);
292
+ const detail = [...offences]
293
+ .map(([asset, selectors]) => {
294
+ const shown = selectors.slice(0, maxReportedSelectors).map((selector) => ` ${selector}`);
295
+ const rest = selectors.length - shown.length;
296
+ return [` ${asset}`, ...shown, ...(rest > 0 ? [` …and ${rest} more`] : [])].join('\n');
297
+ })
298
+ .join('\n');
299
+
300
+ // The two cases have nothing to do with each other, and pointing someone who wrote `body { margin: 0 }`
301
+ // at their Carbon imports sends them looking in the wrong place entirely.
302
+ const selectors = [...offences.values()].flat();
303
+ const remedy = [
304
+ selectors.some((selector) => selector.includes(carbonPrefix))
305
+ ? 'Selectors naming Carbon classes usually come from SCSS that `@use`s `@carbon/styles`, one of its ' +
306
+ 'resets, or an individual component stylesheet, instead of only its tokens, mixins, and functions, ' +
307
+ "which emit no CSS. To override Carbon from this module, anchor the selector to one of the module's " +
308
+ 'own classes — `.myThing :global(.cds--btn)` rather than `:global(.cds--btn)`.'
309
+ : undefined,
310
+ selectors.some((selector) => !selector.includes(carbonPrefix))
311
+ ? 'Selectors with no class or id of their own — element, universal, or attribute selectors — apply to ' +
312
+ 'the whole page, including other modules. Scope them to this module, e.g. `.myThing p` rather than ' +
313
+ '`p`, or move them to the app shell if they really are meant to be global.'
314
+ : undefined,
315
+ ].filter(Boolean);
316
+
317
+ return new Error(
318
+ `${appName} emits ${total} global CSS rule(s). Carbon's CSS is delivered once, by the app shell, so ` +
319
+ `frontend modules must not restyle the page as a whole.\n\n${detail}\n\n${remedy.join('\n\n')}`,
320
+ );
321
+ }
322
+
323
+ /**
324
+ * Fails the build if any emitted stylesheet restyles the page as a whole. Runs at the reporting stage,
325
+ * late enough to see minified output, so it checks the CSS that actually ships.
326
+ *
327
+ * Only meaningful in a build that extracts CSS: under `style-loader` there are no `.css` assets to
328
+ * read, so the shared configs add this in production only.
329
+ */
330
+ export class CarbonCssGuardPlugin {
331
+ /**
332
+ * @param appName The module being built, named as it appears in its `package.json`
333
+ */
334
+ constructor(private readonly appName: string) {}
335
+
336
+ apply(compiler: Compiler) {
337
+ // Read off the compiler rather than imported, so this package needs a dependency on neither bundler.
338
+ const stage = (compiler.rspack ?? compiler.webpack)?.Compilation?.PROCESS_ASSETS_STAGE_REPORT;
339
+
340
+ if (stage === undefined) {
341
+ throw new Error(
342
+ `${pluginName} could not determine the asset-processing stage to run at: the compiler exposes ` +
343
+ 'neither `rspack` nor `webpack`. Refusing to register, rather than silently checking nothing.',
344
+ );
345
+ }
346
+
347
+ compiler.hooks.compilation.tap(pluginName, (compilation) => {
348
+ compilation.hooks.processAssets.tap({ name: pluginName, stage }, (assets) => {
349
+ const offences = new Map<string, Array<string>>();
350
+
351
+ for (const [name, source] of Object.entries(assets)) {
352
+ if (!name.endsWith('.css')) {
353
+ continue;
354
+ }
355
+
356
+ const globals = findGlobalCarbonRules(source.source().toString());
357
+
358
+ if (globals.length > 0) {
359
+ offences.set(name, globals);
360
+ }
361
+ }
362
+
363
+ if (offences.size > 0) {
364
+ compilation.errors.push(buildGlobalCarbonRuleError(this.appName, offences));
365
+ }
366
+ });
367
+ });
368
+ }
369
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "declaration": true,
4
+ "esModuleInterop": true,
5
+ "module": "CommonJS",
6
+ "target": "es2015",
7
+ "allowSyntheticDefaultImports": true,
8
+ "skipLibCheck": true,
9
+ "strictNullChecks": true,
10
+ "moduleResolution": "node",
11
+ "resolveJsonModule": true,
12
+ "outDir": "dist",
13
+ "lib": ["es5", "es2015", "es2015.promise", "es2016.array.include", "es2018"]
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["src/**/*.test.ts"]
17
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["node", "vitest/globals"]
6
+ },
7
+ "include": ["src/**/*"]
8
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ mockReset: true,
6
+ },
7
+ });