Skip to content

Commit 1d915c2

Browse files
committed
fix(components): add css output validation
1 parent 0091dfc commit 1d915c2

File tree

4 files changed

+306
-2
lines changed

4 files changed

+306
-2
lines changed

packages/components/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@
7272
"luxon": "^3.4.2",
7373
"prismjs": "^1.30.0",
7474
"sass": "^1.83.0",
75-
"tracked-built-ins": "^4.0.0",
7675
"tabbable": "^6.2.0",
77-
"tippy.js": "^6.3.7"
76+
"tippy.js": "^6.3.7",
77+
"tracked-built-ins": "^4.0.0"
7878
},
7979
"devDependencies": {
8080
"@babel/core": "^7.27.1",
@@ -107,12 +107,14 @@
107107
"eslint-plugin-import": "^2.31.0",
108108
"eslint-plugin-n": "^17.17.0",
109109
"globals": "^16.0.0",
110+
"lightningcss": "^1.30.1",
110111
"postcss": "^8.5.3",
111112
"prettier": "^3.5.3",
112113
"prettier-plugin-ember-template-tag": "^2.0.5",
113114
"rollup": "^4.39.0",
114115
"rollup-plugin-copy": "^3.5.0",
115116
"rollup-plugin-scss": "^4.0.1",
117+
"source-map-js": "^1.2.1",
116118
"stylelint": "^16.17.0",
117119
"stylelint-config-rational-order": "^0.1.2",
118120
"stylelint-config-standard-scss": "^14.0.0",
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// rollup-plugin-lightningcss-validator.mjs
2+
import { transform } from 'lightningcss';
3+
import { SourceMapConsumer } from 'source-map-js';
4+
5+
/**
6+
* @typedef {Object} LightningCssValidatorOptions
7+
* @property {(fileName: string) => boolean} [include]
8+
* Predicate to select which CSS files to validate. Defaults to all `.css` files.
9+
* @property {boolean} [errorRecovery=true]
10+
* If true, collect all diagnostics instead of throwing on the first error.
11+
* @property {boolean} [failOnWarning=true]
12+
* If true, fail the build on any diagnostics. If false, emit them as warnings.
13+
*/
14+
15+
/** Safely parse JSON and warn on failure. */
16+
function safeJSONParse(jsonLike, context, warn) {
17+
try {
18+
return typeof jsonLike === 'string' ? JSON.parse(jsonLike) : jsonLike;
19+
} catch (e) {
20+
warn?.(
21+
`(lightningcss-validator) invalid JSON in ${context}: ${e?.message || e}`
22+
);
23+
return null;
24+
}
25+
}
26+
27+
/** Try to load a sourcemap for the given asset. */
28+
function getSourceMap(bundle, fileName, asset, cssText, warn) {
29+
// 1) asset.map
30+
const fromAsset = safeJSONParse(asset.map, `${fileName} (asset.map)`, warn);
31+
if (fromAsset) return fromAsset;
32+
33+
// 2) inline sourceMappingURL (data URL)
34+
const m =
35+
typeof cssText === 'string'
36+
? cssText.match(
37+
/\/\*# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*\*\//
38+
)
39+
: null;
40+
if (m) {
41+
const inlineJson = Buffer.from(m[1], 'base64').toString('utf8');
42+
const fromInline = safeJSONParse(
43+
inlineJson,
44+
`${fileName} (inline sourcemap)`,
45+
warn
46+
);
47+
if (fromInline) return fromInline;
48+
}
49+
50+
// 3) sibling .map asset
51+
const siblingKey = `${fileName}.map`;
52+
const altKey = fileName.replace(/\.css$/i, '.css.map');
53+
const sibling = bundle[siblingKey] || bundle[altKey];
54+
if (sibling?.type === 'asset' && sibling.source) {
55+
const mapText =
56+
typeof sibling.source === 'string'
57+
? sibling.source
58+
: Buffer.from(sibling.source).toString('utf8');
59+
const fromSibling = safeJSONParse(
60+
mapText,
61+
`${fileName} (sibling .map)`,
62+
warn
63+
);
64+
if (fromSibling) return fromSibling;
65+
}
66+
67+
warn?.(
68+
`(lightningcss-validator) no sourcemap found for ${fileName}. Enable sourceMap/sourceMapEmbed/sourceMapContents in your SCSS step for better traces.`
69+
);
70+
return null;
71+
}
72+
73+
/** Map generated position back to original (with column nudges). */
74+
function mapToOriginal(consumer, line, column) {
75+
for (const col of [column, column - 1, column + 1]) {
76+
const orig = consumer.originalPositionFor({
77+
line,
78+
column: Math.max(0, col ?? 0),
79+
});
80+
if (orig?.source && orig.line != null) return orig;
81+
}
82+
return null;
83+
}
84+
85+
/**
86+
* Rollup plugin to validate emitted CSS assets with Lightning CSS.
87+
*
88+
* It parses CSS, collects diagnostics, and reports them with optional source map
89+
* tracebacks to the original SCSS. By default, the build fails if any issues are found.
90+
*
91+
* @param {LightningCssValidatorOptions} [opts]
92+
* @returns {import('rollup').Plugin}
93+
*/
94+
export default function lightningCssValidator(opts = {}) {
95+
const include = opts.include ?? ((f) => f.endsWith('.css'));
96+
const errorRecovery = opts.errorRecovery ?? true;
97+
const failOnWarning = opts.failOnWarning ?? true;
98+
99+
return {
100+
name: 'rollup-plugin-lightningcss-validator',
101+
102+
async generateBundle(_out, bundle) {
103+
const reports = [];
104+
105+
for (const [fileName, asset] of Object.entries(bundle)) {
106+
if (asset.type !== 'asset' || !include(fileName)) continue;
107+
108+
const cssText =
109+
typeof asset.source === 'string'
110+
? asset.source
111+
: Buffer.from(asset.source || []).toString('utf8');
112+
113+
const res = transform({
114+
code: Buffer.from(cssText, 'utf8'),
115+
filename: fileName,
116+
minify: false,
117+
errorRecovery,
118+
});
119+
120+
const diagnostics = [
121+
...(res.diagnostics ?? []),
122+
...(res.warnings ?? []),
123+
];
124+
if (!diagnostics.length) continue;
125+
126+
const mapObj = getSourceMap(
127+
bundle,
128+
fileName,
129+
asset,
130+
cssText,
131+
this.warn
132+
);
133+
let consumer = null;
134+
if (mapObj) {
135+
try {
136+
consumer = await new SourceMapConsumer(mapObj);
137+
} catch (e) {
138+
this.warn(
139+
`(lightningcss-validator) bad sourcemap for ${fileName}: ${e?.message || e}`
140+
);
141+
}
142+
}
143+
144+
for (const d of diagnostics) {
145+
const line = d.loc?.line ?? d.line;
146+
const col = d.loc?.column ?? d.column;
147+
148+
let msg = `❌ CSS issue in ${fileName}`;
149+
if (line != null) msg += `:${line}${col != null ? `:${col}` : ''}`;
150+
msg += ` — ${d.message || 'invalid CSS'}`;
151+
152+
if (consumer && line != null && col != null) {
153+
const orig = mapToOriginal(consumer, line, col);
154+
msg += orig
155+
? `\n ← ${orig.source}:${orig.line}:${orig.column ?? '?'}`
156+
: `\n (no original mapping found — embed SCSS sourcemaps)`;
157+
}
158+
159+
reports.push(msg);
160+
}
161+
}
162+
163+
if (reports.length) {
164+
const header = `\nCSS validation ${failOnWarning ? 'failed' : 'warnings'}${reports.length} issue(s):\n`;
165+
const body = reports.join('\n') + '\n';
166+
failOnWarning ? this.error(header + body) : this.warn(header + body);
167+
}
168+
},
169+
};
170+
}

packages/components/rollup.config.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import scss from 'rollup-plugin-scss';
1010
import process from 'process';
1111
import path from 'node:path';
1212

13+
import lightningCssValidator from './rollup-plugin-lightningcss-validator.mjs';
14+
1315
const addon = new Addon({
1416
srcDir: 'src',
1517
destDir: 'dist',
@@ -55,12 +57,21 @@ const plugins = [
5557
includePaths: [
5658
'node_modules/@hashicorp/design-system-tokens/dist/products/css',
5759
],
60+
sourceMap: true,
61+
sourceMapEmbed: true, // <-- embed map into CSS we can read later
62+
sourceMapContents: true, // <-- include original sources in the map
5863
}),
5964

6065
scss({
6166
fileName: 'styles/@hashicorp/design-system-power-select-overrides.css',
67+
sourceMap: true,
68+
sourceMapEmbed: true, // <-- embed map into CSS we can read later
69+
sourceMapContents: true, // <-- include original sources in the map
6270
}),
6371

72+
// fail build if any invalid CSS is found in emitted .css files
73+
lightningCssValidator(),
74+
6475
// Ensure that standalone .hbs files are properly integrated as Javascript.
6576
addon.hbs(),
6677

pnpm-lock.yaml

Lines changed: 121 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)