Skip to content

Commit 2074bf9

Browse files
committed
fix(components): add css output validation
1 parent 5508196 commit 2074bf9

File tree

4 files changed

+345
-213
lines changed

4 files changed

+345
-213
lines changed

packages/components/package.json

Lines changed: 3 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,6 +107,7 @@
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",
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
@@ -9,6 +9,8 @@ import copy from 'rollup-plugin-copy';
99
import scss from 'rollup-plugin-scss';
1010
import process from 'process';
1111

12+
import lightningCssValidator from './rollup-plugin-lightningcss-validator.mjs';
13+
1214
const addon = new Addon({
1315
srcDir: 'src',
1416
destDir: 'dist',
@@ -45,12 +47,21 @@ const plugins = [
4547
includePaths: [
4648
'node_modules/@hashicorp/design-system-tokens/dist/products/css',
4749
],
50+
sourceMap: true,
51+
sourceMapEmbed: true, // <-- embed map into CSS we can read later
52+
sourceMapContents: true, // <-- include original sources in the map
4853
}),
4954

5055
scss({
5156
fileName: 'styles/@hashicorp/design-system-power-select-overrides.css',
57+
sourceMap: true,
58+
sourceMapEmbed: true, // <-- embed map into CSS we can read later
59+
sourceMapContents: true, // <-- include original sources in the map
5260
}),
5361

62+
// fail build if any invalid CSS is found in emitted .css files
63+
lightningCssValidator(),
64+
5465
// Ensure that standalone .hbs files are properly integrated as Javascript.
5566
addon.hbs(),
5667

0 commit comments

Comments
 (0)