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;
-}