From 2c5017ca9abd4a675a70232f38cceac0fb556020 Mon Sep 17 00:00:00 2001 From: luhengchang Date: Fri, 10 Apr 2026 15:09:32 +0800 Subject: [PATCH] feat(rspeedy): add CSS sourcemap diagnostics plumbing --- examples/react/src/App.css | 1 + packages/rspeedy/core/src/config/defaults.ts | 4 + .../core/src/config/output/source-map.ts | 2 +- .../test/config/output/source-map.test.ts | 63 +++ packages/tools/css-serializer/package.json | 1 + packages/tools/css-serializer/src/css/ast.ts | 9 +- .../css-serializer/src/css/cssChunksToMap.ts | 108 +++-- .../tools/css-serializer/src/css/debundle.ts | 138 +++++- .../tools/css-serializer/src/css/index.ts | 1 + .../tools/css-serializer/src/css/sourceMap.ts | 13 + .../tools/css-serializer/test/css.test.ts | 109 +++++ .../template-webpack-plugin/package.json | 1 + .../src/LynxEncodePlugin.ts | 80 +++- .../src/LynxTemplatePlugin.ts | 46 +- .../src/cssDiagnostics.ts | 395 ++++++++++++++++++ .../test/cssDiagnostics.test.ts | 251 +++++++++++ pnpm-lock.yaml | 64 +-- 17 files changed, 1219 insertions(+), 67 deletions(-) create mode 100644 packages/rspeedy/core/test/config/output/source-map.test.ts create mode 100644 packages/tools/css-serializer/src/css/sourceMap.ts create mode 100644 packages/webpack/template-webpack-plugin/src/cssDiagnostics.ts create mode 100644 packages/webpack/template-webpack-plugin/test/cssDiagnostics.test.ts diff --git a/examples/react/src/App.css b/examples/react/src/App.css index 650e423282..b42599a8b7 100644 --- a/examples/react/src/App.css +++ b/examples/react/src/App.css @@ -1,6 +1,7 @@ :root { background-color: #000; --color-text: #fff; + unknown-prop: unknown-value; } .Background { diff --git a/packages/rspeedy/core/src/config/defaults.ts b/packages/rspeedy/core/src/config/defaults.ts index 0081985ad9..a41e7bea01 100644 --- a/packages/rspeedy/core/src/config/defaults.ts +++ b/packages/rspeedy/core/src/config/defaults.ts @@ -30,6 +30,10 @@ export function applyDefaultRspeedyConfig(config: Config): Config { // from the `output.filename.bundle` field. filename: getFilename(config.output?.filename), + sourceMap: { + css: true, + }, + // inlineScripts defaults to false when chunk splitting is enabled, true otherwise inlineScripts: !enableChunkSplitting, diff --git a/packages/rspeedy/core/src/config/output/source-map.ts b/packages/rspeedy/core/src/config/output/source-map.ts index e98c625b74..5ce1b5cce3 100644 --- a/packages/rspeedy/core/src/config/output/source-map.ts +++ b/packages/rspeedy/core/src/config/output/source-map.ts @@ -78,7 +78,7 @@ export interface SourceMap { * * @remarks * - * Defaults to `false`. + * Defaults to `true`. * * @example * diff --git a/packages/rspeedy/core/test/config/output/source-map.test.ts b/packages/rspeedy/core/test/config/output/source-map.test.ts new file mode 100644 index 0000000000..70d74277a6 --- /dev/null +++ b/packages/rspeedy/core/test/config/output/source-map.test.ts @@ -0,0 +1,63 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from 'vitest' + +import { createRspeedy } from '../../../src/index.js' + +describe('output.sourceMap', () => { + test('defaults css source map to true', async () => { + const rspeedy = await createRspeedy({ + rspeedyConfig: {}, + }) + + expect(rspeedy.getRspeedyConfig().output?.sourceMap).toEqual({ + css: true, + }) + }) + + test('respects output.sourceMap false', async () => { + const rspeedy = await createRspeedy({ + rspeedyConfig: { + output: { + sourceMap: false, + }, + }, + }) + + expect(rspeedy.getRspeedyConfig().output?.sourceMap).toBe(false) + }) + + test('respects output.sourceMap.css false', async () => { + const rspeedy = await createRspeedy({ + rspeedyConfig: { + output: { + sourceMap: { + css: false, + }, + }, + }, + }) + + expect(rspeedy.getRspeedyConfig().output?.sourceMap).toEqual({ + css: false, + }) + }) + + test('merges css default with user js source map config', async () => { + const rspeedy = await createRspeedy({ + rspeedyConfig: { + output: { + sourceMap: { + js: 'source-map', + }, + }, + }, + }) + + expect(rspeedy.getRspeedyConfig().output?.sourceMap).toEqual({ + css: true, + js: 'source-map', + }) + }) +}) diff --git a/packages/tools/css-serializer/package.json b/packages/tools/css-serializer/package.json index cb8ea2d43a..1b33535ca5 100644 --- a/packages/tools/css-serializer/package.json +++ b/packages/tools/css-serializer/package.json @@ -26,6 +26,7 @@ "css-tree": "^3.1.0" }, "devDependencies": { + "@jridgewell/gen-mapping": "^0.3.12", "@types/css-tree": "^2.3.11" } } diff --git a/packages/tools/css-serializer/src/css/ast.ts b/packages/tools/css-serializer/src/css/ast.ts index e31e4815d3..97b9a1b8cf 100644 --- a/packages/tools/css-serializer/src/css/ast.ts +++ b/packages/tools/css-serializer/src/css/ast.ts @@ -1,14 +1,15 @@ // Copyright 2024 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import * as CSS from '../index.js'; -import type { Plugin } from '../index.js'; +import { parse } from '../parse.js'; +import type { LynxStyleNode } from '../types/LynxStyleNode.js'; +import type { ParserError, Plugin } from '../types/Plugin.js'; export function cssToAst( content: string, plugins: Plugin[], -): [CSS.LynxStyleNode[], CSS.ParserError[]] { - const parsedCSS = CSS.parse(content, { +): [LynxStyleNode[], ParserError[]] { + const parsedCSS = parse(content, { plugins, }); return [parsedCSS.root, parsedCSS.errors] as const; diff --git a/packages/tools/css-serializer/src/css/cssChunksToMap.ts b/packages/tools/css-serializer/src/css/cssChunksToMap.ts index 79b9f428a2..40a37ed661 100644 --- a/packages/tools/css-serializer/src/css/cssChunksToMap.ts +++ b/packages/tools/css-serializer/src/css/cssChunksToMap.ts @@ -1,47 +1,107 @@ // Copyright 2024 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type * as CSS from '../index.js'; - import { cssToAst } from './ast.js'; import { debundleCSS } from './debundle.js'; +import type { LynxStyleNode } from '../types/LynxStyleNode.js'; +import type { Plugin } from '../types/Plugin.js'; + +interface CSSChunkAsset { + content: string; + sourceMap?: import('./sourceMap.js').CSSSourceMap | undefined; +} +/** + * Convert CSS chunks into `cssMap` / `cssSource`. + * + * `loc` fields remain in bundle CSS coordinates so they can be resolved later + * via `main.css.map`. + * + * `cssSource` intentionally keeps the historical `/cssId/.css` shape. + */ export function cssChunksToMap( - cssChunks: string[], - plugins: CSS.Plugin[], + cssChunks: Array, + plugins: Plugin[], enableCSSSelector: boolean, ): { - cssMap: Record; + cssMap: Record; cssSource: Record; contentMap: Map; } { const cssMap = cssChunks - .reduce>((cssMap, css) => { - debundleCSS(css, cssMap, enableCSSSelector); + .reduce>((cssMap, cssChunk) => { + const normalizedCSSChunk = normalizeCSSChunk(cssChunk); + const debundledMap = new Map(); + + debundleCSS( + normalizedCSSChunk.content, + debundledMap, + enableCSSSelector, + true, + ); + + debundledMap.forEach((content, cssId) => { + if (!cssMap.has(cssId)) { + cssMap.set(cssId, []); + } + + cssMap.get(cssId)!.push( + ...content.map(content => ({ + content, + sourceMap: normalizedCSSChunk.sourceMap, + })), + ); + }); + return cssMap; }, new Map()); + const stylesheets = Array.from(cssMap.entries()).map( + ([cssId, contentSegments]) => { + const content = contentSegments.map(({ content }) => content); + const [root] = cssToAst(content.join('\n'), plugins); + + root.forEach(rule => { + if (rule.type === 'ImportRule') { + // For example: '/981029' -> '981029' + rule.href = rule.href.replace('/', ''); + } + }); + + return { + cssId, + root, + cssSource: `/cssId/${cssId}.css`, + content, + }; + }, + ); + + const contentMap = Array.from(cssMap.entries()).reduce>( + (contentMap, [cssId, contentSegments]) => { + contentMap.set(cssId, contentSegments.map(({ content }) => content)); + return contentMap; + }, + new Map(), + ); + return { cssMap: Object.fromEntries( - Array.from(cssMap.entries()).map(([cssId, content]) => { - const [root] = cssToAst(content.join('\n'), plugins); - - root.forEach(rule => { - if (rule.type === 'ImportRule') { - // For example: '/981029' -> '981029' - rule.href = rule.href.replace('/', ''); - } - }); - - return [cssId, root]; - }), + stylesheets.map(({ cssId, root }) => [cssId, root]), ), cssSource: Object.fromEntries( - Array.from(cssMap.keys()).map(cssId => [ - cssId, - `/cssId/${cssId}.css`, - ]), + stylesheets.map(({ cssId, cssSource }) => [cssId, cssSource]), ), - contentMap: cssMap, + contentMap, }; } + +function normalizeCSSChunk(cssChunk: string | CSSChunkAsset): CSSChunkAsset { + if (typeof cssChunk === 'string') { + return { + content: cssChunk, + }; + } + + return cssChunk; +} diff --git a/packages/tools/css-serializer/src/css/debundle.ts b/packages/tools/css-serializer/src/css/debundle.ts index 5995247686..689132efb2 100644 --- a/packages/tools/css-serializer/src/css/debundle.ts +++ b/packages/tools/css-serializer/src/css/debundle.ts @@ -9,13 +9,28 @@ import * as cssTree from 'css-tree'; const COMMON_CSS = '/common.css'; const COMMON_CSS_ID = 0; +interface CSSPosition { + column: number; + line: number; + offset: number; +} + +interface CSSSegment { + content: string; + start: CSSPosition; +} + export function debundleCSS( code: string, css: Map, enableCSSSelector: boolean, + preserveLocations: boolean = false, ): void { - const ast = cssTree.parse(code); + const ast = cssTree.parse(code, { + positions: true, + }); + const fileKeyToCSSSegments = new Map(); const fileKeyToCSSContent = new Map(); const cssIdToFileKeys = new Map>(); const fileKeyToCSSId = new Map([[COMMON_CSS, COMMON_CSS_ID]]); @@ -54,7 +69,14 @@ export function debundleCSS( cssIdToFileKeys.set(cssId, fileKeys); } fileKeys.add(fileKey); - if (!fileKeyToCSSContent.has(fileKey)) { + if (preserveLocations) { + if (!fileKeyToCSSSegments.has(fileKey)) { + fileKeyToCSSSegments.set( + fileKey, + getBlockSegments(code, node.block), + ); + } + } else if (!fileKeyToCSSContent.has(fileKey)) { fileKeyToCSSContent.set( fileKey, cssTree.generate({ @@ -71,7 +93,9 @@ export function debundleCSS( // If there are Rules left in the AST(e.g.: some rules that are not in `@file {}`), // we treat them as global styles. Global styles should be added to COMMON_CSS(cssId: 0). - const commonCss = cssTree.generate(ast); + const commonCss = preserveLocations + ? buildStylesheetFromSegments(getTopLevelSegments(code, ast)) + : cssTree.generate(ast); if (commonCss) { emplaceCSSStyleSheet(css, COMMON_CSS_ID, commonCss); } @@ -84,12 +108,22 @@ export function debundleCSS( // // Note that the `Map.prototype.keys()` returns an iterator in insertion order. // This will make sure that the stylesheets are created in the same order of CSS. - Array.from(fileKeyToCSSContent.keys()).forEach((fileKey, index) => { + const fileKeys = preserveLocations + ? Array.from(fileKeyToCSSSegments.keys()) + : Array.from(fileKeyToCSSContent.keys()); + + fileKeys.forEach((fileKey, index) => { // Starts from 1 // 0 is the common CSS index = index + 1; fileKeyToCSSId.set(fileKey, index); - emplaceCSSStyleSheet(css, index, fileKeyToCSSContent.get(fileKey)); + emplaceCSSStyleSheet( + css, + index, + preserveLocations + ? buildStylesheetFromSegments(fileKeyToCSSSegments.get(fileKey)!) + : fileKeyToCSSContent.get(fileKey)!, + ); }); // TODO: remove /cssId/0.css if not exists in the cssMap @@ -112,6 +146,100 @@ export function debundleCSS( }); } +function getBlockSegments( + code: string, + block: cssTree.Block, +): CSSSegment[] { + const children = block.children.toArray(); + + if (children.length === 0) { + return []; + } + + const firstChildLoc = getLoc(children[0]!); + const lastChildLoc = getLoc(children[children.length - 1]!); + + return [{ + content: code.slice(firstChildLoc.start.offset, lastChildLoc.end.offset), + start: firstChildLoc.start, + }]; +} + +function getTopLevelSegments( + code: string, + ast: cssTree.CssNode, +): CSSSegment[] { + if (ast.type !== 'StyleSheet') { + return []; + } + + return ast.children.toArray().map((node) => { + const loc = getLoc(node); + return { + content: code.slice(loc.start.offset, loc.end.offset), + start: loc.start, + }; + }); +} + +function buildStylesheetFromSegments(segments: CSSSegment[]): string { + let result = ''; + let line = 1; + let column = 1; + + for (const segment of segments) { + const lineBreaks = Math.max(segment.start.line - line, 0); + if (lineBreaks > 0) { + result += '\n'.repeat(lineBreaks); + line += lineBreaks; + column = 1; + } + + const spaces = Math.max(segment.start.column - column, 0); + if (spaces > 0) { + result += ' '.repeat(spaces); + column += spaces; + } + + result += segment.content; + ({ line, column } = getPositionAfterContent(line, column, segment.content)); + } + + return result; +} + +function getPositionAfterContent( + line: number, + column: number, + content: string, +): Pick { + for (const char of content) { + if (char === '\n') { + line += 1; + column = 1; + } else { + column += 1; + } + } + + return { line, column }; +} + +function getLoc(node: cssTree.CssNode): { + end: CSSPosition; + start: CSSPosition; +} { + const loc = node.loc; + if (!loc) { + throw new Error('Expected css node location to exist.'); + } + + return { + start: loc.start as CSSPosition, + end: loc.end as CSSPosition, + }; +} + function emplaceCSSStyleSheet(map: Map, key: K, value: V) { if (map.has(key)) { map.get(key)!.push(value); diff --git a/packages/tools/css-serializer/src/css/index.ts b/packages/tools/css-serializer/src/css/index.ts index 52b3884773..d4fb7ec11d 100644 --- a/packages/tools/css-serializer/src/css/index.ts +++ b/packages/tools/css-serializer/src/css/index.ts @@ -2,3 +2,4 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. export { cssChunksToMap } from './cssChunksToMap.js'; +export type { CSSSourceMap } from './sourceMap.js'; diff --git a/packages/tools/css-serializer/src/css/sourceMap.ts b/packages/tools/css-serializer/src/css/sourceMap.ts new file mode 100644 index 0000000000..2504dd6c7a --- /dev/null +++ b/packages/tools/css-serializer/src/css/sourceMap.ts @@ -0,0 +1,13 @@ +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export interface CSSSourceMap { + file?: string | undefined; + mappings: string; + names?: string[] | undefined; + sourceRoot?: string | undefined; + sources: string[]; + sourcesContent?: (string | null)[] | undefined; + version: number; +} diff --git a/packages/tools/css-serializer/test/css.test.ts b/packages/tools/css-serializer/test/css.test.ts index 60b3457ad1..42d697bd95 100644 --- a/packages/tools/css-serializer/test/css.test.ts +++ b/packages/tools/css-serializer/test/css.test.ts @@ -1,6 +1,12 @@ // Copyright 2024 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { + addSegment, + GenMapping, + setSourceContent, + toEncodedMap, +} from '@jridgewell/gen-mapping'; import { describe, expect, test } from 'vitest'; import type { LynxStyleNode } from '../src'; @@ -415,6 +421,91 @@ describe('CSS', () => { '--bar': 'blue', }); }); + + test('preserves bundle locations after debundle', () => { + const { cssMap } = cssChunksToMap( + [ + `\ +.root { + color: red; +} + +@cssId "1000" "foo.css" { + .foo { + color: blue; + } +} + +.tail { + color: green; +} +`, + ], + [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()], + true, + ); + + expect(cssMap[1]?.[0]).toMatchObject({ + type: 'StyleRule', + selectorText: { + value: '.foo', + loc: { + line: 6, + }, + }, + style: [ + { + name: 'color', + keyLoc: { + line: 7, + }, + valLoc: { + line: 7, + }, + }, + ], + }); + }); + + test('keeps bundle locations with css source map', () => { + const source = 'file:///src/foo.css'; + const { cssMap, cssSource } = cssChunksToMap( + [{ + content: `\ +@cssId "1000" "foo.css" { + .foo { + color: blue; + } +} +`, + sourceMap: createCSSSourceMap(source), + }], + [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()], + true, + ); + + expect(cssSource[1]).toBe('/cssId/1.css'); + expect(cssMap[1]?.[0]).toMatchObject({ + type: 'StyleRule', + selectorText: { + value: '.foo', + loc: { + line: 2, + }, + }, + style: [ + { + name: 'color', + keyLoc: { + line: 3, + }, + valLoc: { + line: 3, + }, + }, + ], + }); + }); }); describe('debundle', () => { @@ -876,3 +967,21 @@ function getAllSelectors(nodes: LynxStyleNode[] | undefined): string[] { ?.filter(node => node.type === 'StyleRule') .map(styleRule => styleRule.selectorText.value) ?? []; } + +function createCSSSourceMap(source: string) { + const sourceContent = `\ +.foo { + color: blue; +} +`; + const map = new GenMapping({ + file: '/bundle.css', + }); + + setSourceContent(map, source, sourceContent); + addSegment(map, 1, 2, source, 0, 0); + addSegment(map, 2, 4, source, 1, 2); + addSegment(map, 2, 15, source, 1, 13); + + return toEncodedMap(map); +} diff --git a/packages/webpack/template-webpack-plugin/package.json b/packages/webpack/template-webpack-plugin/package.json index 1eba1dfba0..147edff18e 100644 --- a/packages/webpack/template-webpack-plugin/package.json +++ b/packages/webpack/template-webpack-plugin/package.json @@ -36,6 +36,7 @@ "test": "pnpm vitest --project webpack/template" }, "dependencies": { + "@jridgewell/trace-mapping": "^0.3.29", "@lynx-js/css-serializer": "workspace:*", "@lynx-js/tasm": "0.0.26", "@lynx-js/web-core": "workspace:*", diff --git a/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts index 405c401c10..b4e6146bcc 100644 --- a/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts @@ -2,8 +2,14 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Chunk, Compiler } from 'webpack'; +import type { Chunk, Compilation, Compiler } from 'webpack'; +import type * as CSS from '@lynx-js/css-serializer'; + +import { + extractTasmCSSDiagnostics, + resolveTasmCSSDiagnostics, +} from './cssDiagnostics.js'; import { LynxTemplatePlugin } from './LynxTemplatePlugin.js'; import { getRequireModuleAsyncCachePolyfill } from './polyfill/requireModuleAsync.js'; @@ -226,10 +232,51 @@ export class LynxEncodePluginImpl { const encode = getEncodeMode(); - const { buffer, lepus_debug } = await Promise.resolve( + const { buffer, lepus_debug, css_diagnostics } = await Promise.resolve( encode(encodeOptions), ); + const diagnostics = extractTasmCSSDiagnostics(css_diagnostics); + if (diagnostics.length > 0) { + const resolvedDiagnostics = resolveTasmCSSDiagnostics({ + cssDiagnostics: diagnostics, + mainCSSSourceMap: getMainCSSSourceMap(compilation), + context: compiler.context, + }); + + resolvedDiagnostics.forEach((diagnostic) => { + const webpackWarning = new compiler.webpack.WebpackError( + diagnostic.message, + ); + webpackWarning.stack = ''; + + if ( + diagnostic.sourceFile + && diagnostic.sourceLine !== undefined + && diagnostic.sourceColumn !== undefined + ) { + webpackWarning.file = diagnostic.sourceFile; + webpackWarning.loc = { + start: { + line: diagnostic.sourceLine, + column: diagnostic.sourceColumn, + }, + }; + } else { + webpackWarning.loc = { + start: { + line: diagnostic.line, + column: diagnostic.column, + }, + }; + } + + compilation.warnings.push(webpackWarning); + }); + + return { buffer, debugInfo: lepus_debug }; + } + return { buffer, debugInfo: lepus_debug }; }); }); @@ -322,6 +369,35 @@ export class LynxEncodePluginImpl { protected options: Required; } +type Asset = ReturnType[number]; + +function normalizeCSSSourceMap( + sourceMap: ReturnType | undefined, +): CSS.CSSSourceMap | undefined { + if (!sourceMap || Array.isArray(sourceMap)) { + return undefined; + } + + return sourceMap; +} + +function getMainCSSSourceMap( + compilation: Compilation, +): CSS.CSSSourceMap | undefined { + for (const asset of compilation.getAssets()) { + if (!asset.name.endsWith('.css')) { + continue; + } + + const sourceMap = normalizeCSSSourceMap(asset.source.map?.()); + if (sourceMap) { + return sourceMap; + } + } + + return undefined; +} + export function isDebug(): boolean { if (!process.env['DEBUG']) { return false; diff --git a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts index dc3dc28d86..333b075469 100644 --- a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts @@ -410,6 +410,17 @@ export class LynxTemplatePlugin { * @param options - The encode options. * @returns The CSS map and css source. * + * @remarks + * + * When a CSS source map is provided, the generated `loc` fields will be + * remapped to original source coordinates when possible. + * + * `cssSource` is still keyed by `cssId`, so each stylesheet can only expose a + * single filename. If one debundled stylesheet is composed from multiple + * source files, `loc` can still point to the correct source line/column, but + * `cssSource` will fall back to `/cssId/.css` instead of a real source + * filename. + * * @example * ``` * (console.log(await convertCSSChunksToMap( @@ -422,7 +433,12 @@ export class LynxTemplatePlugin { * ``` */ static convertCSSChunksToMap( - cssChunks: string[], + cssChunks: Array< + string | { + content: string; + sourceMap?: CSS.CSSSourceMap | undefined; + } + >, plugins: CSS.Plugin[], enableCSSSelector: boolean, ): { @@ -753,7 +769,10 @@ class LynxTemplatePluginImpl { assetsInfoByGroups.css .map(chunk => compilation.getAsset(chunk.name)) .filter((v): v is Asset => !!v) - .map(asset => asset.source.source().toString()), + .map(asset => ({ + content: asset.source.source().toString(), + sourceMap: normalizeCSSSourceMap(asset.source.map?.()), + })), cssPlugins, enableCSSSelector, ); @@ -1029,6 +1048,29 @@ class LynxTemplatePluginImpl { #options: Required; } +function normalizeCSSSourceMap( + sourceMap: ReturnType | undefined, +): CSS.CSSSourceMap | undefined { + if (!sourceMap || Array.isArray(sourceMap)) { + return undefined; + } + + return sourceMap as CSS.CSSSourceMap; +} + +// function getMainCSSSourceMap( +// cssAssets: Asset[], +// ): CSS.CSSSourceMap | undefined { +// for (const asset of cssAssets) { +// const sourceMap = normalizeCSSSourceMap(asset.source.map?.()); +// if (sourceMap) { +// return sourceMap; +// } +// } + +// return undefined; +// } + interface AssetsInformationByGroups { backgroundThread: Asset[]; css: Asset[]; diff --git a/packages/webpack/template-webpack-plugin/src/cssDiagnostics.ts b/packages/webpack/template-webpack-plugin/src/cssDiagnostics.ts new file mode 100644 index 0000000000..0f38e44bed --- /dev/null +++ b/packages/webpack/template-webpack-plugin/src/cssDiagnostics.ts @@ -0,0 +1,395 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { existsSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'; +import type { SourceMapInput } from '@jridgewell/trace-mapping'; + +import type * as CSS from '@lynx-js/css-serializer'; + +export interface CSSDiagnostic { + kind: 'selector' | 'declaration'; + cssId: number; + selector: string; + property?: string | undefined; + message: string; + loc: { + line: number; + column: number; + }; +} + +export interface ResolvedCSSDiagnostic extends CSSDiagnostic { + sourceFile: string; + sourceLine: number; + sourceColumn: number; +} + +export interface TasmCSSDiagnostic { + type?: string | undefined; + name?: string | undefined; + line: number; + column: number; +} + +export interface ResolvedTasmCSSDiagnostic extends TasmCSSDiagnostic { + message: string; + sourceFile?: string | undefined; + sourceLine?: number | undefined; + sourceColumn?: number | undefined; +} + +interface StyleDeclarationLoc { + name: string; + keyLoc?: { + line: number; + column: number; + }; + valLoc?: { + line: number; + column: number; + }; +} + +interface StyleRuleLoc { + type: 'StyleRule'; + style?: StyleDeclarationLoc[]; + selectorText?: { + value?: string; + loc?: { + line: number; + column: number; + }; + }; +} + +type CSSMap = Record; + +export function extractCSSDiagnostics(error: unknown): CSSDiagnostic[] { + const rawDiagnostics = findCSSDiagnosticsCandidate(error); + if (!Array.isArray(rawDiagnostics)) { + return []; + } + + return rawDiagnostics + .map((element) => normalizeCSSDiagnostic(element)) + .filter((diagnostic): diagnostic is CSSDiagnostic => diagnostic !== null); +} + +export async function resolveCSSDiagnostics({ + cssDiagnostics, + cssMap, + mainCSSSourceMap, +}: { + cssDiagnostics: CSSDiagnostic[]; + cssMap: CSSMap | undefined; + mainCSSSourceMap: CSS.CSSSourceMap; +}): Promise { + const traceMap = new TraceMap(mainCSSSourceMap as SourceMapInput); + + return cssDiagnostics.flatMap(diagnostic => { + const loc = findBundleLocation(diagnostic, cssMap) ?? diagnostic.loc; + const mapped = originalPositionFor(traceMap, { + line: loc.line, + column: Math.max(loc.column - 1, 0), + }); + + if ( + mapped.source === null + || mapped.line === null + || mapped.column === null + ) { + return []; + } + + return [{ + ...diagnostic, + sourceFile: normalizeSourcePath(mapped.source), + sourceLine: mapped.line, + sourceColumn: mapped.column + 1, + }]; + }); +} + +export function extractTasmCSSDiagnostics(value: unknown): TasmCSSDiagnostic[] { + if (typeof value !== 'string' || value.trim() === '') { + return []; + } + + try { + const parsed = JSON.parse(value) as unknown as TasmCSSDiagnostic[]; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map((element) => normalizeTasmCSSDiagnostic(element)) + .filter((diagnostic): diagnostic is TasmCSSDiagnostic => ( + diagnostic !== null + )); + } catch { + return []; + } +} + +export function resolveTasmCSSDiagnostics({ + cssDiagnostics, + mainCSSSourceMap, + context, + fileExists = existsSync, +}: { + cssDiagnostics: TasmCSSDiagnostic[]; + mainCSSSourceMap: CSS.CSSSourceMap | undefined; + context: string; + fileExists?: (path: string) => boolean; +}): ResolvedTasmCSSDiagnostic[] { + if (!mainCSSSourceMap) { + return cssDiagnostics.map(diagnostic => ({ + ...diagnostic, + message: formatTasmCSSDiagnosticMessage(diagnostic), + })); + } + + const traceMap = new TraceMap(mainCSSSourceMap as SourceMapInput); + + return cssDiagnostics.map(diagnostic => { + const mapped = originalPositionFor(traceMap, { + line: diagnostic.line, + column: Math.max(diagnostic.column - 1, 0), + }); + + const message = formatTasmCSSDiagnosticMessage(diagnostic); + if ( + mapped.source === null + || mapped.line === null + || mapped.column === null + ) { + return { + ...diagnostic, + message, + }; + } + + const sourceFile = normalizeTasmSourcePath( + mapped.source, + mainCSSSourceMap, + context, + ); + if (!sourceFile || !fileExists(sourceFile)) { + return { + ...diagnostic, + message, + }; + } + + return { + ...diagnostic, + message, + sourceFile, + sourceLine: mapped.line, + sourceColumn: mapped.column + 1, + }; + }); +} + +function findCSSDiagnosticsCandidate(value: unknown): unknown { + const candidates = [ + value, + getRecordValue(value, 'cause'), + parseEmbeddedJSON(getRecordValue(value, 'error_msg')), + parseEmbeddedJSON(getRecordValue(value, 'message')), + ]; + + for (const candidate of candidates) { + const diagnostics = getDiagnosticsArray(candidate); + if (diagnostics) { + return diagnostics; + } + } + + return undefined; +} + +function getDiagnosticsArray(value: unknown): unknown[] | undefined { + if (Array.isArray(value)) { + return value as unknown[]; + } + + return [ + 'cssDiagnostics', + 'css_diagnostics', + 'diagnostics', + 'cssErrors', + ].flatMap(key => { + const candidate = getRecordValue(value, key); + return Array.isArray(candidate) ? [candidate] : []; + })[0]; +} + +function parseEmbeddedJSON(value: unknown): unknown { + if (typeof value !== 'string') { + return undefined; + } + + const candidates = [ + value, + value.replace(/^encode error:\s*/i, ''), + value.slice(Math.max(value.indexOf('{'), 0)), + value.slice(Math.max(value.indexOf('['), 0)), + ].filter(candidate => candidate.trim().length > 0); + + for (const candidate of candidates) { + try { + return JSON.parse(candidate); + } catch (error) { + void error; + } + } + + return undefined; +} + +function normalizeCSSDiagnostic(value: unknown): CSSDiagnostic | null { + if (!isRecord(value)) { + return null; + } + + const kind = value['kind']; + const cssId = value['cssId']; + const selector = value['selector']; + const message = value['message']; + const loc = value['loc']; + + if ( + (kind !== 'selector' && kind !== 'declaration') + || typeof cssId !== 'number' + || typeof selector !== 'string' + || typeof message !== 'string' + || !isRecord(loc) + || typeof loc['line'] !== 'number' + || typeof loc['column'] !== 'number' + ) { + return null; + } + + const property = typeof value['property'] === 'string' + ? value['property'] + : undefined; + + return { + kind, + cssId, + selector, + property, + message, + loc: { + line: loc['line'], + column: loc['column'], + }, + }; +} + +function normalizeTasmCSSDiagnostic(value: unknown): TasmCSSDiagnostic | null { + if (!isRecord(value)) { + return null; + } + + return { + type: value['type'] as string | undefined, + name: value['name'] as string | undefined, + line: value['line'] as number, + column: value['column'] as number, + }; +} + +function findBundleLocation( + diagnostic: CSSDiagnostic, + cssMap: CSSMap | undefined, +): CSSDiagnostic['loc'] | undefined { + const rules = cssMap?.[diagnostic.cssId]; + if (!Array.isArray(rules)) { + return undefined; + } + + const matchedRule = rules.find((rule): rule is StyleRuleLoc => { + if (!isRecord(rule) || rule['type'] !== 'StyleRule') { + return false; + } + + const selectorText = getRecordValue(rule, 'selectorText'); + return isRecord(selectorText) + && selectorText['value'] === diagnostic.selector; + }); + + if (!matchedRule) { + return undefined; + } + + if (diagnostic.kind === 'selector') { + return matchedRule.selectorText?.loc; + } + + const declaration = matchedRule.style?.find(item => + item.name === diagnostic.property + ); + return declaration?.valLoc ?? declaration?.keyLoc; +} + +function normalizeSourcePath(source: string): string { + if (source.startsWith('file://')) { + return fileURLToPath(source); + } + + return source; +} + +function normalizeTasmSourcePath( + source: string, + sourceMap: CSS.CSSSourceMap, + context: string, +): string | undefined { + if (source.startsWith('file://')) { + return fileURLToPath(source); + } + + if (source.startsWith('webpack:')) { + const normalized = source + .replace(/^webpack:(?:\/\/\/)?/, '') + .replace(/^\.\//, '') + .replace(/^\/+/, ''); + return resolvePath(context, normalized); + } + + if (source.startsWith('/')) { + return source; + } + + if (sourceMap.sourceRoot) { + return resolvePath(sourceMap.sourceRoot, source); + } + + return resolvePath(context, source); +} + +function formatTasmCSSDiagnosticMessage( + diagnostic: TasmCSSDiagnostic, +): string { + const type = diagnostic.type ?? 'css syntax'; + if (diagnostic.name) { + return `Unsupported ${type} "${diagnostic.name}" was removed during template encode.`; + } + + return `Unsupported ${type} was removed during template encode.`; +} + +function getRecordValue(value: unknown, key: string): unknown { + return isRecord(value) ? value[key] : undefined; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} diff --git a/packages/webpack/template-webpack-plugin/test/cssDiagnostics.test.ts b/packages/webpack/template-webpack-plugin/test/cssDiagnostics.test.ts new file mode 100644 index 0000000000..d866f6272a --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/cssDiagnostics.test.ts @@ -0,0 +1,251 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from 'vitest'; + +import type { CSSSourceMap } from '@lynx-js/css-serializer'; + +import { + extractCSSDiagnostics, + extractTasmCSSDiagnostics, + resolveCSSDiagnostics, + resolveTasmCSSDiagnostics, +} from '../src/cssDiagnostics.js'; +import type { CSSDiagnostic } from '../src/cssDiagnostics.js'; + +describe('cssDiagnostics', () => { + test('extract css diagnostics from encode error payload', () => { + const diagnostics = extractCSSDiagnostics({ + error_msg: JSON.stringify({ + cssDiagnostics: [ + { + kind: 'declaration', + cssId: 0, + selector: '.foo', + property: 'color', + message: 'unsupported color', + loc: { + line: 2, + column: 10, + }, + }, + ], + }), + }); + + expect(diagnostics).toEqual([ + { + kind: 'declaration', + cssId: 0, + selector: '.foo', + property: 'color', + message: 'unsupported color', + loc: { + line: 2, + column: 10, + }, + }, + ]); + }); + + test('resolve selector and declaration locations with css source map', async () => { + const cssMap = { + 0: [ + { + type: 'StyleRule', + style: [ + { + name: 'color', + value: 'red', + keyLoc: { + line: 2, + column: 3, + }, + valLoc: { + line: 2, + column: 10, + }, + }, + ], + selectorText: { + value: '.foo', + loc: { + line: 1, + column: 1, + }, + }, + variables: {}, + }, + ], + }; + + const diagnostics: CSSDiagnostic[] = [ + { + kind: 'selector', + cssId: 0, + selector: '.foo', + message: 'selector error', + loc: { + line: 1, + column: 1, + }, + }, + { + kind: 'declaration', + cssId: 0, + selector: '.foo', + property: 'color', + message: 'declaration error', + loc: { + line: 2, + column: 10, + }, + }, + ]; + + const sourceMap: CSSSourceMap = { + version: 3, + file: '.rspeedy/main/main.css', + sources: ['file:///src/app.css'], + sourcesContent: [ + '.foo {\n color: red;\n}\n', + ], + names: [], + mappings: 'AAAA;EACE,UAAU;AACZ', + }; + + const resolved = await resolveCSSDiagnostics({ + cssDiagnostics: diagnostics, + cssMap, + mainCSSSourceMap: sourceMap, + }); + + expect(resolved).toMatchInlineSnapshot(` + [ + { + "cssId": 0, + "kind": "selector", + "loc": { + "column": 1, + "line": 1, + }, + "message": "selector error", + "selector": ".foo", + "sourceColumn": 1, + "sourceFile": "/src/app.css", + "sourceLine": 1, + }, + { + "cssId": 0, + "kind": "declaration", + "loc": { + "column": 10, + "line": 2, + }, + "message": "declaration error", + "property": "color", + "selector": ".foo", + "sourceColumn": 3, + "sourceFile": "/src/app.css", + "sourceLine": 2, + }, + ] + `); + }); + + test('extract tasm css diagnostics from JSON string', () => { + expect( + extractTasmCSSDiagnostics( + '[{"type":"property","name":"unknown-prop","line":4,"column":15}]', + ), + ).toEqual([ + { + type: 'property', + name: 'unknown-prop', + line: 4, + column: 15, + }, + ]); + + expect(extractTasmCSSDiagnostics('[]')).toEqual([]); + }); + + test('resolve tasm css diagnostics with css source map', () => { + const sourceMap: CSSSourceMap = { + version: 3, + file: '.rspeedy/main/main.css', + sources: ['webpack:/src/app.css'], + sourcesContent: [ + '.foo {\n unknown-prop: red;\n}\n', + ], + names: [], + mappings: 'AAAA;EACE,kBAAkB;AACpB', + }; + + const resolved = resolveTasmCSSDiagnostics({ + cssDiagnostics: [ + { + type: 'property', + name: 'unknown-prop', + line: 2, + column: 10, + }, + ], + mainCSSSourceMap: sourceMap, + context: '/workspace/app', + fileExists: () => true, + }); + + expect(resolved).toEqual([ + { + type: 'property', + name: 'unknown-prop', + line: 2, + column: 10, + message: + 'Unsupported property "unknown-prop" was removed during template encode.', + sourceFile: '/workspace/app/src/app.css', + sourceLine: 2, + sourceColumn: 3, + }, + ]); + }); + + test('skip mapped source when file does not exist', () => { + const sourceMap: CSSSourceMap = { + version: 3, + file: '.rspeedy/main/main.css', + sources: ['file:///src/app.css'], + sourcesContent: [ + '.foo {\n unknown-prop: red;\n}\n', + ], + names: [], + mappings: 'AAAA;EACE,kBAAkB;AACpB', + }; + + const resolved = resolveTasmCSSDiagnostics({ + cssDiagnostics: [ + { + type: 'property', + name: 'unknown-prop', + line: 2, + column: 10, + }, + ], + mainCSSSourceMap: sourceMap, + context: '/workspace/app', + fileExists: () => false, + }); + + expect(resolved).toEqual([ + { + type: 'property', + name: 'unknown-prop', + line: 2, + column: 10, + message: + 'Unsupported property "unknown-prop" was removed during template encode.', + }, + ]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c18f8736f4..225c7a01fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -275,7 +275,7 @@ importers: version: 3.7.0 '@rsbuild/plugin-babel': specifier: 1.1.0 - version: 1.1.0(@rsbuild/core@1.7.5) + version: 1.1.0(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0)) '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -1094,10 +1094,10 @@ importers: version: link:../../web-platform/web-elements '@rsbuild/plugin-less': specifier: 1.6.0 - version: 1.6.0(@rsbuild/core@1.7.5) + version: 1.6.0(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0)) '@rsbuild/plugin-sass': specifier: 1.5.0 - version: 1.5.0(@rsbuild/core@1.7.5) + version: 1.5.0(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0)) commander: specifier: ^13.1.0 version: 13.1.0 @@ -1112,7 +1112,7 @@ importers: version: 1.1.1 rsbuild-plugin-tailwindcss: specifier: 0.2.4 - version: 0.2.4(@rsbuild/core@1.7.5)(tailwindcss@4.2.1) + version: 0.2.4(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))(tailwindcss@4.2.1) rslog: specifier: ^1.3.2 version: 1.3.2 @@ -1277,6 +1277,9 @@ importers: specifier: ^3.1.0 version: 3.1.0 devDependencies: + '@jridgewell/gen-mapping': + specifier: ^0.3.12 + version: 0.3.12 '@types/css-tree': specifier: ^2.3.11 version: 2.3.11 @@ -1722,6 +1725,9 @@ importers: packages/webpack/template-webpack-plugin: dependencies: + '@jridgewell/trace-mapping': + specifier: ^0.3.29 + version: 0.3.29 '@lynx-js/css-serializer': specifier: workspace:* version: link:../../tools/css-serializer @@ -1882,13 +1888,13 @@ importers: version: 7.33.4(@types/node@24.10.13) '@rsbuild/plugin-sass': specifier: 1.5.0 - version: 1.5.0(@rsbuild/core@1.7.5) + version: 1.5.0(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0)) '@rsbuild/plugin-type-check': specifier: 1.3.4 - version: 1.3.4(@rsbuild/core@1.7.5)(@rspack/core@1.7.9(@swc/helpers@0.5.20))(tslib@2.8.1)(typescript@5.9.3) + version: 1.3.4(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))(@rspack/core@1.7.9(@swc/helpers@0.5.20))(tslib@2.8.1)(typescript@5.9.3) '@rsbuild/plugin-typed-css-modules': specifier: 1.2.2 - version: 1.2.2(@rsbuild/core@1.7.5) + version: 1.2.2(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0)) '@rspress/core': specifier: 2.0.3 version: 2.0.3(@types/react@19.2.14)(core-js@3.48.0) @@ -12070,13 +12076,13 @@ snapshots: optionalDependencies: core-js: 3.48.0 - '@rsbuild/plugin-babel@1.1.0(@rsbuild/core@1.7.5)': + '@rsbuild/plugin-babel@1.1.0(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@rsbuild/core': 1.7.5 + '@rsbuild/core': 2.0.0-beta.3(core-js@3.48.0) '@types/babel__core': 7.20.5 deepmerge: 4.3.1 reduce-configs: 1.1.1 @@ -12116,9 +12122,9 @@ snapshots: optionalDependencies: '@rsbuild/core': 1.7.5 - '@rsbuild/plugin-less@1.6.0(@rsbuild/core@1.7.5)': + '@rsbuild/plugin-less@1.6.0(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))': dependencies: - '@rsbuild/core': 1.7.5 + '@rsbuild/core': 2.0.0-beta.3(core-js@3.48.0) deepmerge: 4.3.1 reduce-configs: 1.1.1 @@ -12147,6 +12153,15 @@ snapshots: reduce-configs: 1.1.1 sass-embedded: 1.97.3 + '@rsbuild/plugin-sass@1.5.0(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))': + dependencies: + '@rsbuild/core': 2.0.0-beta.3(core-js@3.48.0) + deepmerge: 4.3.1 + loader-utils: 2.0.4 + postcss: 8.5.6 + reduce-configs: 1.1.1 + sass-embedded: 1.97.3 + '@rsbuild/plugin-source-build@1.0.4(@rsbuild/core@1.7.5)': dependencies: fast-glob: 3.3.3 @@ -12155,19 +12170,6 @@ snapshots: optionalDependencies: '@rsbuild/core': 1.7.5 - '@rsbuild/plugin-type-check@1.3.4(@rsbuild/core@1.7.5)(@rspack/core@1.7.9(@swc/helpers@0.5.20))(tslib@2.8.1)(typescript@5.9.3)': - dependencies: - deepmerge: 4.3.1 - json5: 2.2.3 - reduce-configs: 1.1.1 - ts-checker-rspack-plugin: 1.3.0(@rspack/core@1.7.9(@swc/helpers@0.5.20))(tslib@2.8.1)(typescript@5.9.3) - optionalDependencies: - '@rsbuild/core': 1.7.5 - transitivePeerDependencies: - - '@rspack/core' - - tslib - - typescript - '@rsbuild/plugin-type-check@1.3.4(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))(@rspack/core@1.7.9(@swc/helpers@0.5.20))(tslib@2.8.1)(typescript@5.9.3)': dependencies: deepmerge: 4.3.1 @@ -12185,6 +12187,10 @@ snapshots: optionalDependencies: '@rsbuild/core': 1.7.5 + '@rsbuild/plugin-typed-css-modules@1.2.2(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))': + optionalDependencies: + '@rsbuild/core': 2.0.0-beta.3(core-js@3.48.0) + '@rsdoctor/client@1.2.3': {} '@rsdoctor/core@1.2.3(@rsbuild/core@1.7.5)(@rspack/core@1.7.9(@swc/helpers@0.5.20))(webpack@5.105.2)': @@ -18114,15 +18120,15 @@ snapshots: optionalDependencies: '@rsbuild/core': 2.0.0-beta.3(core-js@3.48.0) - rsbuild-plugin-tailwindcss@0.2.4(@rsbuild/core@1.7.5)(tailwindcss@4.2.1): + rsbuild-plugin-tailwindcss@0.2.4(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))(tailwindcss@3.4.19): dependencies: - tailwindcss: 4.2.1 + tailwindcss: 3.4.19 optionalDependencies: - '@rsbuild/core': 1.7.5 + '@rsbuild/core': 2.0.0-beta.3(core-js@3.48.0) - rsbuild-plugin-tailwindcss@0.2.4(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))(tailwindcss@3.4.19): + rsbuild-plugin-tailwindcss@0.2.4(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))(tailwindcss@4.2.1): dependencies: - tailwindcss: 3.4.19 + tailwindcss: 4.2.1 optionalDependencies: '@rsbuild/core': 2.0.0-beta.3(core-js@3.48.0)