From f16d8ae0459a0055f29ab57fa26f3832a6b1b995 Mon Sep 17 00:00:00 2001 From: Linpeng Zhang <44171495+linpengzhang@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:39:02 +0100 Subject: [PATCH 1/8] fix: resolve sourcemap positions to the correct stylesheet A CSS asset is a concatenation of the stylesheets that went into it, but the individual sourcemaps were combined with merge-source-map, which composes maps as a chain of transforms. Composition is the wrong operation here: the stylesheets sit side by side rather than each rewriting the previous one, so the combined map named every source while resolving every position back to the first one. Each stylesheet's mappings are now translated into the coordinate space it actually occupies in the asset, found by locating its compiled CSS in the output. Tracking the column as well as the line matters, because Vite can place one stylesheet's last line and the next one's first line on a single physical line. CSS minification is also disabled while the plugin is active. Vite minifies the asset after the plugin has recorded those positions, collapsing it onto a few lines and invalidating all of them; this was the other reason every lookup landed on the first source. Set disableCssMinify: false to opt out. Locating stylesheets by position also removes the need to proxy the augmentChunkHash hook of vite:css-post to predict asset names, which is what produced .map files whose hash did not match the asset they belonged to on Vite 8. Emission now runs after vite:css-post and reads the real asset name, so the map is a correct sibling and the sourceMappingURL comment is injected. The existing tests asserted only that a .map file existed and was referenced, which held while the mappings inside it were meaningless. The new integration tests resolve known selectors back through the map and assert the mappings are spread across every source. Fixes #7 Fixes #8 Fixes #13 Fixes #16 Co-authored-by: Cursor --- README.md | 40 ++- package-lock.json | 13 +- package.json | 7 +- src/integration.test.ts | 94 +++++++ src/plugin.test.ts | 160 +++++++----- src/plugin.ts | 439 ++++++++++++++++---------------- src/types/merge-source-map.d.ts | 7 - 7 files changed, 443 insertions(+), 317 deletions(-) delete mode 100644 src/types/merge-source-map.d.ts diff --git a/README.md b/README.md index 06dd9a4..bab2abe 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A Vite plugin for handling CSS sourcemaps. This plugin ensures that CSS sourcema - Supports custom sourcemap file locations - Configurable sourcemap URL generation - Works with Vite's build process -- Compatible with Vite 5.x and 6.x +- Compatible with Vite 5.x through 8.x ## Installation @@ -55,17 +55,22 @@ cssSourcemap({ // Custom function to generate sourcemap URLs getURL: (fileName) => `sourcemaps/${fileName}`, + + // Keep Vite's CSS minification on (default: false, i.e. minification is + // disabled while the plugin is active) + disableCssMinify: true, }); ``` ### Options -| Option | Type | Default | Description | -| ------------ | ------------------------------ | ------------------------ | ------------------------------------------ | -| `enabled` | `boolean` | `true` | Enable or disable the plugin | -| `extensions` | `string[]` | `['.css', '.scss']` | File extensions to process | -| `folder` | `string` | `''` | Custom folder for sourcemap files | -| `getURL` | `(fileName: string) => string` | `(fileName) => fileName` | Custom function to generate sourcemap URLs | +| Option | Type | Default | Description | +| ------------------ | ------------------------------ | ------------------------ | ---------------------------------------------------------- | +| `enabled` | `boolean` | `true` | Enable or disable the plugin | +| `extensions` | `string[]` | `['.css', '.scss']` | File extensions to process | +| `folder` | `string` | `''` | Custom folder for sourcemap files | +| `getURL` | `(fileName: string) => string` | `(fileName) => fileName` | Custom function to generate sourcemap URLs | +| `disableCssMinify` | `boolean` | `true` | Disable Vite's CSS minification while the plugin is active | ## Examples @@ -140,17 +145,32 @@ This plugin hooks into Vite's build process to: The plugin works by: -1. Using the `transform` hook to process CSS files and generate sourcemaps -2. Using the `generateBundle` hook to ensure sourcemaps are properly emitted -3. It observes `vite:css-post` plugin, specifically the `augmentChunkHash` hook to obtain the future id of the file. +1. Using the `transform` hook to capture each stylesheet after Vite has + compiled it, along with whatever sourcemap the preprocessor produced +2. Using the `generateBundle` hook, ordered after `vite:css-post`, to find + where each stylesheet was placed inside the concatenated CSS asset +3. Translating each stylesheet's mappings into the asset's coordinate space and + emitting the combined sourcemap alongside it 4. Allows configuring the sourcemap URL based on the provided options +### Why CSS minification is disabled + +Vite minifies a CSS asset after this plugin has recorded where each stylesheet +landed inside it. Minifying collapses the asset onto a handful of lines, which +invalidates those positions and produces a sourcemap that resolves every +position back to the first source. + +The plugin therefore turns CSS minification off by default. If you would rather +keep it on and forgo accurate sourcemaps, set `disableCssMinify: false`. + ## Compatibility This plugin is compatible with: - Vite 5.x - Vite 6.x +- Vite 7.x +- Vite 8.x (including the Rolldown-based build) ## License diff --git a/package-lock.json b/package-lock.json index c76ff2b..674387a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "vite-plugin-css-sourcemap", "version": "1.0.5", "license": "MIT", - "dependencies": { - "merge-source-map": "^1.1.0" - }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", "@commitlint/cli": "^19.8.0", @@ -5521,15 +5518,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-source-map": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz", - "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==", - "license": "MIT", - "dependencies": { - "source-map": "^0.6.1" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9745,6 +9733,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index efa8395..346113a 100644 --- a/package.json +++ b/package.json @@ -68,9 +68,9 @@ "access": "public" }, "peerDependencies": { - "vite": ">= 5.0.0", "sass": ">= 1.0.0", - "sass-embedded": ">= 1.0.0" + "sass-embedded": ">= 1.0.0", + "vite": ">= 5.0.0" }, "peerDependenciesMeta": { "sass": { @@ -100,8 +100,5 @@ "typescript": "^5.2.2", "vite": "^7.3.0", "vitest": "^3.1.1" - }, - "dependencies": { - "merge-source-map": "^1.1.0" } } diff --git a/src/integration.test.ts b/src/integration.test.ts index 2bca7ac..0287ea1 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -3,6 +3,7 @@ import { build } from 'vite'; import { resolve } from 'path'; import { readFile, readdir } from 'fs/promises'; import { rimraf } from 'rimraf'; +import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'; describe('vite-plugin-css-sourcemap integration', () => { const playgroundDir = resolve(__dirname, '../playground'); @@ -422,3 +423,96 @@ describe('vite-plugin-css-sourcemap SCSS entrypoint integration', () => { await rimraf(distDir); }); }); + +describe('vite-plugin-css-sourcemap position resolution', () => { + const playgroundDir = resolve(__dirname, '../playground'); + const distDir = resolve(playgroundDir, 'dist'); + const assetsDir = resolve(distDir, 'assets'); + + beforeEach(async () => { + await rimraf(distDir); + }); + + afterAll(async () => { + await rimraf(distDir); + }); + + // Listing every stylesheet under `sources` is not enough on its own: a + // sourcemap can name all of them and still resolve every position to the + // first one. These assertions pin the mappings themselves. + it('should resolve positions back to the stylesheet each rule came from', async () => { + await build({ + root: playgroundDir, + build: { outDir: 'dist' }, + configFile: false, + logLevel: 'error', + plugins: [(await import('./index')).default()], + }); + + const files = await readdir(assetsDir); + const cssFile = files.find((file) => file.endsWith('.css'))!; + const css = await readFile(resolve(assetsDir, cssFile), 'utf-8'); + const map = JSON.parse( + await readFile(resolve(assetsDir, `${cssFile}.map`), 'utf-8'), + ); + + const tracer = new TraceMap(map); + const lines = css.split('\n'); + + const expectations: [string, string][] = [ + ['btn', 'components/button.css'], + ['card', 'components/card.css'], + ['form', 'components/form.css'], + ['modal', 'components/modal.css'], + ['fade', 'animations.css'], + ]; + + for (const [selector, expectedSource] of expectations) { + const line = lines.findIndex( + (text) => text.includes(selector) && text.includes('{'), + ); + expect( + line, + `no rule containing "${selector}" in the built CSS`, + ).toBeGreaterThan(-1); + + const position = originalPositionFor(tracer, { + line: line + 1, + column: lines[line]!.indexOf(selector), + }); + + expect( + position.source, + `"${selector}" resolved to the wrong stylesheet`, + ).toContain(expectedSource); + } + }); + + it('should spread mappings across every stylesheet rather than collapsing onto one', async () => { + await build({ + root: playgroundDir, + build: { outDir: 'dist' }, + configFile: false, + logLevel: 'error', + plugins: [(await import('./index')).default()], + }); + + const files = await readdir(assetsDir); + const cssFile = files.find((file) => file.endsWith('.css'))!; + const css = await readFile(resolve(assetsDir, cssFile), 'utf-8'); + const map = JSON.parse( + await readFile(resolve(assetsDir, `${cssFile}.map`), 'utf-8'), + ); + + const tracer = new TraceMap(map); + const resolved = new Set(); + + for (let line = 1; line <= css.split('\n').length; line++) { + const { source } = originalPositionFor(tracer, { line, column: 0 }); + if (source) resolved.add(source); + } + + expect(resolved.size).toBeGreaterThan(1); + expect(resolved.size).toBe(map.sources.length); + }); +}); diff --git a/src/plugin.test.ts b/src/plugin.test.ts index 4f2c5f2..df30e5f 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -52,75 +52,105 @@ describe('vite-plugin-css-sourcemap', () => { expect(hasValidExtension('/path/to/file.less')).toBe(false); }); - it('should handle custom sourcemap URL function', () => { + it('should disable CSS minification by default', () => { + const plugin = cssSourcemap(); + + const config = callConfig(plugin); + + expect(config).toEqual({ build: { cssMinify: false } }); + }); + + it('should leave CSS minification alone when opted out', () => { + const plugin = cssSourcemap({ disableCssMinify: false }); + + expect(callConfig(plugin)).toBeUndefined(); + }); + + it('should handle custom sourcemap URL function', async () => { const customPrefix = '/custom-sourcemaps/'; - const getURL = (fileName: string) => `${customPrefix}${fileName}`; - - const plugin = cssSourcemap({ getURL }); - - const mockContext = { - getCombinedSourcemap: () => ({ toString: () => '{}' }), - emitFile: vi.fn().mockReturnValue('referenceId'), - }; - - const cssFile = '/path/to/styles.css'; - if (plugin.transform && typeof plugin.transform === 'function') { - plugin.transform.call( - mockContext as any, - 'body { color: red; }', - cssFile, - ); - - expect(mockContext.emitFile).toHaveBeenCalledWith( - expect.objectContaining({ - name: expect.stringContaining('.map'), - }), - ); - } + const plugin = cssSourcemap({ + getURL: (fileName: string) => `${customPrefix}${fileName}`, + }); + + const asset = await runPluginOverSingleStylesheet(plugin); + + expect(asset.source).toContain( + `/*# sourceMappingURL=${customPrefix}styles.css.map */`, + ); }); - // TODO: Fix this test - it.skip('should handle custom folder option', () => { + it('should handle custom folder option', async () => { const customFolder = 'custom-sourcemaps'; const plugin = cssSourcemap({ folder: customFolder }); - const mockBundle = { - 'styles.css': { - type: 'asset', - fileName: 'styles.css', - source: 'body { color: red; }', - name: 'styles.css', - needsCodeReference: false, - names: [], - originalFileName: 'styles.css', - originalFileNames: ['styles.css'], - } as OutputAsset, - }; - const mockMap = { - finalSourceMap: { - version: 3, - sources: [ - 'vite-plugin-css-sourcemap/playground/src/styles/main.css', - 'vite-plugin-css-sourcemap/playground/src/styles/second.css', - ], - names: [], - mappings: 'AAAA,CAAC,CAAC', - sourcesContent: ['body { color: red; }'], - }, - }; - const emitFile = vi.fn().mockReturnValue('referenceId'); - - const mockContext = { - emitFile, - } as unknown as PluginContext; - - if (plugin.generateBundle && typeof plugin.generateBundle === 'function') { - plugin.generateBundle.call(mockContext, {} as any, mockBundle, false); - - expect(emitFile).toHaveBeenCalledWith( - expect.objectContaining({ - fileName: expect.stringContaining(`${customFolder}/`), - }), - ); - } + + const { emitFile } = await runPluginOverSingleStylesheet(plugin); + + expect(emitFile).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: expect.stringContaining(`${customFolder}/`), + }), + ); + }); + + it('should map a position back to the stylesheet it came from', async () => { + const plugin = cssSourcemap(); + + const { emitFile } = await runPluginOverSingleStylesheet(plugin); + const map = JSON.parse(emitFile.mock.calls[0]![0].source); + + expect(map.sources).toEqual([CSS_FILE]); + expect(map.mappings).not.toBe(''); }); }); + +const CSS_FILE = '/path/to/styles.css'; +const CSS_SOURCE = 'body {\n color: red;\n}'; + +function callConfig(plugin: ReturnType) { + const config = plugin.config; + if (typeof config !== 'function') throw new Error('expected a config hook'); + return config.call({} as any, {} as any, {} as any); +} + +/** + * Drives the plugin the way Vite does: transform each stylesheet, then hand + * generateBundle the concatenated asset those stylesheets produced. + */ +async function runPluginOverSingleStylesheet( + plugin: ReturnType, +) { + const emitFile = vi.fn().mockReturnValue('referenceId'); + const context = { + emitFile, + warn: vi.fn(), + getCombinedSourcemap: () => ({ mappings: '', sources: [] }), + } as unknown as PluginContext; + + const transform = plugin.transform; + if (typeof transform !== 'function') throw new Error('expected a transform'); + await transform.call(context as any, CSS_SOURCE, CSS_FILE); + + const asset = { + type: 'asset', + fileName: 'styles.css', + source: CSS_SOURCE, + name: 'styles.css', + needsCodeReference: false, + names: [], + originalFileName: 'styles.css', + originalFileNames: [], + } as unknown as OutputAsset; + + const generateBundle = plugin.generateBundle; + if (typeof generateBundle !== 'object' || !generateBundle?.handler) { + throw new Error('expected a generateBundle hook'); + } + await generateBundle.handler.call( + context, + {} as any, + { 'styles.css': asset }, + false, + ); + + return Object.assign(asset, { emitFile }); +} diff --git a/src/plugin.ts b/src/plugin.ts index 9326ce3..56b5ebf 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,15 +1,10 @@ import path from 'node:path'; import fs from 'node:fs'; import type { Plugin } from 'vite'; -import mergeSourceMap from 'merge-source-map'; +import { decode, encode } from '@jridgewell/sourcemap-codec'; import type { NormalizedOutputOptions, OutputBundle, - PluginContext, - PreRenderedAsset, - RenderedChunk, - InputOption, - OutputOptions, ExistingRawSourceMap, } from 'rollup'; import { EXTENSIONS, PLUGIN_NAME } from './constants'; @@ -18,8 +13,9 @@ import { hasValidExtension } from './utils'; /** * Checks if a sourcemap is empty (has no meaningful mappings or sources) */ -function isEmptySourcemap(map: ExistingRawSourceMap): boolean { +function isEmptySourcemap(map: ExistingRawSourceMap | null): boolean { return ( + !map || !map.mappings || map.mappings === '' || !map.sources || @@ -27,68 +23,51 @@ function isEmptySourcemap(map: ExistingRawSourceMap): boolean { ); } -/** - * Generates an identity sourcemap for a source file. - * This maps each line to itself in the original file. - */ -function generateIdentitySourcemap( - code: string, - filename: string, -): ExistingRawSourceMap { - const lines = code.split('\n'); - // VLQ encoding for identity map: each line maps to the same line in source - // AAAA = column 0, source 0, source line 0, source column 0 - // For subsequent lines, we only need to indicate "next line, same column offset" - // AACA = column 0, source 0, source line +1, source column 0 - const mappings = lines.map((_, i) => (i === 0 ? 'AAAA' : 'AACA')).join(';'); - - return { - version: 3, - file: path.basename(filename), - sources: [filename], - sourcesContent: [code], - names: [], - mappings, - }; -} - export interface CssSourcemapOptions { extensions?: string[]; enabled?: boolean; folder?: string; getURL?: (fileName: string) => string; + /** + * Disable CSS minification while the plugin is active. + * + * Vite minifies a CSS asset after this plugin has recorded where each + * stylesheet landed inside it, which invalidates every offset. Leaving + * minification on therefore produces a sourcemap that resolves every + * position to the first source. + * + * @default true + */ + disableCssMinify?: boolean; } -function extractFileName(input: InputOption) { - if (typeof input === 'string') { - return path.parse(input).name; - } - - if (Array.isArray(input) && input[0]) { - return path.parse(input[0]).name; - } - - return path.parse(Object.keys(input)[0]!).name; +/** + * A concatenated sourcemap. Declared locally rather than reusing Rollup's + * `ExistingRawSourceMap` because the spec allows a null entry in + * `sourcesContent` for a source whose text is unavailable, which that type + * does not model. + */ +interface ConcatenatedSourceMap { + version: 3; + file: string; + sources: string[]; + sourcesContent: (string | null)[]; + names: string[]; + mappings: string; } -function extractFullPath(outputOptions: OutputOptions, fileName: string) { - let fullPath = fileName; - - if (outputOptions && outputOptions.assetFileNames) { - let assetDir: string | null = null; - - if (typeof outputOptions.assetFileNames === 'string') { - assetDir = path.dirname(outputOptions.assetFileNames); - } else if (typeof outputOptions.assetFileNames === 'function') { - // TODO: Implement this - } - - if (assetDir && assetDir !== '.') { - fullPath = path.join(assetDir, fileName); - } - } +/** A stylesheet as it appeared once Vite finished compiling it. */ +interface CompiledStylesheet { + code: string; + map: ExistingRawSourceMap | null; +} - return fullPath; +/** Where a compiled stylesheet ended up inside the concatenated CSS asset. */ +interface PlacedStylesheet { + id: string; + stylesheet: CompiledStylesheet; + line: number; + column: number; } export default function cssSourcemapPlugin( @@ -99,6 +78,7 @@ export default function cssSourcemapPlugin( enabled = true, folder = '', getURL = (fileName: string) => fileName, + disableCssMinify = true, } = options; if (!enabled) { @@ -108,11 +88,7 @@ export default function cssSourcemapPlugin( }; } - const assetToId = new Map(); - const idToMap = new Map(); - let templateName: string; - let outputOptions: OutputOptions | null = null; - let willAugmentChunkHash = true; + const compiled = new Map(); let sassCompiler: any = null; /** @@ -158,9 +134,9 @@ export default function cssSourcemapPlugin( if (result.sourceMap) { return result.sourceMap as ExistingRawSourceMap; } - } catch (e) { + } catch { // Sass compilation failed (syntax error, file not found, etc.) - // Will fall back to identity sourcemap + // Will fall back to an identity mapping. } return null; } @@ -169,177 +145,204 @@ export default function cssSourcemapPlugin( name: PLUGIN_NAME, apply: 'build', - buildStart(options) { - const viteCSSPlugin = options.plugins.find( - (plugin) => plugin.name === 'vite:css-post', - ); + config() { + if (!disableCssMinify) return; + return { build: { cssMinify: false as const } }; + }, - if (!viteCSSPlugin) { - throw new Error('vite:css-post plugin not found.'); + // Left at the default order so that this runs after `vite:css` has + // compiled a stylesheet, but before `vite:css-post` replaces it with a + // JavaScript module. + async transform(code, id) { + if (!hasValidExtension(id, extensions)) return null; + if (!code.trim()) return null; + + let map = this.getCombinedSourcemap() as ExistingRawSourceMap | null; + + if ( + isEmptySourcemap(map) && + (id.endsWith('.scss') || id.endsWith('.sass')) + ) { + // Compile the file ourselves so that @use/@import partials are + // represented, which the combined sourcemap does not cover when SCSS + // is a direct rollup entrypoint. + map = await compileSCSS(id); } - templateName = extractFileName(options.input); - - const augmentChunkHashHandler = { - apply: function ( - target: (this: PluginContext, chunk: RenderedChunk) => string | void, - thisArg: PluginContext, - argumentsList: any[], - ) { - const [chunk] = argumentsList; - const result = Reflect.apply(target, thisArg, argumentsList); + compiled.set(id, { + code, + map: isEmptySourcemap(map) ? null : map, + }); - if (!result) { - return result; - } + return null; + }, - for (const id of chunk.moduleIds) { - if (hasValidExtension(id, extensions)) { - if (assetToId.has(result)) { - assetToId.get(result)?.push(id); - } else { - assetToId.set(result, [id]); - } - } + generateBundle: { + // `vite:css-post` adds the CSS asset to the bundle from its own + // generateBundle, so this has to run after it. + order: 'post', + handler(_options: NormalizedOutputOptions, bundle: OutputBundle) { + for (const [fileName, asset] of Object.entries(bundle)) { + if (asset.type !== 'asset' || !fileName.endsWith('.css')) continue; + + const css = String(asset.source); + const placed = locateStylesheets(css, compiled); + + if (placed.length === 0) { + this.warn( + `No compiled stylesheet could be located inside ${fileName}, so no ` + + `sourcemap was emitted. This usually means the asset was minified ` + + `or rewritten after the plugin recorded it.`, + ); + continue; } - return result; - }, - }; + const map = buildConcatenatedSourcemap(placed, fileName); + const mapFileName = `${asset.fileName}.map`; + const mapBaseName = path.basename(mapFileName); - const currentMethod = viteCSSPlugin['augmentChunkHash']!; - const augmentChunkHashProxy = new Proxy( - currentMethod, - augmentChunkHashHandler, - ); + this.emitFile({ + type: 'asset', + fileName: path.join(path.dirname(mapFileName), folder, mapBaseName), + source: JSON.stringify(map), + }); - Object.defineProperty(viteCSSPlugin, 'augmentChunkHash', { - value: augmentChunkHashProxy, - }); + asset.source = `${css}\n/*# sourceMappingURL=${getURL(mapBaseName)} */`; + } + }, }, + }; +} - outputOptions(options: OutputOptions) { - outputOptions = options; - - if (typeof options.entryFileNames === 'string') { - willAugmentChunkHash = options.entryFileNames.includes('[hash]'); - } else if (typeof options.entryFileNames === 'function') { - // TODO: Implement this - } - - return options; - }, +/** + * Finds where each compiled stylesheet was placed inside the concatenated CSS + * asset. Anything that cannot be found is skipped, which keeps a stylesheet + * that some other plugin rewrote from corrupting its neighbours' offsets. + */ +function locateStylesheets( + css: string, + compiled: Map, +): PlacedStylesheet[] { + const placed: PlacedStylesheet[] = []; + + for (const [id, stylesheet] of compiled) { + const offset = css.indexOf(stylesheet.code.trim()); + if (offset === -1) continue; + + const preceding = css.slice(0, offset); + placed.push({ + id, + stylesheet, + line: preceding.split('\n').length - 1, + // Vite can concatenate one stylesheet's last line and the next + // stylesheet's first onto a single physical line, so the offset within + // that line is what keeps their mappings apart. + column: offset - (preceding.lastIndexOf('\n') + 1), + }); + } - async renderChunk(_: string, chunk: RenderedChunk) { - if (willAugmentChunkHash) return null; + return placed.sort((a, b) => a.line - b.line || a.column - b.column); +} - for (const id of chunk.moduleIds) { - if (hasValidExtension(id, extensions)) { - // Use the chunk name to derive the asset path, not templateName - // This fixes the issue where SCSS entrypoints have different names - const fullPath = extractFullPath(outputOptions!, chunk.name); +/** + * Builds a single sourcemap for a concatenated CSS asset by shifting each + * stylesheet's own mappings into the position it occupies in the asset. + * + * Concatenation places stylesheets side by side, so the individual maps cannot + * be composed the way a chain of transforms would be; each one has to be + * translated into the asset's coordinate space instead. + */ +function buildConcatenatedSourcemap( + placed: PlacedStylesheet[], + fileName: string, +): ConcatenatedSourceMap { + const sources: string[] = []; + const sourcesContent: (string | null)[] = []; + + const sourceIndex = (source: string, content: string | null): number => { + const existing = sources.indexOf(source); + if (existing !== -1) return existing; + sources.push(source); + sourcesContent.push(content); + return sources.length - 1; + }; - if (assetToId.has(fullPath)) { - assetToId.get(fullPath)?.push(id); - } else { - assetToId.set(fullPath, [id]); - } - } - } + const lines: [number, number, number, number][][] = []; + const addSegment = ( + line: number, + segment: [number, number, number, number], + ) => { + while (lines.length <= line) lines.push([]); + lines[line]!.push(segment); + }; - return null; - }, + for (const { id, stylesheet, line, column } of placed) { + // A stylesheet owns exactly the lines its compiled CSS occupies. Segments + // past that would land inside the next stylesheet and win its lookups. + const span = stylesheet.code.trim().split('\n').length; + // Only the first line is offset horizontally; later lines start at column 0. + const shift = (index: number, col: number) => + index === 0 ? column + col : col; + const decoded = stylesheet.map ? decode(stylesheet.map.mappings) : null; + + if (decoded?.some((segments) => segments.length > 0)) { + const remapped = stylesheet.map!.sources.map((source, index) => + sourceIndex( + resolveSource(id, source), + stylesheet.map!.sourcesContent?.[index] ?? null, + ), + ); - async transform(code, id) { - if (hasValidExtension(id, extensions)) { - const fileName = path.parse(id).name.replace('.module', ''); - let sourcemap = this.getCombinedSourcemap() as ExistingRawSourceMap; - - // If the combined sourcemap is empty (no prior transforms generated one), - // try to compile SCSS ourselves to get the sourcemap with all partials - if (isEmptySourcemap(sourcemap)) { - if (id.endsWith('.scss') || id.endsWith('.sass')) { - // For SCSS/Sass files, compile to get proper sourcemap with all @imported partials - const scssSourcemap = await compileSCSS(id); - if (scssSourcemap && !isEmptySourcemap(scssSourcemap)) { - sourcemap = scssSourcemap; - } else { - sourcemap = generateIdentitySourcemap(code, id); - } - } else { - // For plain CSS, generate an identity sourcemap - sourcemap = generateIdentitySourcemap(code, id); - } + decoded.slice(0, span).forEach((segments, index) => { + for (const segment of segments) { + if (segment.length < 4) continue; + addSegment(line + index, [ + shift(index, segment[0]), + remapped[segment[1]!] ?? 0, + segment[2]!, + segment[3]!, + ]); } - - const referenceIdMap = this.emitFile({ - type: 'asset', - name: `${fileName}.map`, - source: JSON.stringify(sourcemap), - }); - - idToMap.set(id, referenceIdMap); - - return { - code: code, - map: sourcemap, - }; + }); + } else { + // Without a map the compiled CSS is line-for-line with its source, except + // where a plugin generated CSS the source never spelled out. Clamping + // keeps those lines attributed to the file that produced them rather than + // pointing past its end. + const index = sourceIndex(id, stylesheet.code); + const lastLine = Math.max(0, countLines(id) - 1); + for (let i = 0; i < span; i++) { + addSegment(line + i, [shift(i, 0), index, Math.min(i, lastLine), 0]); } + } + } - return null; - }, - - async generateBundle(_: NormalizedOutputOptions, bundle: OutputBundle) { - const fullPath = extractFullPath(outputOptions!, templateName); - - for (const [fileName, asset] of Object.entries(bundle)) { - if (asset.type === 'asset' && fileName.endsWith('.css')) { - // Try multiple key formats to find the source file IDs - // This handles both hashed asset names and non-hashed chunk names - const fileNameWithoutExt = fileName.replace(/\.css$/, ''); - const sourceFileIds = - assetToId.get(fileName) || - assetToId.get(fileNameWithoutExt) || - assetToId.get(fullPath) || - []; - const newMapFileName = `${asset.fileName}.map`; - - const finalSourceMap = sourceFileIds.reduce( - (mergedMap: string | object | null, refId: string) => { - const mapReferenceId = idToMap.get(refId); - if (!mapReferenceId) return mergedMap; - - const mapFileName = this.getFileName(mapReferenceId); - const generatedMap = (bundle[mapFileName] as PreRenderedAsset) - ?.source; - - delete bundle[mapFileName]; - - return mergeSourceMap(mergedMap, generatedMap); - }, - null, - ); - - if (!finalSourceMap) { - console.warn(`No source map found for ${fileName}`); - continue; - } - - const mapReferencePath = path.basename(newMapFileName); - const outputPath = path.dirname(newMapFileName); + return { + version: 3, + file: path.basename(fileName), + sources, + sourcesContent, + names: [], + mappings: encode( + lines.map((segments) => segments.sort((a, b) => a[0] - b[0])), + ), + }; +} - this.emitFile({ - type: 'asset', - fileName: path.join(outputPath, folder, mapReferencePath), - source: - typeof finalSourceMap === 'string' - ? finalSourceMap - : JSON.stringify(finalSourceMap), - }); +function resolveSource(id: string, source: string): string { + if (path.isAbsolute(source)) return source; + try { + if (source.startsWith('file://')) return new URL(source).pathname; + } catch { + // Not a URL; treat it as a relative path below. + } + return path.resolve(path.dirname(id), source); +} - asset.source += `\n/*# sourceMappingURL=${getURL(mapReferencePath)} */`; - } - } - }, - }; +function countLines(file: string): number { + try { + return fs.readFileSync(file, 'utf-8').split('\n').length; + } catch { + return Number.POSITIVE_INFINITY; + } } diff --git a/src/types/merge-source-map.d.ts b/src/types/merge-source-map.d.ts deleted file mode 100644 index be7b331..0000000 --- a/src/types/merge-source-map.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare module 'merge-source-map' { - function mergeSourceMap( - oldMap: string | object | null, - newMap: string | object, - ): string | object; - export default mergeSourceMap; -} From 906c19cce2de0d866459d0688370113998970a9a Mon Sep 17 00:00:00 2001 From: Linpeng Zhang <44171495+linpengzhang@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:43:41 +0100 Subject: [PATCH 2/8] fix: keep stylesheets apart when they compile to identical CSS Two stylesheets can compile to byte-identical CSS, in which case both claimed the first occurrence in the asset. One of them was then unreachable through the map and its positions resolved to the other file. Each stylesheet now takes an occurrence no other has claimed. Co-authored-by: Cursor --- src/plugin.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/plugin.ts b/src/plugin.ts index 56b5ebf..d080821 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -224,10 +224,12 @@ function locateStylesheets( compiled: Map, ): PlacedStylesheet[] { const placed: PlacedStylesheet[] = []; + const claimed = new Set(); for (const [id, stylesheet] of compiled) { - const offset = css.indexOf(stylesheet.code.trim()); + const offset = findUnclaimedOffset(css, stylesheet.code.trim(), claimed); if (offset === -1) continue; + claimed.add(offset); const preceding = css.slice(0, offset); placed.push({ @@ -244,6 +246,23 @@ function locateStylesheets( return placed.sort((a, b) => a.line - b.line || a.column - b.column); } +/** + * Finds an occurrence of `needle` that no other stylesheet has taken. Two + * stylesheets can compile to byte-identical CSS, and without this they would + * both claim the first occurrence, leaving one unreachable in the map. + */ +function findUnclaimedOffset( + css: string, + needle: string, + claimed: ReadonlySet, +): number { + let offset = css.indexOf(needle); + while (offset !== -1 && claimed.has(offset)) { + offset = css.indexOf(needle, offset + 1); + } + return offset; +} + /** * Builds a single sourcemap for a concatenated CSS asset by shifting each * stylesheet's own mappings into the position it occupies in the asset. From 415fd7a9f573ab8d1da38a7d025290ec461578ee Mon Sep 17 00:00:00 2001 From: Linpeng Zhang <44171495+linpengzhang@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:04:42 +0100 Subject: [PATCH 3/8] fix: map stylesheets that reference assets or contain another's CSS Three cases were still missing from the concatenated sourcemap: - A url() reference is an unresolved __VITE_ASSET__ placeholder when the plugin captures a stylesheet, and vite:css-post substitutes the hashed URL afterwards, so searching the finished asset for the captured text never matched. Any stylesheet referencing an image or font was dropped. - Claiming a start offset rather than a whole region let a stylesheet whose compiled CSS is contained in another's take a position inside it. Regions are now claimed whole, longest first. - file:// sources went through URL.pathname, which leaves a Windows path as /C:/... and keeps percent-encoding; they now use fileURLToPath. Styles from single-file components also record the file rather than the queried id. Co-authored-by: Cursor --- playground/src/assets/dot.svg | 3 + playground/src/main.ts | 5 ++ playground/src/styles/a11y.css | 10 +++ playground/src/styles/hero.css | 11 +++ playground/src/styles/print.css | 4 + src/integration.test.ts | 84 +++++++++++++++++++++ src/plugin.ts | 127 ++++++++++++++++++++++++++------ 7 files changed, 221 insertions(+), 23 deletions(-) create mode 100644 playground/src/assets/dot.svg create mode 100644 playground/src/styles/a11y.css create mode 100644 playground/src/styles/hero.css create mode 100644 playground/src/styles/print.css diff --git a/playground/src/assets/dot.svg b/playground/src/assets/dot.svg new file mode 100644 index 0000000..a17b704 --- /dev/null +++ b/playground/src/assets/dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/playground/src/main.ts b/playground/src/main.ts index a7c68ed..005af5c 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -18,6 +18,11 @@ import './styles/components/modal.css'; // Utility styles import './styles/utilities.css'; import './styles/animations.css'; +import './styles/a11y.css'; +import './styles/print.css'; + +// Styles that reference an emitted asset +import './styles/hero.css'; // Original styles import './styles/main.css'; diff --git a/playground/src/styles/a11y.css b/playground/src/styles/a11y.css new file mode 100644 index 0000000..e19c94f --- /dev/null +++ b/playground/src/styles/a11y.css @@ -0,0 +1,10 @@ +.skip-link { + left: -999px; +} + +/* Duplicated verbatim from print.css, which makes that stylesheet's compiled + CSS a substring of this one's. */ +.visually-hidden { + position: absolute; + clip: rect(0 0 0 0); +} diff --git a/playground/src/styles/hero.css b/playground/src/styles/hero.css new file mode 100644 index 0000000..bbe9f56 --- /dev/null +++ b/playground/src/styles/hero.css @@ -0,0 +1,11 @@ +/* Referencing an asset means this stylesheet still holds a Vite placeholder + when the plugin captures it, and the real URL is substituted afterwards. */ +.hero { + background-image: url('../assets/dot.svg'); + padding: 2rem; +} + +.hero-caption { + font-size: 0.875rem; + color: #666; +} diff --git a/playground/src/styles/print.css b/playground/src/styles/print.css new file mode 100644 index 0000000..cb2c7e2 --- /dev/null +++ b/playground/src/styles/print.css @@ -0,0 +1,4 @@ +.visually-hidden { + position: absolute; + clip: rect(0 0 0 0); +} diff --git a/src/integration.test.ts b/src/integration.test.ts index 0287ea1..33e1097 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -515,4 +515,88 @@ describe('vite-plugin-css-sourcemap position resolution', () => { expect(resolved.size).toBeGreaterThan(1); expect(resolved.size).toBe(map.sources.length); }); + + // A `url()` is still an unresolved placeholder when the plugin captures the + // stylesheet, and `vite:css-post` substitutes the hashed URL afterwards, so + // looking for the captured text verbatim never finds it. Without allowing + // for that, every rule in a stylesheet using a font or an image is dropped. + it('should map stylesheets that reference an emitted asset', async () => { + await build({ + root: playgroundDir, + // Emit the asset as a file rather than inlining it, which is what + // happens to any real font or image over Vite's inlining threshold. + build: { outDir: 'dist', assetsInlineLimit: 0 }, + configFile: false, + logLevel: 'error', + plugins: [(await import('./index')).default()], + }); + + const files = await readdir(assetsDir); + const cssFile = files.find((file) => file.endsWith('.css'))!; + const css = await readFile(resolve(assetsDir, cssFile), 'utf-8'); + const map = JSON.parse( + await readFile(resolve(assetsDir, `${cssFile}.map`), 'utf-8'), + ); + + expect(css).toContain('url('); + expect(css).not.toContain('__VITE_ASSET__'); + expect( + map.sources.some((source: string) => source.includes('hero.css')), + ).toBe(true); + + const tracer = new TraceMap(map); + const lines = css.split('\n'); + const line = lines.findIndex((text) => text.includes('.hero-caption')); + expect(line, 'no .hero-caption rule in the built CSS').toBeGreaterThan(-1); + + expect( + originalPositionFor(tracer, { + line: line + 1, + column: lines[line]!.indexOf('.hero-caption'), + }).source, + ).toContain('hero.css'); + }); + + // Claiming a start offset rather than a whole region lets a stylesheet whose + // CSS is contained in another's take a position inside it, so the containing + // stylesheet either goes missing or inherits the shorter one's coverage. + it('should keep stylesheets apart when one contains the other', async () => { + await build({ + root: playgroundDir, + build: { outDir: 'dist' }, + configFile: false, + logLevel: 'error', + plugins: [(await import('./index')).default()], + }); + + const files = await readdir(assetsDir); + const cssFile = files.find((file) => file.endsWith('.css'))!; + const css = await readFile(resolve(assetsDir, cssFile), 'utf-8'); + const map = JSON.parse( + await readFile(resolve(assetsDir, `${cssFile}.map`), 'utf-8'), + ); + + const tracer = new TraceMap(map); + const resolved = new Set(); + for (let line = 1; line <= css.split('\n').length; line++) { + const { source } = originalPositionFor(tracer, { line, column: 0 }); + if (source) resolved.add(source); + } + + for (const stylesheet of ['a11y.css', 'print.css']) { + expect( + [...resolved].some((source) => source.includes(stylesheet)), + `${stylesheet} is unreachable through the sourcemap`, + ).toBe(true); + } + + const lines = css.split('\n'); + const line = lines.findIndex((text) => text.includes('.skip-link')); + expect( + originalPositionFor(tracer, { + line: line + 1, + column: lines[line]!.indexOf('.skip-link'), + }).source, + ).toContain('a11y.css'); + }); }); diff --git a/src/plugin.ts b/src/plugin.ts index d080821..4b32c50 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; import type { Plugin } from 'vite'; import { decode, encode } from '@jridgewell/sourcemap-codec'; import type { @@ -58,6 +59,8 @@ interface ConcatenatedSourceMap { /** A stylesheet as it appeared once Vite finished compiling it. */ interface CompiledStylesheet { + /** The file the stylesheet lives in, without Vite's module query. */ + sourcePath: string; code: string; map: ExistingRawSourceMap | null; } @@ -70,6 +73,15 @@ interface PlacedStylesheet { column: number; } +/** A region of the concatenated asset that one stylesheet occupies. */ +interface AssetRegion { + start: number; + end: number; +} + +/** Stand-in Vite leaves in CSS for an asset whose final URL isn't known yet. */ +const ASSET_PLACEHOLDER = /__VITE(?:_PUBLIC)?_ASSET__[\w$]+__/g; + export default function cssSourcemapPlugin( options: CssSourcemapOptions = {}, ): Plugin { @@ -169,7 +181,11 @@ export default function cssSourcemapPlugin( map = await compileSCSS(id); } + // A single-file component hands over its styles under an id whose + // extension sits in the query, e.g. `App.vue?vue&type=style&lang.css`, + // so the path has to be recovered separately for `sources`. compiled.set(id, { + sourcePath: id.split('?')[0] ?? id, code, map: isEmptySourcemap(map) ? null : map, }); @@ -224,22 +240,28 @@ function locateStylesheets( compiled: Map, ): PlacedStylesheet[] { const placed: PlacedStylesheet[] = []; - const claimed = new Set(); + const claimed: AssetRegion[] = []; - for (const [id, stylesheet] of compiled) { - const offset = findUnclaimedOffset(css, stylesheet.code.trim(), claimed); - if (offset === -1) continue; - claimed.add(offset); + // Longest first, so that a stylesheet whose CSS contains another's claims its + // full region before the shorter one can take a position inside it. + const byLengthDescending = [...compiled.entries()].sort( + ([, a], [, b]) => b.code.trim().length - a.code.trim().length, + ); - const preceding = css.slice(0, offset); + for (const [, stylesheet] of byLengthDescending) { + const region = findUnclaimedRegion(css, stylesheet.code.trim(), claimed); + if (!region) continue; + claimed.push(region); + + const preceding = css.slice(0, region.start); placed.push({ - id, + id: stylesheet.sourcePath, stylesheet, line: preceding.split('\n').length - 1, // Vite can concatenate one stylesheet's last line and the next // stylesheet's first onto a single physical line, so the offset within // that line is what keeps their mappings apart. - column: offset - (preceding.lastIndexOf('\n') + 1), + column: region.start - (preceding.lastIndexOf('\n') + 1), }); } @@ -247,20 +269,75 @@ function locateStylesheets( } /** - * Finds an occurrence of `needle` that no other stylesheet has taken. Two - * stylesheets can compile to byte-identical CSS, and without this they would - * both claim the first occurrence, leaving one unreachable in the map. + * Finds the region of the asset a stylesheet occupies, ignoring regions another + * stylesheet already occupies. + * + * Claiming whole regions rather than start offsets matters in both directions: + * two stylesheets can compile to byte-identical CSS, and one stylesheet's CSS + * can be contained in another's. Either way the loser would otherwise be + * unreachable through the map, with its coverage attributed to the winner. */ -function findUnclaimedOffset( +function findUnclaimedRegion( css: string, needle: string, - claimed: ReadonlySet, -): number { - let offset = css.indexOf(needle); - while (offset !== -1 && claimed.has(offset)) { - offset = css.indexOf(needle, offset + 1); + claimed: readonly AssetRegion[], +): AssetRegion | null { + const pattern = buildNeedlePattern(needle); + + if (!pattern) { + let start = css.indexOf(needle); + while (start !== -1) { + const region = { start, end: start + needle.length }; + if (!overlapsClaimed(region, claimed)) return region; + start = css.indexOf(needle, start + 1); + } + return null; + } + + let match = pattern.exec(css); + while (match) { + const region = { start: match.index, end: match.index + match[0].length }; + if (!overlapsClaimed(region, claimed)) return region; + pattern.lastIndex = match.index + 1; + match = pattern.exec(css); } - return offset; + return null; +} + +/** + * Builds a pattern for a stylesheet whose CSS still contains asset + * placeholders, or returns null when a plain substring search will do. + * + * A `url()` reference is still a placeholder at the point this plugin captures + * the stylesheet, and `vite:css-post` substitutes the real hashed URL + * afterwards. Searching for the captured text verbatim would therefore never + * find a stylesheet that references an image or font. + */ +function buildNeedlePattern(needle: string): RegExp | null { + ASSET_PLACEHOLDER.lastIndex = 0; + if (!ASSET_PLACEHOLDER.test(needle)) return null; + + const source = needle + .split(ASSET_PLACEHOLDER) + .map(escapeForRegExp) + // A substituted URL never contains a quote, a closing paren, or a newline, + // so this cannot run past the end of the `url()` it belongs to. + .join(`[^"')\\n]*`); + + return new RegExp(source, 'g'); +} + +function escapeForRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function overlapsClaimed( + region: AssetRegion, + claimed: readonly AssetRegion[], +): boolean { + return claimed.some( + (other) => region.start < other.end && other.start < region.end, + ); } /** @@ -349,12 +426,16 @@ function buildConcatenatedSourcemap( } function resolveSource(id: string, source: string): string { - if (path.isAbsolute(source)) return source; - try { - if (source.startsWith('file://')) return new URL(source).pathname; - } catch { - // Not a URL; treat it as a relative path below. + if (source.startsWith('file://')) { + try { + // Not `URL.pathname`, which leaves a Windows path as `/C:/...` and keeps + // any percent-encoding. + return fileURLToPath(source); + } catch { + // Not a well-formed file URL; treat it as a path below. + } } + if (path.isAbsolute(source)) return source; return path.resolve(path.dirname(id), source); } From def63385808071c44b94e5fe856d4f99300dbaf3 Mon Sep 17 00:00:00 2001 From: Linpeng Zhang <44171495+linpengzhang@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:33:49 +0100 Subject: [PATCH 4/8] fix: keep stylesheets whose asset URL carries a query or fragment Vite's asset token holds a query or fragment in a trailing $_...__ group, so url('sprite.svg#star') becomes __VITE_ASSET____$_#star__. Matching only the reference id left that suffix in the search pattern, which no longer appears once the real URL is substituted, and the stylesheet was dropped. Co-authored-by: Cursor --- playground/src/assets/sprite.svg | 3 +++ playground/src/main.ts | 1 + playground/src/styles/sprite.css | 11 +++++++++++ src/integration.test.ts | 12 +++++++++--- src/plugin.ts | 8 ++++++-- 5 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 playground/src/assets/sprite.svg create mode 100644 playground/src/styles/sprite.css diff --git a/playground/src/assets/sprite.svg b/playground/src/assets/sprite.svg new file mode 100644 index 0000000..b380341 --- /dev/null +++ b/playground/src/assets/sprite.svg @@ -0,0 +1,3 @@ + + + diff --git a/playground/src/main.ts b/playground/src/main.ts index 005af5c..0a0dec3 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -23,6 +23,7 @@ import './styles/print.css'; // Styles that reference an emitted asset import './styles/hero.css'; +import './styles/sprite.css'; // Original styles import './styles/main.css'; diff --git a/playground/src/styles/sprite.css b/playground/src/styles/sprite.css new file mode 100644 index 0000000..bf44227 --- /dev/null +++ b/playground/src/styles/sprite.css @@ -0,0 +1,11 @@ +/* A reference carrying a fragment is held in a different shape of Vite + placeholder than a bare one. */ +.sprite { + background-image: url('../assets/sprite.svg#star'); + width: 1rem; + height: 1rem; +} + +.sprite-label { + font-weight: 700; +} diff --git a/src/integration.test.ts b/src/integration.test.ts index 33e1097..7528141 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -540,9 +540,15 @@ describe('vite-plugin-css-sourcemap position resolution', () => { expect(css).toContain('url('); expect(css).not.toContain('__VITE_ASSET__'); - expect( - map.sources.some((source: string) => source.includes('hero.css')), - ).toBe(true); + + // A bare reference and one carrying a fragment use different shapes of + // placeholder, so both have to survive the rewrite. + for (const stylesheet of ['hero.css', 'sprite.css']) { + expect( + map.sources.some((source: string) => source.includes(stylesheet)), + `${stylesheet} is missing from the sourcemap`, + ).toBe(true); + } const tracer = new TraceMap(map); const lines = css.split('\n'); diff --git a/src/plugin.ts b/src/plugin.ts index 4b32c50..e92f792 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -79,8 +79,12 @@ interface AssetRegion { end: number; } -/** Stand-in Vite leaves in CSS for an asset whose final URL isn't known yet. */ -const ASSET_PLACEHOLDER = /__VITE(?:_PUBLIC)?_ASSET__[\w$]+__/g; +/** + * Stand-in Vite leaves in CSS for an asset whose final URL isn't known yet. A + * reference that carries a query or a fragment, such as `url("sprite.svg#id")`, + * puts it in the trailing `$_…__` group. + */ +const ASSET_PLACEHOLDER = /__VITE(?:_PUBLIC)?_ASSET__[\w$]+__(?:\$_.*?__)?/g; export default function cssSourcemapPlugin( options: CssSourcemapOptions = {}, From fa869772e204f1786ecd2a2f0dc6bf42316225d3 Mon Sep 17 00:00:00 2001 From: Linpeng Zhang <44171495+linpengzhang@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:14:53 +0100 Subject: [PATCH 5/8] fix: map hoisted at-rule stylesheets and split assets to the right file Vite lifts @charset and @import to the top of a concatenated asset, so a stylesheet opening with a webfont import is split apart and its compiled text never appears as one run. Placement now retries without those leading at-rules and shifts the mappings by the lines they occupied. Placement also searched every captured stylesheet inside every CSS asset. With cssCodeSplit that let one chunk's stylesheet claim the identical region in another chunk's asset, so its twin went unmapped and its coverage landed on the wrong file. Each asset is now searched only against the stylesheets the chunk that pulled it in was built from. Co-authored-by: Cursor --- playground/src/main.ts | 3 + playground/src/split/a-only.css | 3 + playground/src/split/alpha.css | 3 + playground/src/split/b-only.css | 3 + playground/src/split/beta.css | 3 + playground/src/split/entry-a.ts | 4 + playground/src/split/entry-b.ts | 4 + playground/src/styles/fonts.css | 9 ++ src/integration.test.ts | 105 ++++++++++++++++++++++ src/plugin.ts | 150 +++++++++++++++++++++++++++----- 10 files changed, 265 insertions(+), 22 deletions(-) create mode 100644 playground/src/split/a-only.css create mode 100644 playground/src/split/alpha.css create mode 100644 playground/src/split/b-only.css create mode 100644 playground/src/split/beta.css create mode 100644 playground/src/split/entry-a.ts create mode 100644 playground/src/split/entry-b.ts create mode 100644 playground/src/styles/fonts.css diff --git a/playground/src/main.ts b/playground/src/main.ts index 0a0dec3..61b9f91 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -21,6 +21,9 @@ import './styles/animations.css'; import './styles/a11y.css'; import './styles/print.css'; +// Styles whose leading at-rule is hoisted to the top of the asset +import './styles/fonts.css'; + // Styles that reference an emitted asset import './styles/hero.css'; import './styles/sprite.css'; diff --git a/playground/src/split/a-only.css b/playground/src/split/a-only.css new file mode 100644 index 0000000..c5213a9 --- /dev/null +++ b/playground/src/split/a-only.css @@ -0,0 +1,3 @@ +.only-a { + margin: 1px; +} diff --git a/playground/src/split/alpha.css b/playground/src/split/alpha.css new file mode 100644 index 0000000..c133de8 --- /dev/null +++ b/playground/src/split/alpha.css @@ -0,0 +1,3 @@ +.shared-widget { + color: #abcdef; +} diff --git a/playground/src/split/b-only.css b/playground/src/split/b-only.css new file mode 100644 index 0000000..a38e22b --- /dev/null +++ b/playground/src/split/b-only.css @@ -0,0 +1,3 @@ +.only-b { + margin: 2px; +} diff --git a/playground/src/split/beta.css b/playground/src/split/beta.css new file mode 100644 index 0000000..c133de8 --- /dev/null +++ b/playground/src/split/beta.css @@ -0,0 +1,3 @@ +.shared-widget { + color: #abcdef; +} diff --git a/playground/src/split/entry-a.ts b/playground/src/split/entry-a.ts new file mode 100644 index 0000000..7169c91 --- /dev/null +++ b/playground/src/split/entry-a.ts @@ -0,0 +1,4 @@ +import './a-only.css'; +import './alpha.css'; + +console.log('split entry a'); diff --git a/playground/src/split/entry-b.ts b/playground/src/split/entry-b.ts new file mode 100644 index 0000000..fa385aa --- /dev/null +++ b/playground/src/split/entry-b.ts @@ -0,0 +1,4 @@ +import './b-only.css'; +import './beta.css'; + +console.log('split entry b'); diff --git a/playground/src/styles/fonts.css b/playground/src/styles/fonts.css new file mode 100644 index 0000000..cce3a3a --- /dev/null +++ b/playground/src/styles/fonts.css @@ -0,0 +1,9 @@ +@import url('https://fonts.example/css2?family=Inter'); + +.font-body { + font-family: Inter, sans-serif; +} + +.font-heading { + font-weight: 800; +} diff --git a/src/integration.test.ts b/src/integration.test.ts index 7528141..c42a0fc 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -563,6 +563,45 @@ describe('vite-plugin-css-sourcemap position resolution', () => { ).toContain('hero.css'); }); + // Vite lifts `@import` and `@charset` to the top of the concatenated file, so + // a stylesheet opening with one is split in two and its compiled text never + // appears as a single run. + it('should map a stylesheet whose leading at-rule is hoisted', async () => { + await build({ + root: playgroundDir, + build: { outDir: 'dist' }, + configFile: false, + logLevel: 'error', + plugins: [(await import('./index')).default()], + }); + + const files = await readdir(assetsDir); + const cssFile = files.find((file) => file.endsWith('.css'))!; + const css = await readFile(resolve(assetsDir, cssFile), 'utf-8'); + const map = JSON.parse( + await readFile(resolve(assetsDir, `${cssFile}.map`), 'utf-8'), + ); + + const lines = css.split('\n'); + expect(lines[0]).toContain('@import'); + expect( + map.sources.some((source: string) => source.includes('fonts.css')), + 'fonts.css is missing from the sourcemap', + ).toBe(true); + + // The rules must keep pointing at their original lines even though the + // at-rule above them was moved elsewhere. + const line = lines.findIndex((text) => text.includes('.font-heading')); + expect(line, 'no .font-heading rule in the built CSS').toBeGreaterThan(-1); + + const position = originalPositionFor(new TraceMap(map), { + line: line + 1, + column: lines[line]!.indexOf('.font-heading'), + }); + expect(position.source).toContain('fonts.css'); + expect(position.line).toBe(7); + }); + // Claiming a start offset rather than a whole region lets a stylesheet whose // CSS is contained in another's take a position inside it, so the containing // stylesheet either goes missing or inherits the shorter one's coverage. @@ -606,3 +645,69 @@ describe('vite-plugin-css-sourcemap position resolution', () => { ).toContain('a11y.css'); }); }); + +// With `cssCodeSplit` each entry gets its own CSS asset. Searching every +// captured stylesheet inside every asset lets one entry's stylesheet claim the +// identical region in the other entry's asset, leaving its twin unmapped and +// its coverage attributed to the wrong file. +describe('vite-plugin-css-sourcemap split assets', () => { + const playgroundDir = resolve(__dirname, '../playground'); + const distDir = resolve(playgroundDir, 'dist-split'); + const assetsDir = resolve(distDir, 'assets'); + + beforeEach(async () => { + await rimraf(distDir); + }); + + afterAll(async () => { + await rimraf(distDir); + }); + + it('should attribute identical stylesheets to their own file', async () => { + await build({ + root: playgroundDir, + configFile: false, + logLevel: 'error', + build: { + outDir: 'dist-split', + cssCodeSplit: true, + rollupOptions: { + input: { + a: resolve(playgroundDir, 'src/split/entry-a.ts'), + b: resolve(playgroundDir, 'src/split/entry-b.ts'), + }, + }, + }, + plugins: [(await import('./index')).default()], + }); + + const files = await readdir(assetsDir); + + for (const [entry, expected] of [ + ['a-', 'split/alpha.css'], + ['b-', 'split/beta.css'], + ]) { + const cssFile = files.find( + (file) => file.startsWith(entry!) && file.endsWith('.css'), + ); + expect(cssFile, `no CSS asset for entry ${entry}`).toBeDefined(); + + const css = await readFile(resolve(assetsDir, cssFile!), 'utf-8'); + const map = JSON.parse( + await readFile(resolve(assetsDir, `${cssFile}.map`), 'utf-8'), + ); + + const lines = css.split('\n'); + const line = lines.findIndex((text) => text.includes('.shared-widget')); + expect(line, 'no .shared-widget rule in the built CSS').toBeGreaterThan( + -1, + ); + + const position = originalPositionFor(new TraceMap(map), { + line: line + 1, + column: 0, + }); + expect(position.source).toContain(expected!); + } + }); +}); diff --git a/src/plugin.ts b/src/plugin.ts index e92f792..1a44759 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -71,6 +71,10 @@ interface PlacedStylesheet { stylesheet: CompiledStylesheet; line: number; column: number; + /** Leading compiled lines that were hoisted away from the rest. */ + skippedLines: number; + /** How many lines of the asset the stylesheet accounts for. */ + span: number; } /** A region of the concatenated asset that one stylesheet occupies. */ @@ -202,11 +206,19 @@ export default function cssSourcemapPlugin( // generateBundle, so this has to run after it. order: 'post', handler(_options: NormalizedOutputOptions, bundle: OutputBundle) { + const stylesheetsByAsset = groupStylesheetsByAsset(bundle, compiled); + for (const [fileName, asset] of Object.entries(bundle)) { if (asset.type !== 'asset' || !fileName.endsWith('.css')) continue; const css = String(asset.source); - const placed = locateStylesheets(css, compiled); + // Restricted to the stylesheets this asset was built from. Searching + // all of them would let a stylesheet in one chunk claim the region of + // an identical one in another, and the loser's coverage with it. + const placed = locateStylesheets( + css, + stylesheetsByAsset.get(fileName) ?? compiled, + ); if (placed.length === 0) { this.warn( @@ -253,11 +265,11 @@ function locateStylesheets( ); for (const [, stylesheet] of byLengthDescending) { - const region = findUnclaimedRegion(css, stylesheet.code.trim(), claimed); - if (!region) continue; - claimed.push(region); + const body = locateBody(css, stylesheet.code.trim(), claimed); + if (!body) continue; + claimed.push(body.region); - const preceding = css.slice(0, region.start); + const preceding = css.slice(0, body.region.start); placed.push({ id: stylesheet.sourcePath, stylesheet, @@ -265,13 +277,101 @@ function locateStylesheets( // Vite can concatenate one stylesheet's last line and the next // stylesheet's first onto a single physical line, so the offset within // that line is what keeps their mappings apart. - column: region.start - (preceding.lastIndexOf('\n') + 1), + column: body.region.start - (preceding.lastIndexOf('\n') + 1), + skippedLines: body.skippedLines, + span: body.text.split('\n').length, }); } return placed.sort((a, b) => a.line - b.line || a.column - b.column); } +/** + * Works out which stylesheets each CSS asset was built from, by way of the + * chunk that pulled the asset in. Assets with no chunk claiming them are left + * out, and fall back to being searched against every stylesheet. + */ +function groupStylesheetsByAsset( + bundle: OutputBundle, + compiled: Map, +): Map> { + const byAsset = new Map>(); + + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') continue; + + const importedCss = output.viteMetadata?.importedCss; + if (!importedCss) continue; + + for (const assetFileName of importedCss) { + let stylesheets = byAsset.get(assetFileName); + if (!stylesheets) { + stylesheets = new Map(); + byAsset.set(assetFileName, stylesheets); + } + for (const id of output.moduleIds) { + const stylesheet = compiled.get(id); + if (stylesheet) stylesheets.set(id, stylesheet); + } + } + } + + return byAsset; +} + +/** + * Finds the part of a stylesheet that survives into the asset as one run of + * text, along with where it landed. + * + * Vite hoists `@charset` and `@import` to the top of the concatenated file, so + * a stylesheet opening with an external import — a webfont, most often — is + * split in two and its compiled text never appears contiguously. Retrying + * without those leading at-rules recovers the rest of the file, which is where + * all of its rules are anyway. + */ +function locateBody( + css: string, + code: string, + claimed: readonly AssetRegion[], +): { region: AssetRegion; text: string; skippedLines: number } | null { + const whole = findUnclaimedRegion(css, code, claimed); + if (whole) return { region: whole, text: code, skippedLines: 0 }; + + const withoutAtRules = stripHoistedAtRules(code); + if (!withoutAtRules) return null; + + const region = findUnclaimedRegion(css, withoutAtRules.text, claimed); + return region ? { region, ...withoutAtRules } : null; +} + +/** + * Drops the `@charset` and `@import` statements a stylesheet opens with, or + * returns null when it has none. CSS only permits them at the top of a file, so + * they are always a prefix. + */ +function stripHoistedAtRules( + code: string, +): { text: string; skippedLines: number } | null { + let rest = code; + let found = false; + + for (;;) { + const match = /^\s*@(?:charset|import)\b[^;]*;/.exec(rest); + if (!match) break; + rest = rest.slice(match[0].length); + found = true; + } + + if (!found) return null; + + const text = rest.trimStart(); + const consumed = code.length - text.length; + return { + text, + skippedLines: code.slice(0, consumed).split('\n').length - 1, + }; +} + /** * Finds the region of the asset a stylesheet occupies, ignoring regions another * stylesheet already occupies. @@ -376,10 +476,7 @@ function buildConcatenatedSourcemap( lines[line]!.push(segment); }; - for (const { id, stylesheet, line, column } of placed) { - // A stylesheet owns exactly the lines its compiled CSS occupies. Segments - // past that would land inside the next stylesheet and win its lookups. - const span = stylesheet.code.trim().split('\n').length; + for (const { id, stylesheet, line, column, skippedLines, span } of placed) { // Only the first line is offset horizontally; later lines start at column 0. const shift = (index: number, col: number) => index === 0 ? column + col : col; @@ -393,17 +490,21 @@ function buildConcatenatedSourcemap( ), ); - decoded.slice(0, span).forEach((segments, index) => { - for (const segment of segments) { - if (segment.length < 4) continue; - addSegment(line + index, [ - shift(index, segment[0]), - remapped[segment[1]!] ?? 0, - segment[2]!, - segment[3]!, - ]); - } - }); + // A stylesheet owns exactly the lines it occupies in the asset. Segments + // past that would land inside the next stylesheet and win its lookups. + decoded + .slice(skippedLines, skippedLines + span) + .forEach((segments, index) => { + for (const segment of segments) { + if (segment.length < 4) continue; + addSegment(line + index, [ + shift(index, segment[0]), + remapped[segment[1]!] ?? 0, + segment[2]!, + segment[3]!, + ]); + } + }); } else { // Without a map the compiled CSS is line-for-line with its source, except // where a plugin generated CSS the source never spelled out. Clamping @@ -412,7 +513,12 @@ function buildConcatenatedSourcemap( const index = sourceIndex(id, stylesheet.code); const lastLine = Math.max(0, countLines(id) - 1); for (let i = 0; i < span; i++) { - addSegment(line + i, [shift(i, 0), index, Math.min(i, lastLine), 0]); + addSegment(line + i, [ + shift(i, 0), + index, + Math.min(i + skippedLines, lastLine), + 0, + ]); } } } From 3dc9d5be65b0bfec08d003c9b78ac553e47a4450 Mon Sep 17 00:00:00 2001 From: Linpeng Zhang <44171495+linpengzhang@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:43:56 +0100 Subject: [PATCH 6/8] fix: end an at-rule on its real terminator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A webfont import asks for several weights with semicolons inside the URL — family=Inter:wght@400;700 — and a data URI can hold any number of them. Taking the at-rule to end at the first semicolon left the statement half stripped, so the stylesheet was dropped from the map. It is now scanned with quotes and parens accounted for. Co-authored-by: Cursor --- playground/src/styles/fonts.css | 2 +- src/plugin.ts | 40 ++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/playground/src/styles/fonts.css b/playground/src/styles/fonts.css index cce3a3a..8413182 100644 --- a/playground/src/styles/fonts.css +++ b/playground/src/styles/fonts.css @@ -1,4 +1,4 @@ -@import url('https://fonts.example/css2?family=Inter'); +@import url('https://fonts.example/css2?family=Inter:wght@400;700'); .font-body { font-family: Inter, sans-serif; diff --git a/src/plugin.ts b/src/plugin.ts index 1a44759..1d03535 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -356,9 +356,11 @@ function stripHoistedAtRules( let found = false; for (;;) { - const match = /^\s*@(?:charset|import)\b[^;]*;/.exec(rest); - if (!match) break; - rest = rest.slice(match[0].length); + const trimmed = rest.trimStart(); + if (!/^@(?:charset|import)\b/.test(trimmed)) break; + const end = endOfStatement(trimmed); + if (end === -1) break; + rest = trimmed.slice(end); found = true; } @@ -372,6 +374,38 @@ function stripHoistedAtRules( }; } +/** + * Offset just past the `;` that ends an at-rule statement, or -1 if it has no + * such terminator. + * + * Scanning rather than searching for the first `;` because a URL can contain + * one — `family=Inter:wght@400;700` is how Google Fonts asks for two weights, + * and a data URI can hold any number of them. + */ +function endOfStatement(code: string): number { + let quote: string | null = null; + let depth = 0; + + for (let i = 0; i < code.length; i++) { + const char = code[i]; + + if (quote) { + if (char === '\\') i++; + else if (char === quote) quote = null; + continue; + } + + if (char === '"' || char === "'") quote = char; + else if (char === '(') depth++; + else if (char === ')') depth--; + // A block at-rule such as `@media`, which Vite leaves where it is. + else if (char === '{') return -1; + else if (char === ';' && depth === 0) return i + 1; + } + + return -1; +} + /** * Finds the region of the asset a stylesheet occupies, ignoring regions another * stylesheet already occupies. From 2a4600d15b788c95567bedda1fb569e61416abb4 Mon Sep 17 00:00:00 2001 From: Linpeng Zhang <44171495+linpengzhang@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:07:23 +0100 Subject: [PATCH 7/8] fix: keep trimmed CSS lined up with preprocessor maps Searching code.trim() without shifting mappings put rules on the wrong line. Snapshot the source file's line count at transform time, use Vite's combined map instead of compiling Sass again, and guard decode / comments / sourceRoot so a bad upstream map cannot fail the build. Co-authored-by: Cursor --- playground-scss-entrypoint/README.md | 122 ++----------- src/integration.test.ts | 41 ++--- src/plugin.test.ts | 187 ++++++++++++++++++- src/plugin.ts | 256 +++++++++++++++++---------- 4 files changed, 362 insertions(+), 244 deletions(-) diff --git a/playground-scss-entrypoint/README.md b/playground-scss-entrypoint/README.md index 6ae18c3..d77bb8c 100644 --- a/playground-scss-entrypoint/README.md +++ b/playground-scss-entrypoint/README.md @@ -1,126 +1,34 @@ # SCSS Entrypoint Playground -This playground tests the scenario where SCSS is used as a direct rollup entrypoint, with `@import`ed partials that should all appear in the sourcemap. +This playground is a raw SCSS Rollup input (`styles: 'styles/main.scss'`). Vite +does not expose a combined preprocessor map for that shape, so the plugin +attributes the compiled CSS to `main.scss` rather than compiling Sass a second +time outside Vite's resolver. -## The Issue - -In traditional server-rendered projects, you might have a Vite config like this: - -```js -export default defineConfig({ - build: { - rollupOptions: { - input: { - main: 'javascript/main.js', - styles: 'styles/main.scss', // SCSS as direct entrypoint - }, - }, - }, -}); -``` - -The `styles/main.scss` file uses `@import` to pull in multiple partials: - -```scss -@import 'partials/variables'; -@import 'partials/reset'; -@import 'partials/buttons'; -// ... etc -``` - -**Expected:** The generated sourcemap should include mappings for ALL SCSS files. - -**Actual:** The sourcemap only covers `main.scss`, not the imported partials. +SCSS imported through Vite's CSS pipeline (`import './main.scss'` from JS) still +gets whatever map Vite already built, including partials when Sass provided one. ## Structure ``` playground-scss-entrypoint/ -├── vite.config.ts # Vite config with SCSS as rollup input -├── javascript/ -│ └── main.js # JS entrypoint (minimal) -├── styles/ -│ ├── main.scss # SCSS entrypoint (uses @import) -│ └── partials/ -│ ├── _variables.scss -│ ├── _reset.scss -│ ├── _layout.scss -│ ├── _buttons.scss -│ ├── _cards.scss -│ ├── _forms.scss -│ └── _utilities.scss -└── index.html # Test page +├── vite.config.ts +├── javascript/main.js +├── styles/main.scss +└── styles/partials/… ``` ## Running ```bash -# From the root of the project cd playground-scss-entrypoint npx vite build - -# Check the sourcemap cat dist/assets/styles.css.map | jq '.sources' ``` -## What to Verify - -1. Build succeeds -2. `dist/assets/styles.css` is generated -3. `dist/assets/styles.css.map` is generated -4. The sourcemap's `sources` array should include ALL partials, not just `main.scss` - -## Current Behavior (Fixed) - -After the fix, the sourcemap includes all SCSS partials: - -```json -{ - "sources": [ - "file:///path/to/styles/main.scss", - "file:///path/to/styles/partials/_reset.scss", - "file:///path/to/styles/partials/_variables.scss", - "file:///path/to/styles/partials/_layout.scss", - "file:///path/to/styles/partials/_buttons.scss", - "file:///path/to/styles/partials/_cards.scss", - "file:///path/to/styles/partials/_forms.scss", - "file:///path/to/styles/partials/_utilities.scss" - ], - "sourcesContent": ["/* Original SCSS source for each file */"] -} -``` - -When debugging in browser DevTools, styles correctly point to the actual partial files like `_buttons.scss`, `_forms.scss`, etc. - -## Fixes Applied - -### Fix 1: Asset Name Matching - -The plugin now tries multiple key formats when looking up source files in `generateBundle`: - -- The full filename with extension (e.g., `assets/styles.css`) -- The filename without extension (e.g., `assets/styles`) -- The fallback template path (e.g., `assets/main`) - -### Fix 2: SCSS Compilation for Sourcemaps - -When `getCombinedSourcemap()` returns an empty sourcemap (which happens when SCSS is a direct rollup entrypoint), the plugin now: - -1. Detects that the file is SCSS/Sass -2. Dynamically loads `sass-embedded` or `sass` if available -3. Compiles the SCSS file to extract the proper sourcemap with all `@import`ed partials -4. Falls back to identity sourcemap if no Sass compiler is available - -## Requirements - -For SCSS sourcemaps to work correctly, you need either `sass-embedded` or `sass` installed: - -```bash -npm install -D sass-embedded -# or -npm install -D sass -``` - -## Related +## What to verify -This playground was created to test SCSS sourcemap support when SCSS is used as a direct rollup entrypoint. +1. Build succeeds and emits `styles.css` + `styles.css.map`. +2. `sources` includes `main.scss`. +3. Partials appear only if Vite's combined map listed them — this playground + typically does not, and the plugin does not invent them. diff --git a/src/integration.test.ts b/src/integration.test.ts index c42a0fc..65f1b08 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -352,7 +352,7 @@ describe('vite-plugin-css-sourcemap SCSS entrypoint integration', () => { } }); - it('should include all SCSS partials in sourcemap sources', async () => { + it('should attribute a direct SCSS entry to the file Vite compiled', async () => { await build({ root: scssPlaygroundDir, build: { @@ -385,38 +385,17 @@ describe('vite-plugin-css-sourcemap SCSS entrypoint integration', () => { expect(mapFiles.length).toBeGreaterThan(0); - const mapContent = await readFile(resolve(assetsDir, mapFiles[0]), 'utf-8'); - const sourcemap = JSON.parse(mapContent); - - expect(sourcemap.sources).toBeDefined(); - expect(Array.isArray(sourcemap.sources)).toBe(true); - - // The sourcemap should include multiple SCSS files (main + partials) - // not just the entrypoint - expect(sourcemap.sources.length).toBeGreaterThan(1); - - // Check that partials are included (they should contain partial file names) - const sourceNames = sourcemap.sources.map((s: string) => - s.split('/').pop(), + const sourcemap = JSON.parse( + await readFile(resolve(assetsDir, mapFiles[0]!), 'utf-8'), ); - // Should include at least some of the partials - const expectedPartials = [ - '_variables.scss', - '_reset.scss', - '_layout.scss', - '_buttons.scss', - '_cards.scss', - '_forms.scss', - '_utilities.scss', - ]; - - const foundPartials = expectedPartials.filter((partial) => - sourceNames.some((name: string) => name === partial), - ); - - // We should find most of the partials in the sourcemap - expect(foundPartials.length).toBeGreaterThanOrEqual(5); + // A second Sass compile (outside Vite's resolver) used to list every + // partial. That map silently went 1:1 when aliases or sass-embedded + // disagreed with Vite. We now use Vite's combined map only; a raw SCSS + // rollup entry typically exposes just the entry file. + expect( + sourcemap.sources.some((source: string) => source.includes('main.scss')), + ).toBe(true); }); afterAll(async () => { diff --git a/src/plugin.test.ts b/src/plugin.test.ts index df30e5f..c241231 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -1,8 +1,13 @@ -import { describe, it, expect, vi } from 'vitest'; -import cssSourcemap from './plugin'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { encode } from '@jridgewell/sourcemap-codec'; +import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'; +import type { OutputAsset, PluginContext } from 'rollup'; +import { describe, expect, it, vi } from 'vitest'; import { PLUGIN_NAME } from './constants'; +import cssSourcemap from './plugin'; import { hasValidExtension } from './utils'; -import type { PluginContext, OutputAsset } from 'rollup'; describe('vite-plugin-css-sourcemap', () => { it('should be a function', () => { @@ -101,6 +106,147 @@ describe('vite-plugin-css-sourcemap', () => { expect(map.sources).toEqual([CSS_FILE]); expect(map.mappings).not.toBe(''); }); + + it('should keep preprocessor mappings lined up after trimming leading whitespace', async () => { + const plugin = cssSourcemap(); + // Generated lines 0-1 are blank; the rule lives on generated line 2 and + // source line 5. Searching the trimmed text must still apply line 2's + // segment, not line 0's. + const compiled = '\n\n.btn {\n color: red;\n}'; + const asset = '.btn {\n color: red;\n}'; + const { emitFile } = await runPlugin(plugin, { + id: CSS_FILE, + code: compiled, + asset, + map: { + version: 3, + mappings: encode([[], [], [[0, 0, 5, 0]]]), + sources: ['button.css'], + sourcesContent: ['.btn { color: red; }'], + }, + }); + + const map = JSON.parse(emitFile.mock.calls[0]![0].source); + const position = originalPositionFor(new TraceMap(map), { + line: 1, + column: 0, + }); + expect(position.source).toContain('button.css'); + expect(position.line).toBe(6); + }); + + it('should strip a first-line indent without shifting the mapped column', async () => { + const plugin = cssSourcemap(); + const compiled = ' .btn {\n color: red;\n}'; + const asset = '.btn {\n color: red;\n}'; + const { emitFile } = await runPlugin(plugin, { + id: CSS_FILE, + code: compiled, + asset, + map: { + version: 3, + mappings: encode([[[2, 0, 0, 0]]]), + sources: ['button.css'], + sourcesContent: ['.btn { color: red; }'], + }, + }); + + const map = JSON.parse(emitFile.mock.calls[0]![0].source); + const position = originalPositionFor(new TraceMap(map), { + line: 1, + column: 0, + }); + expect(position.source).toContain('button.css'); + expect(position.line).toBe(1); + }); + + it('should not treat a semicolon inside a comment as the end of an at-rule', async () => { + const plugin = cssSourcemap(); + const compiled = + '@import url("https://fonts.example/css2?family=Inter:wght@400;700") /* note; keep */;\n.font-heading { font-weight: 700; }'; + const asset = '.font-heading { font-weight: 700; }'; + const { emitFile } = await runPlugin(plugin, { + id: '/path/to/fonts.css', + code: compiled, + asset, + }); + + const map = JSON.parse(emitFile.mock.calls[0]![0].source); + expect( + map.sources.some((source: string) => source.includes('fonts.css')), + ).toBe(true); + const position = originalPositionFor(new TraceMap(map), { + line: 1, + column: 0, + }); + expect(position.source).toContain('fonts.css'); + }); + + it('should resolve relative sources against sourceRoot', async () => { + const plugin = cssSourcemap(); + const { emitFile } = await runPlugin(plugin, { + id: CSS_FILE, + code: CSS_SOURCE, + asset: CSS_SOURCE, + map: { + version: 3, + mappings: encode([[[0, 0, 0, 0]]]), + sources: ['button.css'], + sourceRoot: '/app/src/', + sourcesContent: ['.btn { color: red; }'], + }, + }); + + const map = JSON.parse(emitFile.mock.calls[0]![0].source); + expect(map.sources).toEqual(['/app/src/button.css']); + }); + + it('should not fail the build when an upstream map cannot be decoded', async () => { + const plugin = cssSourcemap(); + const { emitFile, warn } = await runPlugin(plugin, { + id: CSS_FILE, + code: CSS_SOURCE, + asset: CSS_SOURCE, + map: { + version: 3, + // Not a VLQ string — decode() throws instead of inventing segments. + mappings: {} as unknown as string, + sources: ['button.css'], + }, + }); + + expect(emitFile).toHaveBeenCalled(); + const map = JSON.parse(emitFile.mock.calls[0]![0].source); + expect(map.sources).toEqual([CSS_FILE]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Could not decode the source map'), + ); + }); + + it('should clamp generated CSS to the source file line count, not compiled output', async () => { + const plugin = cssSourcemap(); + const sourceFile = path.join( + mkdtempSync(path.join(tmpdir(), 'css-map-')), + 'styles.css', + ); + writeFileSync(sourceFile, 'a {\n color: red;\n}\n'); + const compiled = Array.from( + { length: 20 }, + (_, i) => `.u-${i}{color:red}`, + ).join('\n'); + const { emitFile } = await runPlugin(plugin, { + id: sourceFile, + code: compiled, + asset: compiled, + }); + + const map = JSON.parse(emitFile.mock.calls[0]![0].source); + const last = originalPositionFor(new TraceMap(map), { + line: compiled.split('\n').length, + column: 0, + }); + expect(last.line).toBeLessThanOrEqual(4); + }); }); const CSS_FILE = '/path/to/styles.css'; @@ -112,28 +258,51 @@ function callConfig(plugin: ReturnType) { return config.call({} as any, {} as any, {} as any); } +async function runPluginOverSingleStylesheet( + plugin: ReturnType, +) { + return runPlugin(plugin, { + id: CSS_FILE, + code: CSS_SOURCE, + asset: CSS_SOURCE, + }); +} + /** * Drives the plugin the way Vite does: transform each stylesheet, then hand * generateBundle the concatenated asset those stylesheets produced. */ -async function runPluginOverSingleStylesheet( +async function runPlugin( plugin: ReturnType, + input: { + id: string; + code: string; + asset: string; + map?: { + version: number; + mappings: string; + sources: string[]; + sourceRoot?: string; + sourcesContent?: string[]; + }; + }, ) { const emitFile = vi.fn().mockReturnValue('referenceId'); + const warn = vi.fn(); const context = { emitFile, - warn: vi.fn(), - getCombinedSourcemap: () => ({ mappings: '', sources: [] }), + warn, + getCombinedSourcemap: () => input.map ?? { mappings: '', sources: [] }, } as unknown as PluginContext; const transform = plugin.transform; if (typeof transform !== 'function') throw new Error('expected a transform'); - await transform.call(context as any, CSS_SOURCE, CSS_FILE); + await transform.call(context as any, input.code, input.id); const asset = { type: 'asset', fileName: 'styles.css', - source: CSS_SOURCE, + source: input.asset, name: 'styles.css', needsCodeReference: false, names: [], @@ -152,5 +321,5 @@ async function runPluginOverSingleStylesheet( false, ); - return Object.assign(asset, { emitFile }); + return Object.assign(asset, { emitFile, warn }); } diff --git a/src/plugin.ts b/src/plugin.ts index 1d03535..36b84fc 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -63,6 +63,12 @@ interface CompiledStylesheet { sourcePath: string; code: string; map: ExistingRawSourceMap | null; + /** + * Line count of the original source file, snapshotted at transform time. + * Used to clamp identity mappings when a preprocessor generated more CSS + * than the source spelled out. This is the source file, not `code`. + */ + sourceLineCount: number; } /** Where a compiled stylesheet ended up inside the concatenated CSS asset. */ @@ -71,8 +77,10 @@ interface PlacedStylesheet { stylesheet: CompiledStylesheet; line: number; column: number; - /** Leading compiled lines that were hoisted away from the rest. */ + /** Leading compiled lines that were hoisted or trimmed away from the rest. */ skippedLines: number; + /** Columns stripped from the first remaining compiled line by `trim()`. */ + skippedColumns: number; /** How many lines of the asset the stylesheet accounts for. */ span: number; } @@ -109,57 +117,6 @@ export default function cssSourcemapPlugin( } const compiled = new Map(); - let sassCompiler: any = null; - - /** - * Try to load a Sass compiler (sass-embedded or sass). - * Returns the compiler module or false if not available. - */ - async function getSassCompiler() { - if (sassCompiler !== null) return sassCompiler; - - try { - // Try sass-embedded first (preferred by Vite 7+) - sassCompiler = await import('sass-embedded'); - } catch { - // sass-embedded not available, trying sass - try { - // Fall back to sass - sassCompiler = await import('sass'); - } catch { - // Neither sass-embedded nor sass is installed - sassCompiler = false; - } - } - return sassCompiler; - } - - /** - * Compile SCSS/Sass file and extract the sourcemap. - * This is used when Vite's CSS pipeline doesn't expose the Sass sourcemap - * through getCombinedSourcemap() (e.g., when SCSS is a direct rollup entrypoint). - */ - async function compileSCSS(id: string): Promise { - const sass = await getSassCompiler(); - if (!sass) return null; - - try { - const fileContent = await fs.promises.readFile(id, 'utf-8'); - const result = sass.compileString(fileContent, { - url: new URL(`file://${id}`), - sourceMap: true, - sourceMapIncludeSources: true, - }); - - if (result.sourceMap) { - return result.sourceMap as ExistingRawSourceMap; - } - } catch { - // Sass compilation failed (syntax error, file not found, etc.) - // Will fall back to an identity mapping. - } - return null; - } return { name: PLUGIN_NAME, @@ -173,29 +130,16 @@ export default function cssSourcemapPlugin( // Left at the default order so that this runs after `vite:css` has // compiled a stylesheet, but before `vite:css-post` replaces it with a // JavaScript module. - async transform(code, id) { + transform(code, id) { if (!hasValidExtension(id, extensions)) return null; if (!code.trim()) return null; - let map = this.getCombinedSourcemap() as ExistingRawSourceMap | null; - - if ( - isEmptySourcemap(map) && - (id.endsWith('.scss') || id.endsWith('.sass')) - ) { - // Compile the file ourselves so that @use/@import partials are - // represented, which the combined sourcemap does not cover when SCSS - // is a direct rollup entrypoint. - map = await compileSCSS(id); - } - - // A single-file component hands over its styles under an id whose - // extension sits in the query, e.g. `App.vue?vue&type=style&lang.css`, - // so the path has to be recovered separately for `sources`. + const sourcePath = id.split('?')[0] ?? id; compiled.set(id, { - sourcePath: id.split('?')[0] ?? id, + sourcePath, code, - map: isEmptySourcemap(map) ? null : map, + map: readCombinedSourcemap(() => this.getCombinedSourcemap()), + sourceLineCount: countSourceLines(sourcePath, code), }); return null; @@ -215,10 +159,20 @@ export default function cssSourcemapPlugin( // Restricted to the stylesheets this asset was built from. Searching // all of them would let a stylesheet in one chunk claim the region of // an identical one in another, and the loser's coverage with it. - const placed = locateStylesheets( - css, - stylesheetsByAsset.get(fileName) ?? compiled, - ); + let placed: PlacedStylesheet[]; + try { + placed = locateStylesheets( + css, + stylesheetsByAsset.get(fileName) ?? compiled, + ); + } catch (error) { + const detail = + error instanceof Error ? error.message : String(error); + this.warn( + `Could not build a CSS sourcemap for ${fileName}: ${detail}`, + ); + continue; + } if (placed.length === 0) { this.warn( @@ -229,7 +183,9 @@ export default function cssSourcemapPlugin( continue; } - const map = buildConcatenatedSourcemap(placed, fileName); + const map = buildConcatenatedSourcemap(placed, fileName, (message) => + this.warn(message), + ); const mapFileName = `${asset.fileName}.map`; const mapBaseName = path.basename(mapFileName); @@ -265,7 +221,8 @@ function locateStylesheets( ); for (const [, stylesheet] of byLengthDescending) { - const body = locateBody(css, stylesheet.code.trim(), claimed); + const alignment = leadingTrim(stylesheet.code); + const body = locateBody(css, alignment.text, claimed); if (!body) continue; claimed.push(body.region); @@ -278,7 +235,11 @@ function locateStylesheets( // stylesheet's first onto a single physical line, so the offset within // that line is what keeps their mappings apart. column: body.region.start - (preceding.lastIndexOf('\n') + 1), - skippedLines: body.skippedLines, + skippedLines: alignment.skippedLines + body.skippedLines, + skippedColumns: + body.skippedLines === 0 + ? alignment.skippedColumns + : body.skippedColumns, span: body.text.split('\n').length, }); } @@ -286,6 +247,27 @@ function locateStylesheets( return placed.sort((a, b) => a.line - b.line || a.column - b.column); } +/** + * How much leading whitespace `trim()` strips from compiled CSS, in the + * generated coordinate space of the preprocessor map. Searching the trimmed + * text finds the stylesheet in the asset; these offsets keep the map lined + * up with that text. + */ +function leadingTrim(code: string): { + text: string; + skippedLines: number; + skippedColumns: number; +} { + const text = code.trim(); + const leading = code.slice(0, code.length - code.trimStart().length); + const leadingLines = leading.split('\n'); + return { + text, + skippedLines: leadingLines.length - 1, + skippedColumns: leadingLines[leadingLines.length - 1]!.length, + }; +} + /** * Works out which stylesheets each CSS asset was built from, by way of the * chunk that pulled the asset in. Assets with no chunk claiming them are left @@ -333,9 +315,16 @@ function locateBody( css: string, code: string, claimed: readonly AssetRegion[], -): { region: AssetRegion; text: string; skippedLines: number } | null { +): { + region: AssetRegion; + text: string; + skippedLines: number; + skippedColumns: number; +} | null { const whole = findUnclaimedRegion(css, code, claimed); - if (whole) return { region: whole, text: code, skippedLines: 0 }; + if (whole) { + return { region: whole, text: code, skippedLines: 0, skippedColumns: 0 }; + } const withoutAtRules = stripHoistedAtRules(code); if (!withoutAtRules) return null; @@ -349,9 +338,11 @@ function locateBody( * returns null when it has none. CSS only permits them at the top of a file, so * they are always a prefix. */ -function stripHoistedAtRules( - code: string, -): { text: string; skippedLines: number } | null { +function stripHoistedAtRules(code: string): { + text: string; + skippedLines: number; + skippedColumns: number; +} | null { let rest = code; let found = false; @@ -368,9 +359,12 @@ function stripHoistedAtRules( const text = rest.trimStart(); const consumed = code.length - text.length; + const prefix = code.slice(0, consumed); + const prefixLines = prefix.split('\n'); return { text, - skippedLines: code.slice(0, consumed).split('\n').length - 1, + skippedLines: prefixLines.length - 1, + skippedColumns: prefixLines[prefixLines.length - 1]!.length, }; } @@ -380,7 +374,8 @@ function stripHoistedAtRules( * * Scanning rather than searching for the first `;` because a URL can contain * one — `family=Inter:wght@400;700` is how Google Fonts asks for two weights, - * and a data URI can hold any number of them. + * and a data URI can hold any number of them. Block comments are skipped for + * the same reason: `/* note; *\/` is not the end of the statement. */ function endOfStatement(code: string): number { let quote: string | null = null; @@ -395,6 +390,13 @@ function endOfStatement(code: string): number { continue; } + if (char === '/' && code[i + 1] === '*') { + const end = code.indexOf('*/', i + 2); + if (end === -1) return -1; + i = end + 1; + continue; + } + if (char === '"' || char === "'") quote = char; else if (char === '(') depth++; else if (char === ')') depth--; @@ -489,6 +491,7 @@ function overlapsClaimed( function buildConcatenatedSourcemap( placed: PlacedStylesheet[], fileName: string, + warn: (message: string) => void, ): ConcatenatedSourceMap { const sources: string[] = []; const sourcesContent: (string | null)[] = []; @@ -510,16 +513,30 @@ function buildConcatenatedSourcemap( lines[line]!.push(segment); }; - for (const { id, stylesheet, line, column, skippedLines, span } of placed) { + for (const { + id, + stylesheet, + line, + column, + skippedLines, + skippedColumns, + span, + } of placed) { // Only the first line is offset horizontally; later lines start at column 0. const shift = (index: number, col: number) => index === 0 ? column + col : col; - const decoded = stylesheet.map ? decode(stylesheet.map.mappings) : null; + const decoded = decodeMappings(stylesheet.map?.mappings); + + if (stylesheet.map && decoded == null) { + warn( + `Could not decode the source map for ${id}; attributing its CSS to the file as a whole.`, + ); + } if (decoded?.some((segments) => segments.length > 0)) { const remapped = stylesheet.map!.sources.map((source, index) => sourceIndex( - resolveSource(id, source), + resolveSource(id, source, stylesheet.map!.sourceRoot), stylesheet.map!.sourcesContent?.[index] ?? null, ), ); @@ -531,8 +548,11 @@ function buildConcatenatedSourcemap( .forEach((segments, index) => { for (const segment of segments) { if (segment.length < 4) continue; + const generatedColumn = + index === 0 ? segment[0] - skippedColumns : segment[0]; + if (generatedColumn < 0) continue; addSegment(line + index, [ - shift(index, segment[0]), + shift(index, generatedColumn), remapped[segment[1]!] ?? 0, segment[2]!, segment[3]!, @@ -545,7 +565,7 @@ function buildConcatenatedSourcemap( // keeps those lines attributed to the file that produced them rather than // pointing past its end. const index = sourceIndex(id, stylesheet.code); - const lastLine = Math.max(0, countLines(id) - 1); + const lastLine = Math.max(0, stylesheet.sourceLineCount - 1); for (let i = 0; i < span; i++) { addSegment(line + i, [ shift(i, 0), @@ -569,24 +589,66 @@ function buildConcatenatedSourcemap( }; } -function resolveSource(id: string, source: string): string { - if (source.startsWith('file://')) { +function decodeMappings( + mappings: string | undefined, +): ReturnType | null { + if (!mappings) return null; + try { + return decode(mappings); + } catch { + return null; + } +} + +function readCombinedSourcemap( + get: () => unknown, +): ExistingRawSourceMap | null { + let map: unknown; + try { + map = get(); + } catch { + return null; + } + if (isEmptySourcemap(map as ExistingRawSourceMap | null)) return null; + return map as ExistingRawSourceMap; +} + +function resolveSource( + id: string, + source: string, + sourceRoot?: string, +): string { + const rooted = applySourceRoot(source, sourceRoot); + if (rooted.startsWith('file://')) { try { // Not `URL.pathname`, which leaves a Windows path as `/C:/...` and keeps // any percent-encoding. - return fileURLToPath(source); + return fileURLToPath(rooted); } catch { // Not a well-formed file URL; treat it as a path below. } } - if (path.isAbsolute(source)) return source; - return path.resolve(path.dirname(id), source); + if (path.isAbsolute(rooted)) return rooted; + return path.resolve(path.dirname(id), rooted); +} + +function applySourceRoot(source: string, sourceRoot?: string): string { + if (!sourceRoot) return source; + if (source.startsWith('file://') || path.isAbsolute(source)) return source; + if (/^[a-zA-Z][a-zA-Z+\-.]*:/.test(sourceRoot)) { + try { + return new URL(source, sourceRoot).href; + } catch { + return source; + } + } + return path.join(sourceRoot, source); } -function countLines(file: string): number { +function countSourceLines(sourcePath: string, compiledCode: string): number { try { - return fs.readFileSync(file, 'utf-8').split('\n').length; + return fs.readFileSync(sourcePath, 'utf-8').split('\n').length; } catch { - return Number.POSITIVE_INFINITY; + return compiledCode.split('\n').length; } } From 7ec1f241f27eb03ddbf621d7881f3349785377fc Mon Sep 17 00:00:00 2001 From: Linpeng Zhang <44171495+linpengzhang@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:18:10 +0100 Subject: [PATCH 8/8] fix: locate asset placeholders with a string search Compiling a Tailwind-sized stylesheet into one regular expression throws Invalid regular expression. Match __VITE_ASSET__ gaps as literals instead. Co-authored-by: Cursor --- src/plugin.test.ts | 31 ++++++++++++ src/plugin.ts | 115 +++++++++++++++++++++++++++++++++------------ 2 files changed, 115 insertions(+), 31 deletions(-) diff --git a/src/plugin.test.ts b/src/plugin.test.ts index c241231..b480b05 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -247,6 +247,37 @@ describe('vite-plugin-css-sourcemap', () => { }); expect(last.line).toBeLessThanOrEqual(4); }); + + it('should locate a Tailwind-sized stylesheet without compiling it as a regular expression', async () => { + const plugin = cssSourcemap(); + const layer = + `/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties { + @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { + *, :before, :after, ::backdrop { + --tw-rotate-x: initial; + } + } +} +`.repeat(40); + const compiled = `${layer}.hero{background:url(__VITE_ASSET__a1b2c3__)}`; + const asset = `${layer}.hero{background:url(/assets/hero-D4t9.png)}`; + const { emitFile } = await runPlugin(plugin, { + id: '/src/tailwind.css', + code: compiled, + asset, + }); + + const map = JSON.parse(emitFile.mock.calls[0]![0].source); + expect( + map.sources.some((source: string) => source.includes('tailwind.css')), + ).toBe(true); + const position = originalPositionFor(new TraceMap(map), { + line: 1, + column: 0, + }); + expect(position.source).toContain('tailwind.css'); + }); }); const CSS_FILE = '/path/to/styles.css'; diff --git a/src/plugin.ts b/src/plugin.ts index 36b84fc..0cfd99b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -422,53 +422,106 @@ function findUnclaimedRegion( needle: string, claimed: readonly AssetRegion[], ): AssetRegion | null { - const pattern = buildNeedlePattern(needle); - - if (!pattern) { - let start = css.indexOf(needle); - while (start !== -1) { - const region = { start, end: start + needle.length }; - if (!overlapsClaimed(region, claimed)) return region; - start = css.indexOf(needle, start + 1); - } - return null; - } + ASSET_PLACEHOLDER.lastIndex = 0; + const hasPlaceholder = ASSET_PLACEHOLDER.test(needle); + ASSET_PLACEHOLDER.lastIndex = 0; - let match = pattern.exec(css); - while (match) { - const region = { start: match.index, end: match.index + match[0].length }; + let from = 0; + while (from <= css.length) { + const region = hasPlaceholder + ? matchPlaceholderNeedle(css, needle, from) + : matchExactNeedle(css, needle, from); + if (!region) return null; if (!overlapsClaimed(region, claimed)) return region; - pattern.lastIndex = match.index + 1; - match = pattern.exec(css); + from = region.start + 1; } return null; } +function matchExactNeedle( + css: string, + needle: string, + from: number, +): AssetRegion | null { + const start = css.indexOf(needle, from); + if (start === -1) return null; + return { start, end: start + needle.length }; +} + /** - * Builds a pattern for a stylesheet whose CSS still contains asset - * placeholders, or returns null when a plain substring search will do. + * Finds a stylesheet whose captured CSS still contains asset placeholders. * * A `url()` reference is still a placeholder at the point this plugin captures * the stylesheet, and `vite:css-post` substitutes the real hashed URL - * afterwards. Searching for the captured text verbatim would therefore never - * find a stylesheet that references an image or font. + * afterwards. The substituted URL never contains a quote, a closing paren, or + * a newline, so the gap between the surrounding literals cannot run past the + * `url()` it belongs to. + * + * This is a string search rather than a compiled regular expression: a + * Tailwind-generated stylesheet can be large enough, and full of grouping + * characters, that turning the whole file into a pattern throws + * `Invalid regular expression`. */ -function buildNeedlePattern(needle: string): RegExp | null { +function matchPlaceholderNeedle( + css: string, + needle: string, + from: number, +): AssetRegion | null { ASSET_PLACEHOLDER.lastIndex = 0; - if (!ASSET_PLACEHOLDER.test(needle)) return null; + const parts = needle.split(ASSET_PLACEHOLDER); + const prefix = parts[0] ?? ''; + + let searchFrom = from; + while (searchFrom <= css.length) { + let start: number; + let pos: number; + if (prefix === '') { + start = searchFrom; + pos = searchFrom; + } else { + start = css.indexOf(prefix, searchFrom); + if (start === -1) return null; + pos = start + prefix.length; + } - const source = needle - .split(ASSET_PLACEHOLDER) - .map(escapeForRegExp) - // A substituted URL never contains a quote, a closing paren, or a newline, - // so this cannot run past the end of the `url()` it belongs to. - .join(`[^"')\\n]*`); + let ok = true; + for (let i = 1; i < parts.length; i++) { + const next = parts[i] ?? ''; + const after = consumePlaceholderGap(css, pos, next); + if (after === -1) { + ok = false; + break; + } + pos = after; + } - return new RegExp(source, 'g'); + if (ok) return { start, end: pos }; + if (prefix === '') return null; + searchFrom = start + 1; + } + return null; } -function escapeForRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +function consumePlaceholderGap(css: string, pos: number, next: string): number { + if (next === '') { + while (pos < css.length && !isUrlUnsafe(css[pos] ?? '')) pos++; + return pos; + } + + const found = css.indexOf(next, pos); + if (found === -1 || !isUrlSafeRange(css, pos, found)) return -1; + return found + next.length; +} + +function isUrlUnsafe(char: string): boolean { + return char === '"' || char === "'" || char === ')' || char === '\n'; +} + +function isUrlSafeRange(css: string, from: number, to: number): boolean { + for (let i = from; i < to; i++) { + if (isUrlUnsafe(css[i] ?? '')) return false; + } + return true; } function overlapsClaimed(