diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index d30f6e24b..22d3a9c6a 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -23,7 +23,7 @@ describe.each([ 'Standalone CLI', path.resolve(__dirname, `../../packages/@tailwindcss-standalone/dist/${STANDALONE_BINARY}`), ], -])('%s', (_, command) => { +])('%s', (kind, command) => { test( 'production build', { @@ -628,6 +628,198 @@ describe.each([ ]) }, ) + + test( + 'production build + inline source maps', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'ssrc/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + /* */ + `, + }, + }, + async ({ exec, expect, fs, parseSourceMap }) => { + await exec(`${command} --input src/index.css --output dist/out.css --map`) + + console.log(await fs.read('dist/out.css')) + + await fs.expectFileToContain('dist/out.css', [candidate`flex`]) + + // Make sure we can find a source map + let map = parseSourceMap(await fs.read('dist/out.css')) + + expect(map.at(1, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '/*! tailwi...', + }) + + expect(map.at(2, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('node_modules/tailwindcss/utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.flex {...', + }) + + expect(map.at(3, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('node_modules/tailwindcss/utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'display: f...', + }) + + expect(map.at(4, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}...', + }) + }, + ) + + test( + 'production build + separate source maps', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'ssrc/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + /* */ + `, + }, + }, + async ({ exec, expect, fs, parseSourceMap }) => { + await exec(`${command} --input src/index.css --output dist/out.css --map dist/out.css.map`) + + await fs.expectFileToContain('dist/out.css', [candidate`flex`]) + + // Make sure we can find a source map + let map = parseSourceMap({ + map: await fs.read('dist/out.css.map'), + content: await fs.read('dist/out.css'), + }) + + expect(map.at(1, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '/*! tailwi...', + }) + + expect(map.at(2, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('node_modules/tailwindcss/utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.flex {...', + }) + + expect(map.at(3, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('node_modules/tailwindcss/utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'display: f...', + }) + + expect(map.at(4, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}...', + }) + }, + ) + + // Skipped because Lightning CSS has a bug with source maps containing + // license comments and it breaks stuff. + test.skip( + 'production build + minify + source maps', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'ssrc/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + /* */ + `, + }, + }, + async ({ exec, expect, fs, parseSourceMap }) => { + await exec(`${command} --input src/index.css --output dist/out.css --minify --map`) + + await fs.expectFileToContain('dist/out.css', [candidate`flex`]) + + console.log(await fs.read('dist/out.css')) + + // Make sure we can find a source map + let map = parseSourceMap(await fs.read('dist/out.css')) + + expect(map.at(1, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '/*! tailwi...', + }) + + expect(map.at(2, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('node_modules/tailwindcss/utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.flex {...', + }) + + expect(map.at(3, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('node_modules/tailwindcss/utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'display: f...', + }) + + expect(map.at(4, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}...', + }) + }, + ) }) test( diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index 5e8ecdbae..936540ddd 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -1,5 +1,12 @@ import watcher from '@parcel/watcher' -import { compile, env, Instrumentation, optimize } from '@tailwindcss/node' +import { + compile, + env, + Instrumentation, + optimize, + toSourceMap, + type SourceMap, +} from '@tailwindcss/node' import { clearRequireCache } from '@tailwindcss/node/require-cache' import { Scanner, type ChangedContent } from '@tailwindcss/oxide' import { existsSync, type Stats } from 'node:fs' @@ -52,6 +59,11 @@ export function options() { description: 'The current working directory', default: '.', }, + '--map': { + type: 'boolean | string', + description: 'Generate a source map', + default: false, + }, } satisfies Arg } @@ -105,6 +117,21 @@ export async function handle(args: Result>) { process.exit(1) } + // If the user passes `{bin} build --map -` then this likely means they want to output the map inline + // this is the default behavior of `{bin build} --map` to inform the user of that + if (args['--map'] === '-') { + eprintln(header()) + eprintln() + eprintln(`Use --map without a value to inline the source map`) + process.exit(1) + } + + // Resolve the map as an absolute path. If the output is true then we + // don't need to resolve it because it'll be an inline source map + if (args['--map'] && args['--map'] !== true) { + args['--map'] = path.resolve(base, args['--map']) + } + let start = process.hrtime.bigint() let input = args['--input'] @@ -120,7 +147,12 @@ export async function handle(args: Result>) { optimizedCss: '', } - async function write(css: string, args: Result>, I: Instrumentation) { + async function write( + css: string, + map: SourceMap | null, + args: Result>, + I: Instrumentation, + ) { let output = css // Optimize the output @@ -130,10 +162,14 @@ export async function handle(args: Result>) { let optimized = optimize(css, { file: args['--input'] ?? 'input.css', minify: args['--minify'] ?? false, + map: map?.raw ?? undefined, }) DEBUG && I.end('Optimize CSS') previous.css = css previous.optimizedCss = optimized.code + if (optimized.map) { + map = toSourceMap(optimized.map) + } output = optimized.code } else { output = previous.optimizedCss @@ -141,6 +177,18 @@ export async function handle(args: Result>) { } // Write the output + if (map) { + // Inline the source map + if (args['--map'] === true) { + output += `\n` + output += map.inline + } else if (typeof args['--map'] === 'string') { + DEBUG && I.start('Write source map') + await outputFile(args['--map'], map.raw) + DEBUG && I.end('Write source map') + } + } + DEBUG && I.start('Write output') if (args['--output'] && args['--output'] !== '-') { await outputFile(args['--output'], output) @@ -160,6 +208,7 @@ export async function handle(args: Result>) { async function createCompiler(css: string, I: Instrumentation) { DEBUG && I.start('Setup compiler') let compiler = await compile(css, { + from: args['--output'] ? (inputFilePath ?? 'stdin.css') : undefined, base: inputBasePath, onDependency(path) { fullRebuildPaths.push(path) @@ -231,6 +280,7 @@ export async function handle(args: Result>) { // Track the compiled CSS let compiledCss = '' + let compiledMap: SourceMap | null = null // Scan the entire `base` directory for full rebuilds. if (rebuildStrategy === 'full') { @@ -269,6 +319,12 @@ export async function handle(args: Result>) { DEBUG && I.start('Build CSS') compiledCss = compiler.build(candidates) DEBUG && I.end('Build CSS') + + if (args['--map']) { + DEBUG && I.start('Build Source Map') + compiledMap = compiler.buildSourceMap() as any + DEBUG && I.end('Build Source Map') + } } // Scan changed files only for incremental rebuilds. @@ -288,9 +344,15 @@ export async function handle(args: Result>) { DEBUG && I.start('Build CSS') compiledCss = compiler.build(newCandidates) DEBUG && I.end('Build CSS') + + if (args['--map']) { + DEBUG && I.start('Build Source Map') + compiledMap = compiler.buildSourceMap() as any + DEBUG && I.end('Build Source Map') + } } - await write(compiledCss, args, I) + await write(compiledCss, compiledMap, args, I) let end = process.hrtime.bigint() eprintln(`Done in ${formatDuration(end - start)}`) @@ -325,7 +387,16 @@ export async function handle(args: Result>) { DEBUG && I.start('Build CSS') let output = await handleError(() => compiler.build(candidates)) DEBUG && I.end('Build CSS') - await write(output, args, I) + + let map: SourceMap | null = null + + if (args['--map']) { + DEBUG && I.start('Build Source Map') + map = await handleError(() => toSourceMap(compiler.buildSourceMap())) + DEBUG && I.end('Build Source Map') + } + + await write(output, map, args, I) let end = process.hrtime.bigint() eprintln(header())