Skip to content

Add support for source maps #17775

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion integrations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"devDependencies": {
"dedent": "1.5.3",
"fast-glob": "^3.3.3"
"fast-glob": "^3.3.3",
"source-map-js": "^1.2.1"
}
}
87 changes: 86 additions & 1 deletion integrations/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import fs from 'node:fs/promises'
import { platform, tmpdir } from 'node:os'
import path from 'node:path'
import { stripVTControlCharacters } from 'node:util'
import { RawSourceMap, SourceMapConsumer } from 'source-map-js'
import { test as defaultTest, type ExpectStatic } from 'vitest'
import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table'
import { escape } from '../packages/tailwindcss/src/utils/escape'

const REPO_ROOT = path.join(__dirname, '..')
Expand Down Expand Up @@ -42,6 +44,7 @@ interface TestContext {
expect: ExpectStatic
exec(command: string, options?: ChildProcessOptions, execOptions?: ExecOptions): Promise<string>
spawn(command: string, options?: ChildProcessOptions): Promise<SpawnedProcess>
parseSourceMap(opts: string | SourceMapOptions): SourceMap
fs: {
write(filePath: string, content: string, encoding?: BufferEncoding): Promise<void>
create(filePaths: string[]): Promise<void>
Expand Down Expand Up @@ -104,6 +107,7 @@ export function test(
let context = {
root,
expect: options.expect,
parseSourceMap,
async exec(
command: string,
childProcessOptions: ChildProcessOptions = {},
Expand Down Expand Up @@ -591,7 +595,9 @@ export async function fetchStyles(base: string, path = '/'): Promise<string> {
}

return stylesheets.reduce((acc, css) => {
return acc + '\n' + css
if (acc.length > 0) acc += '\n'
acc += css
return acc
}, '')
}

Expand All @@ -603,3 +609,82 @@ async function gracefullyRemove(dir: string) {
})
}
}

const SOURCE_MAP_COMMENT = /^\/\*# sourceMappingURL=data:application\/json;base64,(.*) \*\/$/

export interface SourceMap {
at(
line: number,
column: number,
): {
source: string | null
original: string
generated: string
}
}

interface SourceMapOptions {
/**
* A raw source map
*
* This may be a string or an object. Strings will be decoded.
*/
map: string | object

/**
* The content of the generated file the source map is for
*/
content: string

/**
* The encoding of the source map
*
* Can be used to decode a base64 map (e.g. an inline source map URI)
*/
encoding?: BufferEncoding
}

function parseSourceMap(opts: string | SourceMapOptions): SourceMap {
if (typeof opts === 'string') {
let lines = opts.trimEnd().split('\n')
let comment = lines.at(-1) ?? ''
let map = String(comment).match(SOURCE_MAP_COMMENT)?.[1] ?? null
if (!map) throw new Error('No source map comment found')

return parseSourceMap({
map,
content: lines.slice(0, -1).join('\n'),
encoding: 'base64',
})
}

let rawMap: RawSourceMap
let content = opts.content

if (typeof opts.map === 'object') {
rawMap = opts.map as RawSourceMap
} else {
rawMap = JSON.parse(Buffer.from(opts.map, opts.encoding ?? 'utf-8').toString())
}

let map = new SourceMapConsumer(rawMap)
let generatedTable = createLineTable(content)

return {
at(line: number, column: number) {
let pos = map.originalPositionFor({ line, column })
let source = pos.source ? map.sourceContentFor(pos.source) : null
let originalTable = createLineTable(source ?? '')
let originalOffset = originalTable.findOffset(pos)
let generatedOffset = generatedTable.findOffset({ line, column })

return {
source: pos.source,
original: source
? source.slice(originalOffset, originalOffset + 10).trim() + '...'
: '(none)',
generated: content.slice(generatedOffset, generatedOffset + 10).trim() + '...',
}
},
}
}
96 changes: 96 additions & 0 deletions integrations/vite/source-maps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'

test(
`dev build`,
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"lightningcss": "^1.26.0",
"vite": "^6"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'

export default defineConfig({
plugins: [tailwindcss()],
css: {
devSourcemap: true,
},
build: {
sourcemap: true,
},
})
`,
'index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="flex">Hello, world!</div>
</body>
`,
'src/index.css': css`
@import 'tailwindcss/utilities';
/* */
`,
},
},
async ({ fs, spawn, expect, parseSourceMap }) => {
// Source maps only work in development mode in Vite
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))

let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})

let styles = await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')

// Wait until we have the right CSS
expect(styles).toContain(candidate`flex`)

return styles
})

// Make sure we can find a source map
let map = parseSourceMap(styles)

expect(map.at(1, 0)).toMatchObject({
source: null,
original: '(none)',
generated: '/*! tailwi...',
})

expect(map.at(2, 0)).toMatchObject({
source: expect.stringContaining('node_modules/tailwindcss/utilities.css'),
original: '@tailwind...',
generated: '.flex {...',
})

expect(map.at(3, 2)).toMatchObject({
source: expect.stringContaining('node_modules/tailwindcss/utilities.css'),
original: '@tailwind...',
generated: 'display: f...',
})

expect(map.at(4, 0)).toMatchObject({
source: null,
original: '(none)',
generated: '}...',
})
},
)
4 changes: 4 additions & 0 deletions packages/@tailwindcss-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ async function loadStylesheet(id: string, base: string) {
function load() {
if (id === 'tailwindcss') {
return {
path: 'virtual:tailwindcss/index.css',
base,
content: assets.css.index,
}
Expand All @@ -125,6 +126,7 @@ async function loadStylesheet(id: string, base: string) {
id === './preflight.css'
) {
return {
path: 'virtual:tailwindcss/preflight.css',
base,
content: assets.css.preflight,
}
Expand All @@ -134,6 +136,7 @@ async function loadStylesheet(id: string, base: string) {
id === './theme.css'
) {
return {
path: 'virtual:tailwindcss/theme.css',
base,
content: assets.css.theme,
}
Expand All @@ -143,6 +146,7 @@ async function loadStylesheet(id: string, base: string) {
id === './utilities.css'
) {
return {
path: 'virtual:tailwindcss/utilities.css',
base,
content: assets.css.utilities,
}
Expand Down
Loading
Loading