diff --git a/.changeset/fix-css-id-source-map-offset.md b/.changeset/fix-css-id-source-map-offset.md new file mode 100644 index 0000000000..c0a3d0a540 --- /dev/null +++ b/.changeset/fix-css-id-source-map-offset.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/css-extract-webpack-plugin": patch +--- + +Fix CSS source map line offsets when wrapping extracted CSS with cssId metadata. diff --git a/packages/webpack/css-extract-webpack-plugin/src/loader.ts b/packages/webpack/css-extract-webpack-plugin/src/loader.ts index 8d514e90dd..9e01a22257 100644 --- a/packages/webpack/css-extract-webpack-plugin/src/loader.ts +++ b/packages/webpack/css-extract-webpack-plugin/src/loader.ts @@ -57,11 +57,16 @@ type Dependency = [ id: string, content: string, media: string, - sourceMap: string | Buffer | undefined, + sourceMap: DependencySourceMap | undefined, supports: string | undefined, layer: string | undefined, ]; +interface DependencySourceMap { + mappings?: string | undefined; + [key: string]: unknown; +} + /** * With css-loader options: `{esModule: true}` */ @@ -178,6 +183,9 @@ export async function load( this.rootContext, extractPathFromIdentifier(identifier)!, ); + const shouldWrapCSSId = Boolean(cssId) + && (params.get('common') === null + || params.get('common') === 'false'); identifierCountMap.set(identifier, count + 1); @@ -188,9 +196,7 @@ export async function load( ), context: this.rootContext, content: Buffer.from( - cssId - && (params.get('common') === null - || params.get('common') === 'false') + shouldWrapCSSId /** * Given the following source code: * @@ -222,8 +228,7 @@ export async function load( * } * ``` */ - ? `\ -@cssId "${cssId}" "${filePath}" { + ? `@cssId "${cssId}" "${filePath}" { ${content} } ` @@ -234,7 +239,9 @@ ${content} layer, identifierIndex: count, sourceMap: sourceMap - ? Buffer.from(JSON.stringify(sourceMap)) + ? Buffer.from(JSON.stringify( + shouldWrapCSSId ? offsetSourceMapLines(sourceMap, 1) : sourceMap, + )) : undefined, }; }, @@ -299,6 +306,20 @@ ${content} return resultSource; } +export function offsetSourceMapLines( + sourceMap: T, + lineOffset: number, +): T { + if (lineOffset <= 0 || !sourceMap.mappings) { + return sourceMap; + } + + return { + ...sourceMap, + mappings: `${';'.repeat(lineOffset)}${sourceMap.mappings}`, + }; +} + export async function pitch( this: LoaderContext, request: string, diff --git a/packages/webpack/css-extract-webpack-plugin/test/loader.test.ts b/packages/webpack/css-extract-webpack-plugin/test/loader.test.ts new file mode 100644 index 0000000000..a5a751359e --- /dev/null +++ b/packages/webpack/css-extract-webpack-plugin/test/loader.test.ts @@ -0,0 +1,35 @@ +// 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 { describe, expect, test } from 'vitest'; + +import { offsetSourceMapLines } from '../src/loader.js'; + +describe('loader', () => { + test('offsets generated source map lines when cssId wraps CSS content', () => { + expect( + offsetSourceMapLines({ + version: 3, + sources: ['index.ttss'], + names: [], + mappings: 'AAAA;AACA;AACA', + }, 1), + ).toEqual({ + version: 3, + sources: ['index.ttss'], + names: [], + mappings: ';AAAA;AACA;AACA', + }); + }); + + test('keeps empty source maps unchanged', () => { + const sourceMap = { + version: 3, + sources: ['index.ttss'], + names: [], + mappings: '', + }; + + expect(offsetSourceMapLines(sourceMap, 1)).toBe(sourceMap); + }); +}); diff --git a/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md b/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md index aab17244ae..9c04564508 100644 --- a/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md +++ b/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md @@ -119,6 +119,34 @@ export interface LynxTemplatePluginOptions { targetSdkVersion: string; } +// @public +export function processTasmCSSDiagnostics(input: ProcessTasmCSSDiagnosticsOptions): ResolvedTasmCSSDiagnostic[]; + +// @public +export interface ProcessTasmCSSDiagnosticsOptions { + compilation: Compilation; + context: string; + cssDiagnostics: unknown; + emittedWarnings?: Set | undefined; + fileExists?: ((path: string) => boolean) | undefined; +} + +// @public +export interface ResolvedTasmCSSDiagnostic extends TasmCSSDiagnostic { + message: string; + sourceColumn?: number | undefined; + sourceFile?: string | undefined; + sourceLine?: number | undefined; +} + +// @public +export interface TasmCSSDiagnostic { + column: number; + line: number; + name?: string | undefined; + type?: string | undefined; +} + // @public export interface TemplateHooks { // @alpha diff --git a/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts index 3dd5ca76bf..b22c898795 100644 --- a/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts @@ -2,15 +2,9 @@ // 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, Compilation, Compiler } from 'webpack'; +import type { Chunk, Compiler } from 'webpack'; -import type * as CSS from '@lynx-js/css-serializer'; - -import { - dedupeTasmCSSDiagnostics, - extractTasmCSSDiagnostics, - resolveTasmCSSDiagnostics, -} from './cssDiagnostics.js'; +import { processTasmCSSDiagnostics } from './cssDiagnostics.js'; import { LynxTemplatePlugin } from './LynxTemplatePlugin.js'; import { getRequireModuleAsyncCachePolyfill } from './polyfill/requireModuleAsync.js'; @@ -240,17 +234,13 @@ export class LynxEncodePluginImpl { encode(encodeOptions), ); - const diagnostics = extractTasmCSSDiagnostics(css_diagnostics); - if (diagnostics.length > 0) { - const resolvedDiagnostics = dedupeTasmCSSDiagnostics( - resolveTasmCSSDiagnostics({ - cssDiagnostics: diagnostics, - mainCSSSourceMap: getMainCSSSourceMap(compilation), - context: compiler.context, - }), - emittedCSSDiagnosticWarnings, - ); - + const resolvedDiagnostics = processTasmCSSDiagnostics({ + cssDiagnostics: css_diagnostics, + compilation, + context: compiler.context, + emittedWarnings: emittedCSSDiagnosticWarnings, + }); + if (resolvedDiagnostics.length > 0) { resolvedDiagnostics.forEach((diagnostic) => { const webpackWarning = new compiler.webpack.WebpackError( diagnostic.message, @@ -374,35 +364,6 @@ 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/cssDiagnostics.ts b/packages/webpack/template-webpack-plugin/src/cssDiagnostics.ts index c2b45195f0..57e7294474 100644 --- a/packages/webpack/template-webpack-plugin/src/cssDiagnostics.ts +++ b/packages/webpack/template-webpack-plugin/src/cssDiagnostics.ts @@ -8,23 +8,119 @@ import { fileURLToPath } from 'node:url'; import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'; import type { SourceMapInput } from '@jridgewell/trace-mapping'; +import type { Compilation } from 'webpack'; import type * as CSS from '@lynx-js/css-serializer'; +/** + * A CSS diagnostic emitted by TASM during template encode. + * + * @public + */ export interface TasmCSSDiagnostic { + /** + * The diagnostic category, such as `property`. + */ type?: string | undefined; + /** + * The unsupported CSS syntax name, when TASM reports one. + */ name?: string | undefined; + /** + * The generated CSS line reported by TASM. + */ line: number; + /** + * The generated CSS column reported by TASM. + */ column: number; } +/** + * A TASM CSS diagnostic with a formatted message and optional source map + * location. + * + * @public + */ export interface ResolvedTasmCSSDiagnostic extends TasmCSSDiagnostic { + /** + * The warning message suitable for webpack diagnostics. + */ message: string; + /** + * The original source file resolved from the CSS source map. + */ sourceFile?: string | undefined; + /** + * The original source line resolved from the CSS source map. + */ sourceLine?: number | undefined; + /** + * The original source column resolved from the CSS source map. + */ sourceColumn?: number | undefined; } +/** + * Options for {@link processTasmCSSDiagnostics}. + * + * @public + */ +export interface ProcessTasmCSSDiagnosticsOptions { + /** + * The raw `css_diagnostics` value returned by TASM. + */ + cssDiagnostics: unknown; + /** + * The webpack compilation containing the main CSS asset. + */ + compilation: Compilation; + /** + * The webpack compiler context used to resolve relative source paths. + */ + context: string; + /** + * A mutable set used to skip diagnostics that were already emitted. + */ + emittedWarnings?: Set | undefined; + /** + * A file existence check used before attaching a mapped source location. + */ + fileExists?: ((path: string) => boolean) | undefined; +} + +/** + * Parses, source-map-resolves, and deduplicates TASM CSS diagnostics. + * + * @public + */ +export function processTasmCSSDiagnostics({ + cssDiagnostics, + compilation, + context, + emittedWarnings, + fileExists, +}: ProcessTasmCSSDiagnosticsOptions): ResolvedTasmCSSDiagnostic[] { + const diagnostics = extractTasmCSSDiagnostics(cssDiagnostics); + if (diagnostics.length === 0) { + return []; + } + + const resolveOptions: Parameters[0] = { + cssDiagnostics: diagnostics, + mainCSSSourceMap: getMainCSSSourceMap(compilation), + context, + }; + if (fileExists !== undefined) { + resolveOptions.fileExists = fileExists; + } + + return dedupeTasmCSSDiagnostics( + resolveTasmCSSDiagnostics(resolveOptions), + emittedWarnings, + ); +} + export function extractTasmCSSDiagnostics(value: unknown): TasmCSSDiagnostic[] { if (typeof value !== 'string' || value.trim() === '') { return []; @@ -108,7 +204,7 @@ export function resolveTasmCSSDiagnostics({ export function dedupeTasmCSSDiagnostics( diagnostics: T[], - seen: Set = new Set(), + seen: Set = new Set(), ): T[] { return diagnostics.filter(diagnostic => { const line = diagnostic.sourceLine ?? diagnostic.line; @@ -129,6 +225,35 @@ export function dedupeTasmCSSDiagnostics( }); } +type Asset = ReturnType[number]; + +function normalizeCSSSourceMap( + sourceMap: ReturnType | undefined, +): CSS.CSSSourceMap | undefined { + if (!sourceMap || Array.isArray(sourceMap)) { + return undefined; + } + + return sourceMap; +} + +export 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; +} + function normalizeTasmCSSDiagnostic(value: unknown): TasmCSSDiagnostic | null { if (!isRecord(value)) { return null; diff --git a/packages/webpack/template-webpack-plugin/src/index.ts b/packages/webpack/template-webpack-plugin/src/index.ts index bf8378a80f..4fb42f7a52 100644 --- a/packages/webpack/template-webpack-plugin/src/index.ts +++ b/packages/webpack/template-webpack-plugin/src/index.ts @@ -19,6 +19,12 @@ export type { } from './LynxTemplatePlugin.js'; export { LynxEncodePlugin } from './LynxEncodePlugin.js'; export type { LynxEncodePluginOptions } from './LynxEncodePlugin.js'; +export { processTasmCSSDiagnostics } from './cssDiagnostics.js'; +export type { + ProcessTasmCSSDiagnosticsOptions, + ResolvedTasmCSSDiagnostic, + TasmCSSDiagnostic, +} from './cssDiagnostics.js'; export { WebEncodePlugin } from './WebEncodePlugin.js'; export { LynxDebugMetadataPlugin, diff --git a/packages/webpack/template-webpack-plugin/test/cases/code-splitting/initial-css-order/rspack.config.js b/packages/webpack/template-webpack-plugin/test/cases/code-splitting/initial-css-order/rspack.config.js index 52dff759f9..a30e21ab4f 100644 --- a/packages/webpack/template-webpack-plugin/test/cases/code-splitting/initial-css-order/rspack.config.js +++ b/packages/webpack/template-webpack-plugin/test/cases/code-splitting/initial-css-order/rspack.config.js @@ -1,6 +1,6 @@ import { CssExtractRspackPlugin } from '@rspack/core'; -import { LynxEncodePlugin, LynxTemplatePlugin } from '../../../../src'; +import { LynxEncodePlugin, LynxTemplatePlugin } from '../../../../lib/index.js'; /** @type {import('@rspack/core').Configuration} */ export default { diff --git a/packages/webpack/template-webpack-plugin/test/cssDiagnostics.test.ts b/packages/webpack/template-webpack-plugin/test/cssDiagnostics.test.ts index 7be0975fe8..9542e2e442 100644 --- a/packages/webpack/template-webpack-plugin/test/cssDiagnostics.test.ts +++ b/packages/webpack/template-webpack-plugin/test/cssDiagnostics.test.ts @@ -2,12 +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 { describe, expect, test } from '@rstest/core'; +import type { Compilation } from 'webpack'; import type { CSSSourceMap } from '@lynx-js/css-serializer'; import { dedupeTasmCSSDiagnostics, extractTasmCSSDiagnostics, + processTasmCSSDiagnostics, resolveTasmCSSDiagnostics, } from '../src/cssDiagnostics.js'; @@ -170,4 +172,51 @@ describe('cssDiagnostics', () => { ]); expect(dedupeTasmCSSDiagnostics([diagnostic], seen)).toEqual([]); }); + + test('process tasm css diagnostics from raw value to deduped diagnostics', () => { + 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 seen = new Set(); + const rawDiagnostics = + '[{"type":"property","name":"unknown-prop","line":2,"column":10},{"type":"property","name":"unknown-prop","line":2,"column":10}]'; + + expect( + processTasmCSSDiagnostics({ + cssDiagnostics: rawDiagnostics, + compilation: { + getAssets: () => [ + { + name: 'main.css', + source: { + map: () => sourceMap, + }, + }, + ], + } as Compilation, + context: '/workspace/app', + emittedWarnings: seen, + fileExists: () => true, + }), + ).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, + }, + ]); + }); });