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/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/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/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 a7c68ed..61b9f91 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -18,6 +18,15 @@ import './styles/components/modal.css'; // Utility styles import './styles/utilities.css'; 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'; // Original styles import './styles/main.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/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/fonts.css b/playground/src/styles/fonts.css new file mode 100644 index 0000000..8413182 --- /dev/null +++ b/playground/src/styles/fonts.css @@ -0,0 +1,9 @@ +@import url('https://fonts.example/css2?family=Inter:wght@400;700'); + +.font-body { + font-family: Inter, sans-serif; +} + +.font-heading { + font-weight: 800; +} 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/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 2bca7ac..65f1b08 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'); @@ -351,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: { @@ -384,41 +385,308 @@ 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); + const sourcemap = JSON.parse( + await readFile(resolve(assetsDir, mapFiles[0]!), 'utf-8'), + ); + + // 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 () => { + 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); + }); - expect(sourcemap.sources).toBeDefined(); - expect(Array.isArray(sourcemap.sources)).toBe(true); + afterAll(async () => { + await rimraf(distDir); + }); - // The sourcemap should include multiple SCSS files (main + partials) - // not just the entrypoint - expect(sourcemap.sources.length).toBeGreaterThan(1); + // 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()], + }); - // Check that partials are included (they should contain partial file names) - const sourceNames = sourcemap.sources.map((s: string) => - s.split('/').pop(), + 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'), ); - // 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 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'], ]; - const foundPartials = expectedPartials.filter((partial) => - sourceNames.some((name: string) => name === partial), + 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); + }); + + // 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__'); + + // 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'); + 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'); + }); + + // 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. + 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'), ); - // We should find most of the partials in the sourcemap - expect(foundPartials.length).toBeGreaterThanOrEqual(5); + 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'); + }); +}); + +// 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.test.ts b/src/plugin.test.ts index 4f2c5f2..b480b05 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', () => { @@ -52,75 +57,300 @@ describe('vite-plugin-css-sourcemap', () => { expect(hasValidExtension('/path/to/file.less')).toBe(false); }); - it('should handle custom sourcemap URL function', () => { - const customPrefix = '/custom-sourcemaps/'; - const getURL = (fileName: string) => `${customPrefix}${fileName}`; + it('should disable CSS minification by default', () => { + const plugin = cssSourcemap(); - const plugin = cssSourcemap({ getURL }); + const config = callConfig(plugin); - const mockContext = { - getCombinedSourcemap: () => ({ toString: () => '{}' }), - emitFile: vi.fn().mockReturnValue('referenceId'), - }; + expect(config).toEqual({ build: { cssMinify: false } }); + }); - 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'), - }), - ); - } + it('should leave CSS minification alone when opted out', () => { + const plugin = cssSourcemap({ disableCssMinify: false }); + + expect(callConfig(plugin)).toBeUndefined(); }); - // TODO: Fix this test - it.skip('should handle custom folder option', () => { + it('should handle custom sourcemap URL function', async () => { + const customPrefix = '/custom-sourcemaps/'; + const plugin = cssSourcemap({ + getURL: (fileName: string) => `${customPrefix}${fileName}`, + }); + + const asset = await runPluginOverSingleStylesheet(plugin); + + expect(asset.source).toContain( + `/*# sourceMappingURL=${customPrefix}styles.css.map */`, + ); + }); + + 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: { + + 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(''); + }); + + 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, - 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; }'], + mappings: encode([[], [], [[0, 0, 5, 0]]]), + sources: ['button.css'], + sourcesContent: ['.btn { color: red; }'], }, - }; - const emitFile = vi.fn().mockReturnValue('referenceId'); + }); + + 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); + }); - const mockContext = { - emitFile, - } as unknown as PluginContext; + 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; }'], + }, + }); - if (plugin.generateBundle && typeof plugin.generateBundle === 'function') { - plugin.generateBundle.call(mockContext, {} as any, mockBundle, false); + 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); + }); - expect(emitFile).toHaveBeenCalledWith( - expect.objectContaining({ - fileName: expect.stringContaining(`${customFolder}/`), - }), - ); + 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); + }); + + 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'; +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); +} + +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 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, + 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, input.code, input.id); + + const asset = { + type: 'asset', + fileName: 'styles.css', + source: input.asset, + 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, warn }); +} diff --git a/src/plugin.ts b/src/plugin.ts index 9326ce3..0cfd99b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,15 +1,11 @@ import path from 'node:path'; import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; 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 +14,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,70 +24,80 @@ 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 - } +/** 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; + /** + * 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; +} - if (assetDir && assetDir !== '.') { - fullPath = path.join(assetDir, fileName); - } - } +/** Where a compiled stylesheet ended up inside the concatenated CSS asset. */ +interface PlacedStylesheet { + id: string; + stylesheet: CompiledStylesheet; + line: number; + column: number; + /** 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; +} - return fullPath; +/** 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. 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 = {}, ): Plugin { @@ -99,6 +106,7 @@ export default function cssSourcemapPlugin( enabled = true, folder = '', getURL = (fileName: string) => fileName, + disableCssMinify = true, } = options; if (!enabled) { @@ -108,238 +116,592 @@ export default function cssSourcemapPlugin( }; } - const assetToId = new Map(); - const idToMap = new Map(); - let templateName: string; - let outputOptions: OutputOptions | null = null; - let willAugmentChunkHash = true; - let sassCompiler: any = null; + const compiled = new Map(); - /** - * 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; + return { + name: PLUGIN_NAME, + apply: 'build', - 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; + config() { + if (!disableCssMinify) return; + return { build: { cssMinify: false as const } }; + }, + + // 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. + transform(code, id) { + if (!hasValidExtension(id, extensions)) return null; + if (!code.trim()) return null; + + const sourcePath = id.split('?')[0] ?? id; + compiled.set(id, { + sourcePath, + code, + map: readCombinedSourcemap(() => this.getCombinedSourcemap()), + sourceLineCount: countSourceLines(sourcePath, code), + }); + + return null; + }, + + 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) { + 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); + // 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. + 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( + `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; + } + + const map = buildConcatenatedSourcemap(placed, fileName, (message) => + this.warn(message), + ); + const mapFileName = `${asset.fileName}.map`; + const mapBaseName = path.basename(mapFileName); + + this.emitFile({ + type: 'asset', + fileName: path.join(path.dirname(mapFileName), folder, mapBaseName), + source: JSON.stringify(map), + }); + + asset.source = `${css}\n/*# sourceMappingURL=${getURL(mapBaseName)} */`; + } + }, + }, + }; +} + +/** + * 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[] = []; + const claimed: AssetRegion[] = []; + + // 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, + ); + + for (const [, stylesheet] of byLengthDescending) { + const alignment = leadingTrim(stylesheet.code); + const body = locateBody(css, alignment.text, claimed); + if (!body) continue; + claimed.push(body.region); + + const preceding = css.slice(0, body.region.start); + placed.push({ + 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: body.region.start - (preceding.lastIndexOf('\n') + 1), + skippedLines: alignment.skippedLines + body.skippedLines, + skippedColumns: + body.skippedLines === 0 + ? alignment.skippedColumns + : body.skippedColumns, + span: body.text.split('\n').length, + }); } - /** - * 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; + return placed.sort((a, b) => a.line - b.line || a.column - b.column); +} - try { - const fileContent = await fs.promises.readFile(id, 'utf-8'); - const result = sass.compileString(fileContent, { - url: new URL(`file://${id}`), - sourceMap: true, - sourceMapIncludeSources: true, - }); +/** + * 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, + }; +} - if (result.sourceMap) { - return result.sourceMap as ExistingRawSourceMap; +/** + * 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); } - } catch (e) { - // Sass compilation failed (syntax error, file not found, etc.) - // Will fall back to identity sourcemap } - return null; } - return { - name: PLUGIN_NAME, - apply: 'build', + return byAsset; +} - buildStart(options) { - const viteCSSPlugin = options.plugins.find( - (plugin) => plugin.name === 'vite:css-post', - ); +/** + * 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; + skippedColumns: number; +} | null { + const whole = findUnclaimedRegion(css, code, claimed); + if (whole) { + return { region: whole, text: code, skippedLines: 0, skippedColumns: 0 }; + } - if (!viteCSSPlugin) { - throw new Error('vite:css-post plugin not found.'); - } + const withoutAtRules = stripHoistedAtRules(code); + if (!withoutAtRules) return null; + + const region = findUnclaimedRegion(css, withoutAtRules.text, claimed); + return region ? { region, ...withoutAtRules } : null; +} - templateName = extractFileName(options.input); +/** + * 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; + skippedColumns: number; +} | null { + let rest = code; + let found = false; + + for (;;) { + 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; + } - 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); + if (!found) return null; - if (!result) { - return result; - } + const text = rest.trimStart(); + const consumed = code.length - text.length; + const prefix = code.slice(0, consumed); + const prefixLines = prefix.split('\n'); + return { + text, + skippedLines: prefixLines.length - 1, + skippedColumns: prefixLines[prefixLines.length - 1]!.length, + }; +} - for (const id of chunk.moduleIds) { - if (hasValidExtension(id, extensions)) { - if (assetToId.has(result)) { - assetToId.get(result)?.push(id); - } else { - assetToId.set(result, [id]); - } - } - } +/** + * 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. 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; + let depth = 0; - return result; - }, - }; + for (let i = 0; i < code.length; i++) { + const char = code[i]; - const currentMethod = viteCSSPlugin['augmentChunkHash']!; - const augmentChunkHashProxy = new Proxy( - currentMethod, - augmentChunkHashHandler, - ); + if (quote) { + if (char === '\\') i++; + else if (char === quote) quote = null; + continue; + } - Object.defineProperty(viteCSSPlugin, 'augmentChunkHash', { - value: augmentChunkHashProxy, - }); - }, + if (char === '/' && code[i + 1] === '*') { + const end = code.indexOf('*/', i + 2); + if (end === -1) return -1; + i = end + 1; + continue; + } - outputOptions(options: OutputOptions) { - outputOptions = options; + 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; + } - if (typeof options.entryFileNames === 'string') { - willAugmentChunkHash = options.entryFileNames.includes('[hash]'); - } else if (typeof options.entryFileNames === 'function') { - // TODO: Implement this - } + return -1; +} - return options; - }, +/** + * 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 findUnclaimedRegion( + css: string, + needle: string, + claimed: readonly AssetRegion[], +): AssetRegion | null { + ASSET_PLACEHOLDER.lastIndex = 0; + const hasPlaceholder = ASSET_PLACEHOLDER.test(needle); + ASSET_PLACEHOLDER.lastIndex = 0; + + 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; + from = region.start + 1; + } + return null; +} - async renderChunk(_: string, chunk: RenderedChunk) { - if (willAugmentChunkHash) 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 }; +} - 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); +/** + * 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. 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 matchPlaceholderNeedle( + css: string, + needle: string, + from: number, +): AssetRegion | null { + ASSET_PLACEHOLDER.lastIndex = 0; + 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; + } - if (assetToId.has(fullPath)) { - assetToId.get(fullPath)?.push(id); - } else { - assetToId.set(fullPath, [id]); - } - } + 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 null; - }, + if (ok) return { start, end: pos }; + if (prefix === '') return null; + searchFrom = start + 1; + } + return 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); - } - } +function consumePlaceholderGap(css: string, pos: number, next: string): number { + if (next === '') { + while (pos < css.length && !isUrlUnsafe(css[pos] ?? '')) pos++; + return pos; + } - const referenceIdMap = this.emitFile({ - type: 'asset', - name: `${fileName}.map`, - source: JSON.stringify(sourcemap), - }); + const found = css.indexOf(next, pos); + if (found === -1 || !isUrlSafeRange(css, pos, found)) return -1; + return found + next.length; +} - idToMap.set(id, referenceIdMap); +function isUrlUnsafe(char: string): boolean { + return char === '"' || char === "'" || char === ')' || char === '\n'; +} - return { - code: code, - map: sourcemap, - }; - } +function isUrlSafeRange(css: string, from: number, to: number): boolean { + for (let i = from; i < to; i++) { + if (isUrlUnsafe(css[i] ?? '')) return false; + } + return true; +} - return null; - }, +function overlapsClaimed( + region: AssetRegion, + claimed: readonly AssetRegion[], +): boolean { + return claimed.some( + (other) => region.start < other.end && other.start < region.end, + ); +} - 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, - ); +/** + * 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, + warn: (message: string) => void, +): 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 (!finalSourceMap) { - console.warn(`No source map found for ${fileName}`); - continue; - } + 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); + }; - const mapReferencePath = path.basename(newMapFileName); - const outputPath = path.dirname(newMapFileName); + 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 = 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.`, + ); + } - this.emitFile({ - type: 'asset', - fileName: path.join(outputPath, folder, mapReferencePath), - source: - typeof finalSourceMap === 'string' - ? finalSourceMap - : JSON.stringify(finalSourceMap), - }); + if (decoded?.some((segments) => segments.length > 0)) { + const remapped = stylesheet.map!.sources.map((source, index) => + sourceIndex( + resolveSource(id, source, stylesheet.map!.sourceRoot), + stylesheet.map!.sourcesContent?.[index] ?? null, + ), + ); - asset.source += `\n/*# sourceMappingURL=${getURL(mapReferencePath)} */`; - } + // 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; + const generatedColumn = + index === 0 ? segment[0] - skippedColumns : segment[0]; + if (generatedColumn < 0) continue; + addSegment(line + index, [ + shift(index, generatedColumn), + 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 + // 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, stylesheet.sourceLineCount - 1); + for (let i = 0; i < span; i++) { + addSegment(line + i, [ + shift(i, 0), + index, + Math.min(i + skippedLines, lastLine), + 0, + ]); } - }, + } + } + + return { + version: 3, + file: path.basename(fileName), + sources, + sourcesContent, + names: [], + mappings: encode( + lines.map((segments) => segments.sort((a, b) => a[0] - b[0])), + ), }; } + +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(rooted); + } catch { + // Not a well-formed file URL; treat it as a path below. + } + } + 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 countSourceLines(sourcePath: string, compiledCode: string): number { + try { + return fs.readFileSync(sourcePath, 'utf-8').split('\n').length; + } catch { + return compiledCode.split('\n').length; + } +} 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; -}