From 1d915c2a06151fab71a118f37f85a9448a8c8842 Mon Sep 17 00:00:00 2001 From: Alexey Kulakov Date: Sat, 16 Aug 2025 18:21:50 -0700 Subject: [PATCH] fix(components): add css output validation --- packages/components/package.json | 6 +- .../rollup-plugin-lightningcss-validator.mjs | 170 ++++++++++++++++++ packages/components/rollup.config.mjs | 11 ++ pnpm-lock.yaml | 121 +++++++++++++ 4 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 packages/components/rollup-plugin-lightningcss-validator.mjs diff --git a/packages/components/package.json b/packages/components/package.json index bc70ab5fbae..fe0a35c6397 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -72,9 +72,9 @@ "luxon": "^3.4.2", "prismjs": "^1.30.0", "sass": "^1.83.0", - "tracked-built-ins": "^4.0.0", "tabbable": "^6.2.0", - "tippy.js": "^6.3.7" + "tippy.js": "^6.3.7", + "tracked-built-ins": "^4.0.0" }, "devDependencies": { "@babel/core": "^7.27.1", @@ -107,12 +107,14 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-n": "^17.17.0", "globals": "^16.0.0", + "lightningcss": "^1.30.1", "postcss": "^8.5.3", "prettier": "^3.5.3", "prettier-plugin-ember-template-tag": "^2.0.5", "rollup": "^4.39.0", "rollup-plugin-copy": "^3.5.0", "rollup-plugin-scss": "^4.0.1", + "source-map-js": "^1.2.1", "stylelint": "^16.17.0", "stylelint-config-rational-order": "^0.1.2", "stylelint-config-standard-scss": "^14.0.0", diff --git a/packages/components/rollup-plugin-lightningcss-validator.mjs b/packages/components/rollup-plugin-lightningcss-validator.mjs new file mode 100644 index 00000000000..45a042ae733 --- /dev/null +++ b/packages/components/rollup-plugin-lightningcss-validator.mjs @@ -0,0 +1,170 @@ +// rollup-plugin-lightningcss-validator.mjs +import { transform } from 'lightningcss'; +import { SourceMapConsumer } from 'source-map-js'; + +/** + * @typedef {Object} LightningCssValidatorOptions + * @property {(fileName: string) => boolean} [include] + * Predicate to select which CSS files to validate. Defaults to all `.css` files. + * @property {boolean} [errorRecovery=true] + * If true, collect all diagnostics instead of throwing on the first error. + * @property {boolean} [failOnWarning=true] + * If true, fail the build on any diagnostics. If false, emit them as warnings. + */ + +/** Safely parse JSON and warn on failure. */ +function safeJSONParse(jsonLike, context, warn) { + try { + return typeof jsonLike === 'string' ? JSON.parse(jsonLike) : jsonLike; + } catch (e) { + warn?.( + `(lightningcss-validator) invalid JSON in ${context}: ${e?.message || e}` + ); + return null; + } +} + +/** Try to load a sourcemap for the given asset. */ +function getSourceMap(bundle, fileName, asset, cssText, warn) { + // 1) asset.map + const fromAsset = safeJSONParse(asset.map, `${fileName} (asset.map)`, warn); + if (fromAsset) return fromAsset; + + // 2) inline sourceMappingURL (data URL) + const m = + typeof cssText === 'string' + ? cssText.match( + /\/\*# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*\*\// + ) + : null; + if (m) { + const inlineJson = Buffer.from(m[1], 'base64').toString('utf8'); + const fromInline = safeJSONParse( + inlineJson, + `${fileName} (inline sourcemap)`, + warn + ); + if (fromInline) return fromInline; + } + + // 3) sibling .map asset + const siblingKey = `${fileName}.map`; + const altKey = fileName.replace(/\.css$/i, '.css.map'); + const sibling = bundle[siblingKey] || bundle[altKey]; + if (sibling?.type === 'asset' && sibling.source) { + const mapText = + typeof sibling.source === 'string' + ? sibling.source + : Buffer.from(sibling.source).toString('utf8'); + const fromSibling = safeJSONParse( + mapText, + `${fileName} (sibling .map)`, + warn + ); + if (fromSibling) return fromSibling; + } + + warn?.( + `(lightningcss-validator) no sourcemap found for ${fileName}. Enable sourceMap/sourceMapEmbed/sourceMapContents in your SCSS step for better traces.` + ); + return null; +} + +/** Map generated position back to original (with column nudges). */ +function mapToOriginal(consumer, line, column) { + for (const col of [column, column - 1, column + 1]) { + const orig = consumer.originalPositionFor({ + line, + column: Math.max(0, col ?? 0), + }); + if (orig?.source && orig.line != null) return orig; + } + return null; +} + +/** + * Rollup plugin to validate emitted CSS assets with Lightning CSS. + * + * It parses CSS, collects diagnostics, and reports them with optional source map + * tracebacks to the original SCSS. By default, the build fails if any issues are found. + * + * @param {LightningCssValidatorOptions} [opts] + * @returns {import('rollup').Plugin} + */ +export default function lightningCssValidator(opts = {}) { + const include = opts.include ?? ((f) => f.endsWith('.css')); + const errorRecovery = opts.errorRecovery ?? true; + const failOnWarning = opts.failOnWarning ?? true; + + return { + name: 'rollup-plugin-lightningcss-validator', + + async generateBundle(_out, bundle) { + const reports = []; + + for (const [fileName, asset] of Object.entries(bundle)) { + if (asset.type !== 'asset' || !include(fileName)) continue; + + const cssText = + typeof asset.source === 'string' + ? asset.source + : Buffer.from(asset.source || []).toString('utf8'); + + const res = transform({ + code: Buffer.from(cssText, 'utf8'), + filename: fileName, + minify: false, + errorRecovery, + }); + + const diagnostics = [ + ...(res.diagnostics ?? []), + ...(res.warnings ?? []), + ]; + if (!diagnostics.length) continue; + + const mapObj = getSourceMap( + bundle, + fileName, + asset, + cssText, + this.warn + ); + let consumer = null; + if (mapObj) { + try { + consumer = await new SourceMapConsumer(mapObj); + } catch (e) { + this.warn( + `(lightningcss-validator) bad sourcemap for ${fileName}: ${e?.message || e}` + ); + } + } + + for (const d of diagnostics) { + const line = d.loc?.line ?? d.line; + const col = d.loc?.column ?? d.column; + + let msg = `❌ CSS issue in ${fileName}`; + if (line != null) msg += `:${line}${col != null ? `:${col}` : ''}`; + msg += ` — ${d.message || 'invalid CSS'}`; + + if (consumer && line != null && col != null) { + const orig = mapToOriginal(consumer, line, col); + msg += orig + ? `\n ← ${orig.source}:${orig.line}:${orig.column ?? '?'}` + : `\n (no original mapping found — embed SCSS sourcemaps)`; + } + + reports.push(msg); + } + } + + if (reports.length) { + const header = `\nCSS validation ${failOnWarning ? 'failed' : 'warnings'} — ${reports.length} issue(s):\n`; + const body = reports.join('\n') + '\n'; + failOnWarning ? this.error(header + body) : this.warn(header + body); + } + }, + }; +} diff --git a/packages/components/rollup.config.mjs b/packages/components/rollup.config.mjs index 9b83a1c23c6..e0d779b688c 100644 --- a/packages/components/rollup.config.mjs +++ b/packages/components/rollup.config.mjs @@ -10,6 +10,8 @@ import scss from 'rollup-plugin-scss'; import process from 'process'; import path from 'node:path'; +import lightningCssValidator from './rollup-plugin-lightningcss-validator.mjs'; + const addon = new Addon({ srcDir: 'src', destDir: 'dist', @@ -55,12 +57,21 @@ const plugins = [ includePaths: [ 'node_modules/@hashicorp/design-system-tokens/dist/products/css', ], + sourceMap: true, + sourceMapEmbed: true, // <-- embed map into CSS we can read later + sourceMapContents: true, // <-- include original sources in the map }), scss({ fileName: 'styles/@hashicorp/design-system-power-select-overrides.css', + sourceMap: true, + sourceMapEmbed: true, // <-- embed map into CSS we can read later + sourceMapContents: true, // <-- include original sources in the map }), + // fail build if any invalid CSS is found in emitted .css files + lightningCssValidator(), + // Ensure that standalone .hbs files are properly integrated as Javascript. addon.hbs(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fab8e6ab13..f3d4e707936 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,9 @@ importers: globals: specifier: ^16.0.0 version: 16.3.0 + lightningcss: + specifier: ^1.30.1 + version: 1.30.1 postcss: specifier: ^8.5.3 version: 8.5.6 @@ -298,6 +301,9 @@ importers: rollup-plugin-scss: specifier: ^4.0.1 version: 4.0.1 + source-map-js: + specifier: ^1.2.1 + version: 1.2.1 stylelint: specifier: ^16.17.0 version: 16.23.0(typescript@5.9.2) @@ -5833,6 +5839,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -8438,6 +8448,70 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-darwin-arm64@1.30.1: + resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.1: + resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.1: + resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.1: + resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.1: + resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.1: + resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.1: + resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.1: + resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.1: + resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.1: + resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.1: + resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} + engines: {node: '>= 12.0.0'} + line-column@1.0.2: resolution: {integrity: sha512-Ktrjk5noGYlHsVnYWh62FLVs4hTb8A3e+vucNZMgPeAOITdshMSgv4cCZQeRDjm7+goqmo6+liZwTXo+U3sVww==} @@ -18187,6 +18261,8 @@ snapshots: detect-libc@1.0.3: optional: true + detect-libc@2.0.4: {} + detect-newline@3.1.0: {} detect-newline@4.0.1: {} @@ -22594,6 +22670,51 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-darwin-arm64@1.30.1: + optional: true + + lightningcss-darwin-x64@1.30.1: + optional: true + + lightningcss-freebsd-x64@1.30.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.1: + optional: true + + lightningcss-linux-arm64-gnu@1.30.1: + optional: true + + lightningcss-linux-arm64-musl@1.30.1: + optional: true + + lightningcss-linux-x64-gnu@1.30.1: + optional: true + + lightningcss-linux-x64-musl@1.30.1: + optional: true + + lightningcss-win32-arm64-msvc@1.30.1: + optional: true + + lightningcss-win32-x64-msvc@1.30.1: + optional: true + + lightningcss@1.30.1: + dependencies: + detect-libc: 2.0.4 + optionalDependencies: + lightningcss-darwin-arm64: 1.30.1 + lightningcss-darwin-x64: 1.30.1 + lightningcss-freebsd-x64: 1.30.1 + lightningcss-linux-arm-gnueabihf: 1.30.1 + lightningcss-linux-arm64-gnu: 1.30.1 + lightningcss-linux-arm64-musl: 1.30.1 + lightningcss-linux-x64-gnu: 1.30.1 + lightningcss-linux-x64-musl: 1.30.1 + lightningcss-win32-arm64-msvc: 1.30.1 + lightningcss-win32-x64-msvc: 1.30.1 + line-column@1.0.2: dependencies: isarray: 1.0.0