From 3f8f50f81ff911aff6ee42baecb87dcd7b7d71bb Mon Sep 17 00:00:00 2001 From: Jordan Pittman <jordan@cryptica.me> Date: Thu, 3 Apr 2025 14:26:02 -0400 Subject: [PATCH 1/8] Cleanup code a bit --- packages/tailwindcss/src/ast.ts | 10 ++++------ packages/tailwindcss/src/css-parser.ts | 13 ++++++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index af76b0772faf..7d23f66e578f 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -677,7 +677,8 @@ export function toCss(ast: AstNode[]) { // @layer base, components, utilities; // ``` if (node.nodes.length === 0) { - return `${indent}${node.name} ${node.params};\n` + let css = `${indent}${node.name} ${node.params};\n` + return css } css += `${indent}${node.name}${node.params ? ` ${node.params} ` : ' '}{\n` @@ -692,7 +693,7 @@ export function toCss(ast: AstNode[]) { css += `${indent}/*${node.value}*/\n` } - // These should've been handled already by `prepareAstForPrinting` which + // These should've been handled already by `optimizeAst` which // means we can safely ignore them here. We return an empty string // immediately to signal that something went wrong. else if (node.kind === 'context' || node.kind === 'at-root') { @@ -710,10 +711,7 @@ export function toCss(ast: AstNode[]) { let css = '' for (let node of ast) { - let result = stringify(node) - if (result !== '') { - css += result - } + css += stringify(node, 0) } return css diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index df10fa850035..732ae7fe5013 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -31,8 +31,14 @@ const AT_SIGN = 0x40 const EXCLAMATION_MARK = 0x21 export function parse(input: string) { - if (input[0] === '\uFEFF') input = input.slice(1) - input = input.replaceAll('\r\n', '\n') + // Note: it is important that any transformations of the input string + // *before* processing do NOT change the length of the string. This + // would invalidate the mechanism used to track source locations. + if (input[0] === '\uFEFF') { + input = ' ' + input.slice(1) + } + + input = input.replaceAll('\r\n', ' \n') let ast: AstNode[] = [] let licenseComments: Comment[] = [] @@ -104,7 +110,8 @@ export function parse(input: string) { // Collect all license comments so that we can hoist them to the top of // the AST. if (commentString.charCodeAt(2) === EXCLAMATION_MARK) { - licenseComments.push(comment(commentString.slice(2, -2))) + let node = comment(commentString.slice(2, -2)) + licenseComments.push(node) } } From 62d2245c651158248cb88f28c777a9077973066a Mon Sep 17 00:00:00 2001 From: Jordan Pittman <jordan@cryptica.me> Date: Fri, 10 Jan 2025 21:09:13 -0500 Subject: [PATCH 2/8] Attach offset metadata to the AST These offsets are for the original input (`src`) as well as the printed output (`dst`) --- packages/tailwindcss/src/ast.ts | 54 +++++++++++++++++++ .../tailwindcss/src/source-maps/offsets.ts | 47 ++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 packages/tailwindcss/src/source-maps/offsets.ts diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 7d23f66e578f..aa4806846ca3 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -1,6 +1,7 @@ import { Polyfills } from '.' import { parseAtRule } from './css-parser' import type { DesignSystem } from './design-system' +import type { Offsets } from './source-maps/offsets' import { Theme, ThemeOptions } from './theme' import { DefaultMap } from './utils/default-map' import { extractUsedVariables } from './utils/variables' @@ -12,6 +13,14 @@ export type StyleRule = { kind: 'rule' selector: string nodes: AstNode[] + + offsets: { + /** The bounds of the rule's selector */ + selector?: Offsets + + /** The bounds of the rule's body including the braces */ + body?: Offsets + } } export type AtRule = { @@ -19,6 +28,17 @@ export type AtRule = { name: string params: string nodes: AstNode[] + + offsets: { + /** The bounds of the rule's name */ + name?: Offsets + + /** The bounds of the rule's params */ + params?: Offsets + + /** The bounds of the rule's body including the braces */ + body?: Offsets + } } export type Declaration = { @@ -26,22 +46,50 @@ export type Declaration = { property: string value: string | undefined important: boolean + + offsets: { + /** The bounds of the property name */ + property?: Offsets + + /** The bounds of the property value */ + value?: Offsets + } } export type Comment = { kind: 'comment' value: string + + offsets: { + /** The bounds of the comment itself including open/close characters */ + value?: Offsets + } } export type Context = { kind: 'context' context: Record<string, string | boolean> nodes: AstNode[] + + offsets: { + /** + * The bounds of the "body" + * + * Since imports expand into context nodes this can, for example, represent + * the bounds of an entire `@import` rule. + */ + body?: Offsets + } } export type AtRoot = { kind: 'at-root' nodes: AstNode[] + + offsets: { + /** The bounds of the rule's body */ + body?: Offsets + } } export type Rule = StyleRule | AtRule @@ -52,6 +100,7 @@ export function styleRule(selector: string, nodes: AstNode[] = []): StyleRule { kind: 'rule', selector, nodes, + offsets: {}, } } @@ -61,6 +110,7 @@ export function atRule(name: string, params: string = '', nodes: AstNode[] = []) name, params, nodes, + offsets: {}, } } @@ -78,6 +128,7 @@ export function decl(property: string, value: string | undefined, important = fa property, value, important, + offsets: {}, } } @@ -85,6 +136,7 @@ export function comment(value: string): Comment { return { kind: 'comment', value: value, + offsets: {}, } } @@ -93,6 +145,7 @@ export function context(context: Record<string, string | boolean>, nodes: AstNod kind: 'context', context, nodes, + offsets: {}, } } @@ -100,6 +153,7 @@ export function atRoot(nodes: AstNode[]): AtRoot { return { kind: 'at-root', nodes, + offsets: {}, } } diff --git a/packages/tailwindcss/src/source-maps/offsets.ts b/packages/tailwindcss/src/source-maps/offsets.ts new file mode 100644 index 000000000000..7f27fb1005ec --- /dev/null +++ b/packages/tailwindcss/src/source-maps/offsets.ts @@ -0,0 +1,47 @@ +/** + * A range between to points in in some text + */ +export type Span = [start: number, end: number] + +/** + * The source code for a given node in the AST + */ +export interface Source { + /** + * The path to the file that contains the referenced source code + * + * If this references the *output* source code, this is `null`. + */ + file: string | null + + /** + * The referenced source code + */ + code: string +} + +/** + * Represents a range in a source file or string and the range in the + * transformed output. + * + * e.g. `src` represents the original source position and `dst` represents the + * transformed position after reprinting. + * + * These numbers are indexes into the source code rather than line/column + * numbers. We compute line/column numbers lazily only when generating + * source maps. + */ +export interface Offsets { + original?: Source + generated?: Source + + src: Span + dst: Span | null +} + +export function createInputSource(file: string, code: string): Source { + return { + file, + code, + } +} From 258ed8739fb79d9a34f98be9c01989df233f5c52 Mon Sep 17 00:00:00 2001 From: Jordan Pittman <jordan@cryptica.me> Date: Sun, 12 Jan 2025 22:28:21 -0500 Subject: [PATCH 3/8] Track offsets when printing --- packages/tailwindcss/src/ast.bench.ts | 28 +++++ packages/tailwindcss/src/ast.ts | 157 +++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 packages/tailwindcss/src/ast.bench.ts diff --git a/packages/tailwindcss/src/ast.bench.ts b/packages/tailwindcss/src/ast.bench.ts new file mode 100644 index 000000000000..a2d8b28920f2 --- /dev/null +++ b/packages/tailwindcss/src/ast.bench.ts @@ -0,0 +1,28 @@ +import { bench } from 'vitest' +import { toCss } from './ast' +import * as CSS from './css-parser' + +const css = String.raw +const input = css` + @theme { + --color-primary: #333; + } + @tailwind utilities; + .foo { + color: red; + /* comment */ + &:hover { + color: blue; + @apply font-bold; + } + } +` +const ast = CSS.parse(input) + +bench('toCss', () => { + toCss(ast) +}) + +bench('toCss with source maps', () => { + toCss(ast, true) +}) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index aa4806846ca3..5ba55f947989 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -1,7 +1,7 @@ import { Polyfills } from '.' import { parseAtRule } from './css-parser' import type { DesignSystem } from './design-system' -import type { Offsets } from './source-maps/offsets' +import type { Offsets, Span } from './source-maps/offsets' import { Theme, ThemeOptions } from './theme' import { DefaultMap } from './utils/default-map' import { extractUsedVariables } from './utils/variables' @@ -615,6 +615,7 @@ export function optimizeAst( path.unshift({ kind: 'at-root', nodes: newAst, + offsets: {}, }) // Remove nodes from the parent as long as the parent is empty @@ -702,7 +703,15 @@ export function optimizeAst( return newAst } -export function toCss(ast: AstNode[]) { +export function toCss(ast: AstNode[], track?: boolean) { + let pos = 0 + + function span(value: string) { + let tmp: Span = [pos, pos + value.length] + pos += value.length + return tmp + } + function stringify(node: AstNode, depth = 0): string { let css = '' let indent = ' '.repeat(depth) @@ -710,15 +719,77 @@ export function toCss(ast: AstNode[]) { // Declaration if (node.kind === 'declaration') { css += `${indent}${node.property}: ${node.value}${node.important ? ' !important' : ''};\n` + + if (track) { + // indent + pos += indent.length + + // node.property + if (node.offsets.property) { + node.offsets.property.dst = span(node.property) + } + + // `: ` + pos += 2 + + // node.value + if (node.offsets.value) { + node.offsets.value.dst = span(node.value!) + } + + // !important + if (node.important) { + pos += 11 + } + + // `;\n` + pos += 2 + } } // Rule else if (node.kind === 'rule') { css += `${indent}${node.selector} {\n` + + if (track) { + // indent + pos += indent.length + + // node.selector + if (node.offsets.selector) { + node.offsets.selector.dst = span(node.selector) + } + + // ` ` + pos += 1 + + // `{` + if (track && node.offsets.body) { + node.offsets.body.dst = span(`{`) + } + + // `\n` + pos += 1 + } + for (let child of node.nodes) { css += stringify(child, depth + 1) } + css += `${indent}}\n` + + if (track) { + // indent + pos += indent.length + + // `}` + if (node.offsets.body?.dst) { + node.offsets.body.dst[1] = span(`}`)[1] + } + + // `\n` + pos += 1 + } } // AtRule @@ -732,19 +803,101 @@ export function toCss(ast: AstNode[]) { // ``` if (node.nodes.length === 0) { let css = `${indent}${node.name} ${node.params};\n` + + if (track) { + // indent + pos += indent.length + + // node.name + if (node.offsets.name) { + node.offsets.name.dst = span(node.name) + } + + // ` ` + pos += 1 + + // node.params + if (node.offsets.params) { + node.offsets.params.dst = span(node.params) + } + + // `;\n` + pos += 2 + } + return css } css += `${indent}${node.name}${node.params ? ` ${node.params} ` : ' '}{\n` + + if (track) { + // indent + pos += indent.length + + // node.name + if (node.offsets.name) { + node.offsets.name.dst = span(node.name) + } + + if (node.params) { + // ` ` + pos += 1 + + // node.params + if (node.offsets.params) { + node.offsets.params.dst = span(node.params) + } + } + + // ` ` + pos += 1 + + // `{` + if (track && node.offsets.body) { + node.offsets.body.dst = span(`{`) + } + + // `\n` + pos += 1 + } + for (let child of node.nodes) { css += stringify(child, depth + 1) } + css += `${indent}}\n` + + if (track) { + // indent + pos += indent.length + + // `}` + if (node.offsets.body?.dst) { + node.offsets.body.dst[1] = span(`}`)[1] + } + + // `\n` + pos += 1 + } } // Comment else if (node.kind === 'comment') { css += `${indent}/*${node.value}*/\n` + + if (track) { + // indent + pos += indent.length + + // The comment itself. We do this instead of just the inside because + // it seems more useful to have the entire comment span tracked. + if (node.offsets.value) { + node.offsets.value.dst = span(`/*${node.value}*/`) + } + + // `\n` + pos += 1 + } } // These should've been handled already by `optimizeAst` which From 47cc835f3914fbf3cfdabdd599c242c01e5a8f18 Mon Sep 17 00:00:00 2001 From: Jordan Pittman <jordan@cryptica.me> Date: Thu, 3 Apr 2025 14:26:05 -0400 Subject: [PATCH 4/8] Track offsets when parsing --- packages/tailwindcss/src/css-parser.bench.ts | 4 + packages/tailwindcss/src/css-parser.ts | 147 ++++++++++++++++++- 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/css-parser.bench.ts b/packages/tailwindcss/src/css-parser.bench.ts index ab490e103849..d48f88ab3841 100644 --- a/packages/tailwindcss/src/css-parser.bench.ts +++ b/packages/tailwindcss/src/css-parser.bench.ts @@ -10,3 +10,7 @@ const cssFile = readFileSync(currentFolder + './preflight.css', 'utf-8') bench('css-parser on preflight.css', () => { CSS.parse(cssFile) }) + +bench('CSS with sourcemaps', () => { + CSS.parse(cssFile, { from: 'input.css' }) +}) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 732ae7fe5013..bd68eb2869a5 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -9,6 +9,7 @@ import { type Declaration, type Rule, } from './ast' +import { createInputSource } from './source-maps/offsets' const BACKSLASH = 0x5c const SLASH = 0x2f @@ -30,7 +31,13 @@ const DASH = 0x2d const AT_SIGN = 0x40 const EXCLAMATION_MARK = 0x21 -export function parse(input: string) { +export interface ParseOptions { + from?: string +} + +export function parse(input: string, opts?: ParseOptions) { + let source = opts?.from ? createInputSource(opts.from, input) : null + // Note: it is important that any transformations of the input string // *before* processing do NOT change the length of the string. This // would invalidate the mechanism used to track source locations. @@ -51,6 +58,9 @@ export function parse(input: string) { let buffer = '' let closingBracketStack = '' + // The start of the first non-whitespace character in the buffer + let bufferStart = 0 + let peekChar for (let i = 0; i < input.length; i++) { @@ -67,6 +77,7 @@ export function parse(input: string) { // ``` // if (currentChar === BACKSLASH) { + if (buffer === '') bufferStart = i buffer += input.slice(i, i + 2) i += 1 } @@ -112,6 +123,14 @@ export function parse(input: string) { if (commentString.charCodeAt(2) === EXCLAMATION_MARK) { let node = comment(commentString.slice(2, -2)) licenseComments.push(node) + + if (source) { + node.offsets.value = { + original: source, + src: [start, i + 1], + dst: null, + } + } } } @@ -211,6 +230,7 @@ export function parse(input: string) { let start = i let colonIdx = -1 + let valueIdx = -1 for (let j = i + 2; j < input.length; j++) { peekChar = input.charCodeAt(j) @@ -291,6 +311,11 @@ export function parse(input: string) { closingBracketStack = closingBracketStack.slice(0, -1) } } + + // Value part of the custom property + else if (colonIdx !== -1 && valueIdx == -1) { + valueIdx = i + } } let declaration = parseDeclaration(buffer, colonIdx) @@ -302,6 +327,20 @@ export function parse(input: string) { ast.push(declaration) } + if (source) { + declaration.offsets.property = { + original: source, + src: [start, colonIdx], + dst: null, + } + + declaration.offsets.value = { + original: source, + src: [valueIdx, i], + dst: null, + } + } + buffer = '' } @@ -326,6 +365,23 @@ export function parse(input: string) { ast.push(node) } + // Track the source location for source maps + if (source) { + // TODO + node.offsets.name = { + original: source, + src: [bufferStart, bufferStart], + dst: null, + } + + // TODO + node.offsets.params = { + original: source, + src: [bufferStart, bufferStart], + dst: null, + } + } + // Reset the state for the next node. buffer = '' node = null @@ -358,6 +414,22 @@ export function parse(input: string) { ast.push(declaration) } + if (source) { + // TODO + declaration.offsets.property = { + original: source, + src: [bufferStart, bufferStart], + dst: null, + } + + // TODO + declaration.offsets.value = { + original: source, + src: [bufferStart, i], + dst: null, + } + } + buffer = '' } @@ -384,6 +456,39 @@ export function parse(input: string) { // attached to it. parent = node + // Track the source location for source maps + if (source) { + if (node.kind === 'rule') { + // TODO + node.offsets.selector = { + original: source, + src: [bufferStart, bufferStart], + dst: null, + } + } else if (node.kind === 'at-rule') { + // TODO + node.offsets.name = { + original: source, + src: [bufferStart, bufferStart], + dst: null, + } + + // TODO + node.offsets.params = { + original: source, + src: [bufferStart, bufferStart], + dst: null, + } + } + + // TODO: This might be correct already?? + node.offsets.body = { + original: source, + src: [i, i], + dst: null, + } + } + // Reset the state for the next node. buffer = '' node = null @@ -427,6 +532,25 @@ export function parse(input: string) { ast.push(node) } + // Track the source location for source maps + if (source) { + // TODO + node.offsets.name = { + original: source, + src: [bufferStart, bufferStart], + dst: null, + } + + // TODO + node.offsets.params = { + original: source, + src: [bufferStart, bufferStart], + dst: null, + } + + // No body for this at-rule + } + // Reset the state for the next node. buffer = '' node = null @@ -454,6 +578,22 @@ export function parse(input: string) { if (!node) throw new Error(`Invalid declaration: \`${buffer.trim()}\``) parent.nodes.push(node) + + if (source) { + // TODO + node.offsets.property = { + original: source, + src: [bufferStart, bufferStart], + dst: null, + } + + // TODO + node.offsets.value = { + original: source, + src: [bufferStart, i], + dst: null, + } + } } } } @@ -466,6 +606,11 @@ export function parse(input: string) { // node. if (grandParent === null && parent) { ast.push(parent) + + // We want to track the closing `}` as part of the parent node. + if (source && parent.offsets.body) { + parent.offsets.body.src[1] = i + } } // Go up one level in the stack. From 4b8a842a96a8130eee5d4ce621b4490ab1661551 Mon Sep 17 00:00:00 2001 From: Jordan Pittman <jordan@cryptica.me> Date: Thu, 3 Apr 2025 13:29:55 -0400 Subject: [PATCH 5/8] Track locations inside `@import` --- packages/tailwindcss/src/at-import.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss/src/at-import.ts b/packages/tailwindcss/src/at-import.ts index effd33e5b203..c8906addb24b 100644 --- a/packages/tailwindcss/src/at-import.ts +++ b/packages/tailwindcss/src/at-import.ts @@ -10,6 +10,7 @@ export async function substituteAtImports( base: string, loadStylesheet: LoadStylesheet, recurseCount = 0, + track = false, ) { let features = Features.None let promises: Promise<void>[] = [] @@ -45,8 +46,8 @@ export async function substituteAtImports( } let loaded = await loadStylesheet(uri, base) - let ast = CSS.parse(loaded.content) - await substituteAtImports(ast, loaded.base, loadStylesheet, recurseCount + 1) + let ast = CSS.parse(loaded.content, { from: track ? uri : undefined }) + await substituteAtImports(ast, loaded.base, loadStylesheet, recurseCount + 1, track) contextNode.nodes = buildImportNodes( [context({ base: loaded.base }, ast)], From f82c79b4ad6cfc23ee9043ab19388f967c1f26f3 Mon Sep 17 00:00:00 2001 From: Jordan Pittman <jordan@cryptica.me> Date: Sun, 12 Jan 2025 22:41:25 -0500 Subject: [PATCH 6/8] Use offset information to generate source maps --- packages/tailwindcss/package.json | 5 +- .../src/source-maps/line-table.test.ts | 46 ++++ .../tailwindcss/src/source-maps/line-table.ts | 78 ++++++ .../src/source-maps/source-map.test.ts | 238 +++++++++++++++++ .../tailwindcss/src/source-maps/source-map.ts | 203 ++++++++++++++ .../src/source-maps/translation-map.test.ts | 250 ++++++++++++++++++ .../src/source-maps/translation-map.ts | 38 +++ packages/tailwindcss/src/source-maps/types.ts | 23 ++ pnpm-lock.yaml | 31 ++- 9 files changed, 900 insertions(+), 12 deletions(-) create mode 100644 packages/tailwindcss/src/source-maps/line-table.test.ts create mode 100644 packages/tailwindcss/src/source-maps/line-table.ts create mode 100644 packages/tailwindcss/src/source-maps/source-map.test.ts create mode 100644 packages/tailwindcss/src/source-maps/source-map.ts create mode 100644 packages/tailwindcss/src/source-maps/translation-map.test.ts create mode 100644 packages/tailwindcss/src/source-maps/translation-map.ts create mode 100644 packages/tailwindcss/src/source-maps/types.ts diff --git a/packages/tailwindcss/package.json b/packages/tailwindcss/package.json index 22683b37c8fe..82a945cd34f3 100644 --- a/packages/tailwindcss/package.json +++ b/packages/tailwindcss/package.json @@ -127,9 +127,12 @@ "utilities.css" ], "devDependencies": { + "@ampproject/remapping": "^2.3.0", "@tailwindcss/oxide": "workspace:^", "@types/node": "catalog:", + "dedent": "1.5.3", "lightningcss": "catalog:", - "dedent": "1.5.3" + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1" } } diff --git a/packages/tailwindcss/src/source-maps/line-table.test.ts b/packages/tailwindcss/src/source-maps/line-table.test.ts new file mode 100644 index 000000000000..cf08b84e8608 --- /dev/null +++ b/packages/tailwindcss/src/source-maps/line-table.test.ts @@ -0,0 +1,46 @@ +import dedent from 'dedent' +import { expect, test } from 'vitest' +import { createLineTable } from './line-table' + +const css = dedent + +test('line tables', () => { + let text = css` + .foo { + color: red; + } + ` + + let table = createLineTable(`${text}\n`) + + // Line 1: `.foo {\n` + expect(table.find(0)).toEqual({ line: 1, column: 1 }) + expect(table.find(1)).toEqual({ line: 1, column: 2 }) + expect(table.find(2)).toEqual({ line: 1, column: 3 }) + expect(table.find(3)).toEqual({ line: 1, column: 4 }) + expect(table.find(4)).toEqual({ line: 1, column: 5 }) + expect(table.find(5)).toEqual({ line: 1, column: 6 }) + expect(table.find(6)).toEqual({ line: 1, column: 7 }) + + // Line 2: ` color: red;\n` + expect(table.find(6 + 1)).toEqual({ line: 2, column: 1 }) + expect(table.find(6 + 2)).toEqual({ line: 2, column: 2 }) + expect(table.find(6 + 3)).toEqual({ line: 2, column: 3 }) + expect(table.find(6 + 4)).toEqual({ line: 2, column: 4 }) + expect(table.find(6 + 5)).toEqual({ line: 2, column: 5 }) + expect(table.find(6 + 6)).toEqual({ line: 2, column: 6 }) + expect(table.find(6 + 7)).toEqual({ line: 2, column: 7 }) + expect(table.find(6 + 8)).toEqual({ line: 2, column: 8 }) + expect(table.find(6 + 9)).toEqual({ line: 2, column: 9 }) + expect(table.find(6 + 10)).toEqual({ line: 2, column: 10 }) + expect(table.find(6 + 11)).toEqual({ line: 2, column: 11 }) + expect(table.find(6 + 12)).toEqual({ line: 2, column: 12 }) + expect(table.find(6 + 13)).toEqual({ line: 2, column: 13 }) + + // Line 3: `}\n` + expect(table.find(20 + 1)).toEqual({ line: 3, column: 1 }) + expect(table.find(20 + 2)).toEqual({ line: 3, column: 2 }) + + // After the new line + expect(table.find(22 + 1)).toEqual({ line: 4, column: 1 }) +}) diff --git a/packages/tailwindcss/src/source-maps/line-table.ts b/packages/tailwindcss/src/source-maps/line-table.ts new file mode 100644 index 000000000000..11fc84bab8cf --- /dev/null +++ b/packages/tailwindcss/src/source-maps/line-table.ts @@ -0,0 +1,78 @@ +/** + * Line offset tables are the key to generating our source maps. They allow us + * to store indexes with our AST nodes and later convert them into positions as + * when given the source that the indexes refer to. + */ + +const LINE_BREAK = 0x0a + +/** + * A position in source code + */ +export interface Position { + /** The line number, one-based */ + line: number + + /** The column/character number, one-based */ + column: number +} + +/** + * A table that lets you turn an offset into a line number and column + */ +export interface LineTable { + /** + * Find the line/column position in the source code for a given offset + * + * Searching for a given offset takes O(log N) time where N is the number of + * lines of code. + * + * @param offset The index for which to find the position + */ + find(offset: number): Position +} + +/** + * Compute a lookup table to allow for efficient line/column lookups based on + * offsets in the source code. + * + * Creating this table is an O(N) operation where N is the length of the source + */ +export function createLineTable(source: string): LineTable { + let table: number[] = [0] + + // Compute the offsets for the start of each line + for (let i = 0; i < source.length; i++) { + if (source.charCodeAt(i) === LINE_BREAK) { + table.push(i + 1) + } + } + + function find(offset: number) { + // Based on esbuild's binary search for line numbers + let line = 0 + let count = table.length + while (count > 0) { + // `| 0` causes integer division + let mid = (count / 2) | 0 + let i = line + mid + if (table[i] <= offset) { + line = i + 1 + count = count - mid - 1 + } else { + count = mid + } + } + + line -= 1 + + let column = offset - table[line] + + return { + line: line + 1, + column: column + 1, + } + } + + return { find } +} diff --git a/packages/tailwindcss/src/source-maps/source-map.test.ts b/packages/tailwindcss/src/source-maps/source-map.test.ts new file mode 100644 index 000000000000..f0692f3f8314 --- /dev/null +++ b/packages/tailwindcss/src/source-maps/source-map.test.ts @@ -0,0 +1,238 @@ +import remapping from '@ampproject/remapping' +import MagicString, { Bundle } from 'magic-string' +import { SourceMapConsumer, SourceMapGenerator, type RawSourceMap } from 'source-map-js' +import { compile } from '..' +import type { DecodedSourceMap } from './source-map' + +async function run(rawCss: string, candidates: string[] = []) { + let source = new MagicString(rawCss) + + let bundle = new Bundle() + + bundle.addSource({ + filename: 'source.css', + content: source, + }) + + let originalMap = bundle.generateMap({ + hires: 'boundary', + file: 'source.css.map', + includeContent: true, + }) + + let compiler = await compile(source.toString(), { from: 'input.css' }) + + let css = compiler.build(candidates) + let decoded = compiler.buildSourceMap() + let rawMap = toRawSourceMap(decoded) + + let combined = remapping([rawMap, originalMap.toString()], () => null) + let map = JSON.parse(combined.toString()) as RawSourceMap + + let sources = combined.sources + let annotations = formattedMappings(map) + + return { css, map, sources, annotations } +} + +function toRawSourceMap(map: DecodedSourceMap): string { + let generator = new SourceMapGenerator() + + for (let mapping of map.mappings) { + generator.addMapping({ + generated: { line: mapping.generatedLine, column: mapping.generatedColumn }, + original: { line: mapping.originalLine, column: mapping.originalColumn }, + source: mapping.originalSource?.content ?? '', + name: mapping.name ?? undefined, + }) + } + + return generator.toString() +} + +/** + * An string annotation that represents a source map + * + * It's not meant to be exhaustive just enough to + * verify that the source map is working and that + * lines are mapped back to the original source + * + * Including when using @apply with multiple classes + */ +function formattedMappings(map: RawSourceMap) { + const smc = new SourceMapConsumer(map) + const annotations: Record< + number, + { + original: { start: [number, number]; end: [number, number] } + generated: { start: [number, number]; end: [number, number] } + } + > = {} + + smc.eachMapping((mapping) => { + let annotation = (annotations[mapping.generatedLine] = annotations[mapping.generatedLine] || { + ...mapping, + + original: { + start: [mapping.originalLine, mapping.originalColumn], + end: [mapping.originalLine, mapping.originalColumn], + }, + + generated: { + start: [mapping.generatedLine, mapping.generatedColumn], + end: [mapping.generatedLine, mapping.generatedColumn], + }, + }) + + annotation.generated.end[0] = mapping.generatedLine + annotation.generated.end[1] = mapping.generatedColumn + + annotation.original.end[0] = mapping.originalLine + annotation.original.end[1] = mapping.originalColumn + }) + + return Object.values(annotations).map((annotation) => { + return `${formatRange(annotation.generated)} <- ${formatRange(annotation.original)}` + }) +} + +function formatRange(range: { start: [number, number]; end: [number, number] }) { + if (range.start[0] === range.end[0]) { + // This range is on the same line + // and the columns are the same + if (range.start[1] === range.end[1]) { + return `${range.start[0]}:${range.start[1]}` + } + + // This range is on the same line + // but the columns are different + return `${range.start[0]}:${range.start[1]}-${range.end[1]}` + } + + // This range spans multiple lines + return `${range.start[0]}:${range.start[1]}-${range.end[0]}:${range.end[1]}` +} + +// TODO: Test full pipeline through compile(…) +// TODO: Test candidate generation +// TODO: Test utilities generated by plugins + +// IDEA: @theme needs to have source locations preserved for its nodes +// +// Example: +// ```css` +// @theme { +// --color-primary: #333; +// } +// ```` +// +// +// When outputting the CSS: +// ```css +// :root { +// --color-primary: #333; +// ^^^^^^^^^^^^^^^ +// (should poinr to the property name inside `@theme`) +// ^^^^ +// (should point to the value inside `@theme`) +// } +// +// A deletion like `--color-*: initial;` should obviously destroy this +// information since it's no longer present in the output CSS. +// +// Later declarations of the same key take precedence, so the source +// location should point to the last declaration of the key. +// +// This could be in a separate file so we need to make sure that individual +// nodes can be annotated with file metadata. + +// test('source locations are tracked during parsing and serializing', async () => { +// let ast = CSS.parse(`.foo { color: red; }`, true) +// toCss(ast, true) + +// if (ast[0].kind !== 'rule') throw new Error('Expected a rule') + +// let rule = annotate(ast[0]) +// expect(rule).toMatchInlineSnapshot(` +// { +// "node": [ +// "1:1-1:5", +// "3:1-3:1", +// ], +// } +// `) + +// let decl = annotate(ast[0].nodes[0]) +// expect(decl).toMatchInlineSnapshot(` +// { +// "node": [ +// "1:8-1:18", +// "2:3-2:13", +// ], +// } +// `) +// }) + +// test('utilities have source maps pointing to the utilities node', async () => { +// let { sources, annotations } = run(`@tailwind utilities;`, [ +// // +// 'underline', +// ]) + +// // All CSS generated by Tailwind CSS should be annotated with source maps +// // And always be able to point to the original source file +// expect(sources).toEqual(['source.css']) +// expect(sources.length).toBe(1) + +// expect(annotations).toEqual([ +// // +// '1:1-11 <- 1:1-20', +// '2:3-34 <- 1:1-20', +// ]) +// }) + +// test('@apply generates source maps', async () => { +// let { sources, annotations } = run(`.foo { +// color: blue; +// @apply text-[#000] hover:text-[#f00]; +// @apply underline; +// color: red; +// }`) + +// // All CSS generated by Tailwind CSS should be annotated with source maps +// // And always be able to point to the original source file +// expect(sources).toEqual(['source.css']) +// expect(sources.length).toBe(1) + +// expect(annotations).toEqual([ +// '1:1-5 <- 1:1-5', +// '2:3-14 <- 2:3-14', +// '3:3-14 <- 3:3-39', +// '4:3-10 <- 3:3-39', +// '5:5-16 <- 3:3-39', +// '7:3-34 <- 4:3-19', +// '8:3-13 <- 5:3-13', +// ]) +// }) + +// test('license comments preserve source locations', async () => { +// let { sources, annotations } = run(`/*! some comment */`) + +// // All CSS generated by Tailwind CSS should be annotated with source maps +// // And always be able to point to the original source file +// expect(sources).toEqual(['source.css']) +// expect(sources.length).toBe(1) + +// expect(annotations).toEqual(['1:1-19 <- 1:1-19']) +// }) + +// test('license comments with new lines preserve source locations', async () => { +// let { sources, annotations, css } = run(`/*! some \n comment */`) + +// // All CSS generated by Tailwind CSS should be annotated with source maps +// // And always be able to point to the original source file +// expect(sources).toEqual(['source.css']) +// expect(sources.length).toBe(1) + +// expect(annotations).toEqual(['1:1 <- 1:1', '2:11 <- 2:11']) +// }) diff --git a/packages/tailwindcss/src/source-maps/source-map.ts b/packages/tailwindcss/src/source-maps/source-map.ts new file mode 100644 index 000000000000..abb6825163a2 --- /dev/null +++ b/packages/tailwindcss/src/source-maps/source-map.ts @@ -0,0 +1,203 @@ +import { walk, type AstNode } from '../ast' +import { createLineTable, type Position } from './line-table' +import { type Offsets } from './offsets' + +/** + * A "decoded" sourcemap + * + * @see https://tc39.es/ecma426/#decoded-source-map + */ +export interface DecodedSourceMap { + file: string | null + sources: DecodedSource[] + mappings: DecodedMapping[] +} + +/** + * A "decoded" source + * + * @see https://tc39.es/ecma426/#decoded-source + */ +export interface DecodedSource { + url: string | null + content: string | null + ignore: boolean +} + +/** + * A "decoded" mapping + * + * @see https://tc39.es/ecma426/#decoded-mapping + */ +export interface DecodedMapping { + generatedLine: number + generatedColumn: number + + originalLine: number + originalColumn: number + + originalSource: DecodedSource | null + + name: string | null +} + +/** + * Build a source map from the given AST. + * + * Our AST is build from flat CSS strings but there are many because we handle + * `@import`. This means that different nodes can have a different source. + * + * Instead of taking an input source map, we take the input CSS string we were + * originally given, as well as the source text for any imported files, and + * use that to generate a source map. + * + * We then require the use of other tools that can translate one or more + * "input" source maps into a final output source map. For example, + * `@ampproject/remapping` can be used to handle this. + * + * This also ensures that tools that expect "local" source maps are able to + * consume the source map we generate. + * + * The source map type we generate is a bit different from "raw" source maps + * that the `source-map-js` package uses. It's a "decoded" source map that is + * represented by an object graph. It's identical to "decoded" source map from + * the ECMA-426 spec for source maps. + * + * This can easily be converted to a "raw" source map by any tool that needs to. + **/ +export function createSourceMap({ + // TODO: This needs to be a Record<string, string> to support multiple sources + // for `@import` nodes. + original, + generated, + ast, +}: { + original: string + generated: string + ast: AstNode[] +}) { + // Compute line tables for both the original and generated source lazily so we + // don't have to do it during parsing or printing. + let originalTable = createLineTable(original) + let generatedTable = createLineTable(generated) + + // Convert each mapping to a set of positions + let map: DecodedSourceMap = { + file: null, + sources: [{ url: null, content: null, ignore: false }], + mappings: [], + } + + // Get all the indexes from the mappings + let groups: (Offsets | undefined)[] = [] + + walk(ast, (node) => { + if (node.kind === 'declaration') { + groups.push(node.offsets.property) + groups.push(node.offsets.value) + } else if (node.kind === 'rule') { + groups.push(node.offsets.selector) + groups.push(node.offsets.body) + } else if (node.kind === 'at-rule') { + groups.push(node.offsets.name) + groups.push(node.offsets.params) + groups.push(node.offsets.body) + } else if (node.kind === 'comment') { + groups.push(node.offsets.value) + } else if (node.kind === 'at-root') { + groups.push(node.offsets.body) + } + }) + + for (let group of groups) { + if (!group) continue + if (!group.dst) continue + + let originalStart = originalTable.find(group.src[0]) + let generatedStart = generatedTable.find(group.dst[0]) + + map.mappings.push({ + name: null, + originalSource: null, + + originalLine: originalStart.line, + originalColumn: originalStart.column, + + generatedLine: generatedStart.line, + generatedColumn: generatedStart.column, + }) + + let originalEnd = originalTable.find(group.src[1]) + let generatedEnd = generatedTable.find(group.dst[1]) + + map.mappings.push({ + name: null, + originalSource: null, + + originalLine: originalEnd.line, + originalColumn: originalEnd.column, + + generatedLine: generatedEnd.line, + generatedColumn: generatedEnd.column, + }) + } + + // Sort the mappings by their new position + map.mappings.sort((a, b) => { + if (a.generatedLine === b.generatedLine) { + return a.generatedColumn - b.generatedColumn + } + + return a.generatedLine - b.generatedLine + }) + + // Remove duplicate mappings + // TODO: can we do this earlier? + let last: DecodedMapping | null = null + + map.mappings = map.mappings.filter((mapping) => { + if ( + last && + last.generatedLine === mapping.generatedLine && + last.generatedColumn === mapping.generatedColumn + ) { + return false + } + + last = mapping + return + }) + + return map +} + +export function createTranslationMap({ + original, + generated, +}: { + original: string + generated: string +}) { + // Compute line tables for both the original and generated source lazily so we + // don't have to do it during parsing or printing. + let originalTable = createLineTable(original) + let generatedTable = createLineTable(generated) + + type Translation = [Position, Position, Position | null, Position | null] + + return (node: AstNode) => { + let translations: Record<string, Translation> = {} + + for (let [name, offsets] of Object.entries(node.offsets)) { + translations[name] = [ + originalTable.find(offsets.src[0]), + originalTable.find(offsets.src[1]), + + offsets.dst ? generatedTable.find(offsets.dst[0]) : null, + offsets.dst ? generatedTable.find(offsets.dst[1]) : null, + ] + } + + return translations + } +} diff --git a/packages/tailwindcss/src/source-maps/translation-map.test.ts b/packages/tailwindcss/src/source-maps/translation-map.test.ts new file mode 100644 index 000000000000..c19886d57afd --- /dev/null +++ b/packages/tailwindcss/src/source-maps/translation-map.test.ts @@ -0,0 +1,250 @@ +import { assert, expect, test } from 'vitest' +import { toCss, type AstNode } from '../ast' +import * as CSS from '../css-parser' +import { createTranslationMap } from './translation-map' + +async function analyze(input: string) { + let ast = CSS.parse(input, { from: 'input.css' }) + let css = toCss(ast, true) + let translate = createTranslationMap({ + original: input, + generated: css, + }) + + function format(node: AstNode) { + let result: Record<string, string> = {} + + for (let [kind, [oStart, oEnd, gStart, gEnd]] of Object.entries(translate(node))) { + let src = `${oStart.line}:${oStart.column}-${oEnd.line}:${oEnd.column}` + + let dst = '(none)' + + if (gStart && gEnd) { + dst = `${gStart.line}:${gStart.column}-${gEnd.line}:${gEnd.column}` + } + + result[kind] = `${dst} <- ${src}` + } + + return result + } + + return { ast, css, format } +} + +// Parse CSS and make sure source locations are tracked correctly +test('comment, single line', async () => { + // Works, no changes needed + let { ast, format } = await analyze(`/*! foo */`) + + assert(ast[0].kind === 'comment') + expect(format(ast[0])).toMatchInlineSnapshot(` + { + "value": "1:1-1:11 <- 1:1-1:11", + } + `) +}) + +test('comment, multi line', async () => { + // Works, no changes needed + let { ast, format } = await analyze(`/*! foo \n bar */`) + + assert(ast[0].kind === 'comment') + expect(format(ast[0])).toMatchInlineSnapshot(` + { + "value": "1:1-2:8 <- 1:1-2:8", + } + `) +}) + +test('declaration, normal property, single line', async () => { + let { ast, format } = await analyze(`.foo { color: red; }`) + + assert(ast[0].kind === 'rule') + assert(ast[0].nodes[0].kind === 'declaration') + expect(format(ast[0].nodes[0])).toMatchInlineSnapshot(` + { + "property": "2:3-2:8 <- 1:1-1:1", + "value": "2:10-2:13 <- 1:1-1:18", + } + `) +}) + +test('declaration, normal property, multi line', async () => { + let { ast, css, format } = await analyze(` + .foo { + grid-template-areas: + "a b c" + "d e f" + "g h i"; + } + `) + + assert(ast[0].kind === 'rule') + assert(ast[0].nodes[0].kind === 'declaration') + expect(format(ast[0].nodes[0])).toMatchInlineSnapshot(` + { + "property": "2:3-2:22 <- 1:1-1:1", + "value": "2:24-2:47 <- 1:1-6:18", + } + `) + + expect(css).toMatchInlineSnapshot(` + ".foo { + grid-template-areas: "a b c" "d e f" "g h i"; + } + " + `) +}) + +test('declaration, custom property, single line', async () => { + let { ast, css, format } = await analyze(`.foo { --foo: bar; }`) + + assert(ast[0].kind === 'rule') + assert(ast[0].nodes[0].kind === 'declaration') + expect(format(ast[0].nodes[0])).toMatchInlineSnapshot(` + { + "property": "2:3-2:8 <- 1:8-1:6", + "value": "2:10-2:13 <- 1:8-1:18", + } + `) + expect(css).toMatchInlineSnapshot(` + ".foo { + --foo: bar; + } + " + `) +}) + +test('declaration, custom property, multi line', async () => { + let { ast, format } = await analyze(` + .foo { + --foo: bar\nbaz; + } + `) + + assert(ast[0].kind === 'rule') + assert(ast[0].nodes[0].kind === 'declaration') + expect(format(ast[0].nodes[0])).toMatchInlineSnapshot(` + { + "property": "2:3-2:8 <- 3:7-2:5", + "value": "2:10-3:4 <- 3:7-4:4", + } + `) +}) + +test('at rules, bodyless, single line', async () => { + let { ast, format } = await analyze(`@layer foo, bar;`) + + assert(ast[0].kind === 'at-rule') + expect(format(ast[0])).toMatchInlineSnapshot(` + { + "name": "1:1-1:7 <- 1:1-1:1", + "params": "1:8-1:16 <- 1:1-1:1", + } + `) +}) + +test('at rules, bodyless, multi line', async () => { + let { ast, format } = await analyze(` + @layer + foo, + bar + ; + `) + + assert(ast[0].kind === 'at-rule') + expect(format(ast[0])).toMatchInlineSnapshot(` + { + "name": "1:1-1:7 <- 1:1-1:1", + "params": "1:8-1:16 <- 1:1-1:1", + } + `) +}) + +test('at rules, body, single line', async () => { + let { ast, css, format } = await analyze(`@layer foo { color: red; }`) + + assert(ast[0].kind === 'at-rule') + expect(format(ast[0])).toMatchInlineSnapshot(` + { + "body": "1:12-3:2 <- 1:12-1:26", + "name": "1:1-1:7 <- 1:1-1:1", + "params": "1:8-1:11 <- 1:1-1:1", + } + `) + expect(css).toMatchInlineSnapshot(` + "@layer foo { + color: red; + } + " + `) +}) + +test('at rules, body, multi line {d', async () => { + let { ast, css, format } = await analyze(` + @layer + foo + { + color: baz; + } + `) + + assert(ast[0].kind === 'at-rule') + expect(format(ast[0])).toMatchInlineSnapshot(` + { + "body": "1:12-3:2 <- 4:5-6:5", + "name": "1:1-1:7 <- 1:1-1:1", + "params": "1:8-1:11 <- 1:1-1:1", + } + `) + expect(css).toMatchInlineSnapshot(` + "@layer foo { + color: baz; + } + " + `) +}) + +test('style rules, body, single line', async () => { + let { ast, css, format } = await analyze(`.foo:is(.bar) { color: red; }`) + + assert(ast[0].kind === 'rule') + expect(format(ast[0])).toMatchInlineSnapshot(` + { + "body": "1:15-3:2 <- 1:15-1:29", + "selector": "1:1-1:14 <- 1:1-1:1", + } + `) + expect(css).toMatchInlineSnapshot(` + ".foo:is(.bar) { + color: red; + } + " + `) +}) + +test('style rules, body, multi line', async () => { + let { ast, css, format } = await analyze(` + .foo:is( + .bar + ) { + color: red; + } + `) + + assert(ast[0].kind === 'rule') + expect(format(ast[0])).toMatchInlineSnapshot(` + { + "body": "1:17-3:2 <- 4:7-6:5", + "selector": "1:1-1:16 <- 1:1-1:1", + } + `) + + expect(css).toMatchInlineSnapshot(` + ".foo:is( .bar ) { + color: red; + } + " + `) +}) diff --git a/packages/tailwindcss/src/source-maps/translation-map.ts b/packages/tailwindcss/src/source-maps/translation-map.ts new file mode 100644 index 000000000000..02783ea533e2 --- /dev/null +++ b/packages/tailwindcss/src/source-maps/translation-map.ts @@ -0,0 +1,38 @@ +import type { AstNode } from '../ast' +import { type Position, createLineTable } from './line-table' + +export type Translation = [Position, Position, Position | null, Position | null] + +/** + * The translation map is a special structure that lets us analyze individual + * nodes in the AST and determine how various pieces of them map back to the + * original source. + * + * It's used for testing and is not directly used by the source map generation. + */ +export function createTranslationMap({ + original, + generated, +}: { + original: string + generated: string +}) { + let originalTable = createLineTable(original) + let generatedTable = createLineTable(generated) + + return (node: AstNode) => { + let translations: Record<string, Translation> = {} + + for (let [name, offsets] of Object.entries(node.offsets)) { + translations[name] = [ + originalTable.find(offsets.src[0]), + originalTable.find(offsets.src[1]), + + offsets.dst ? generatedTable.find(offsets.dst[0]) : null, + offsets.dst ? generatedTable.find(offsets.dst[1]) : null, + ] + } + + return translations + } +} diff --git a/packages/tailwindcss/src/source-maps/types.ts b/packages/tailwindcss/src/source-maps/types.ts new file mode 100644 index 000000000000..69e92e88861c --- /dev/null +++ b/packages/tailwindcss/src/source-maps/types.ts @@ -0,0 +1,23 @@ +export interface DecodedSourceMap { + file: string | null + sources: DecodedSource[] + mappings: DecodedMapping[] +} + +export interface DecodedSource { + url: string | null + content: string | null + ignore: boolean +} + +export interface DecodedMapping { + generatedLine: number + generatedColumn: number + + originalLine: number | null + originalColumn: number | null + + originalSource: DecodedSource | null + + name: string | null +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bccbc2e3bb7..4a2edbfd47be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,6 +401,9 @@ importers: packages/tailwindcss: devDependencies: + '@ampproject/remapping': + specifier: ^2.3.0 + version: 2.3.0 '@tailwindcss/oxide': specifier: workspace:^ version: link:../../crates/node @@ -413,6 +416,12 @@ importers: lightningcss: specifier: 'catalog:' version: 1.29.2(patch_hash=tzyxy3asfxcqc7ihrooumyi5fm) + magic-string: + specifier: ^0.30.17 + version: 0.30.17 + source-map-js: + specifier: ^1.2.1 + version: 1.2.1 playgrounds/nextjs: dependencies: @@ -3014,8 +3023,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - magic-string@0.30.11: - resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -5109,7 +5118,7 @@ snapshots: '@vitest/snapshot@2.0.5': dependencies: '@vitest/pretty-format': 2.0.5 - magic-string: 0.30.11 + magic-string: 0.30.17 pathe: 1.1.2 '@vitest/spy@2.0.5': @@ -5760,7 +5769,7 @@ snapshots: debug: 4.4.0 enhanced-resolve: 5.18.1 eslint: 9.22.0(jiti@2.4.2) - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.22.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)) fast-glob: 3.3.3 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -5779,7 +5788,7 @@ snapshots: debug: 4.4.0 enhanced-resolve: 5.18.1 eslint: 9.22.0(jiti@2.4.2) - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.22.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)) fast-glob: 3.3.3 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -5792,7 +5801,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.22.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: @@ -5803,7 +5812,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.22.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: @@ -5825,7 +5834,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.22.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.22.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -5854,7 +5863,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.22.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.22.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.11.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -6489,7 +6498,7 @@ snapshots: dependencies: yallist: 3.1.1 - magic-string@0.30.11: + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -7439,7 +7448,7 @@ snapshots: chai: 5.1.1 debug: 4.3.6 execa: 8.0.1 - magic-string: 0.30.11 + magic-string: 0.30.17 pathe: 1.1.2 std-env: 3.7.0 tinybench: 2.9.0 From d4a3a4b6554887d6b478c7f080aa309ff5339afb Mon Sep 17 00:00:00 2001 From: Jordan Pittman <jordan@cryptica.me> Date: Thu, 3 Apr 2025 13:33:19 -0400 Subject: [PATCH 7/8] Add source map support to main API --- packages/tailwindcss/src/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 8d953782f621..7a289fba03ed 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -26,6 +26,7 @@ import { applyVariant, compileCandidates } from './compile' import { substituteFunctions } from './css-functions' import * as CSS from './css-parser' import { buildDesignSystem, type DesignSystem } from './design-system' +import type { DecodedSourceMap } from './source-maps/source-map' import { Theme, ThemeOptions } from './theme' import { createCssUtility } from './utilities' import { expand } from './utils/brace-expansion' @@ -51,6 +52,7 @@ export const enum Polyfills { type CompileOptions = { base?: string + from?: string polyfills?: Polyfills loadModule?: ( id: string, @@ -125,6 +127,7 @@ async function parseCss( ast: AstNode[], { base = '', + from, loadModule = throwOnLoadModule, loadStylesheet = throwOnLoadStylesheet, }: CompileOptions = {}, @@ -132,7 +135,7 @@ async function parseCss( let features = Features.None ast = [contextNode({ base }, ast)] as AstNode[] - features |= await substituteAtImports(ast, base, loadStylesheet) + features |= await substituteAtImports(ast, base, loadStylesheet, 0, from !== undefined) let important = null as boolean | null let theme = new Theme() @@ -766,8 +769,9 @@ export async function compile( root: Root features: Features build(candidates: string[]): string + buildSourceMap(): DecodedSourceMap }> { - let ast = CSS.parse(css) + let ast = CSS.parse(css, { from: opts.from }) let api = await compileAst(ast, opts) let compiledAst = ast let compiledCss = css @@ -786,6 +790,10 @@ export async function compile( return compiledCss }, + + buildSourceMap() { + // + }, } } From 5006ff0330191d8a785d4e7b4c3d5742022e6e8a Mon Sep 17 00:00:00 2001 From: Jordan Pittman <jordan@cryptica.me> Date: Thu, 3 Apr 2025 13:29:46 -0400 Subject: [PATCH 8/8] wip: translate sourcemaps in PostCSS plugin --- packages/@tailwindcss-postcss/src/ast.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/@tailwindcss-postcss/src/ast.ts b/packages/@tailwindcss-postcss/src/ast.ts index 201dd4bf8ac9..3e40ca9049f3 100644 --- a/packages/@tailwindcss-postcss/src/ast.ts +++ b/packages/@tailwindcss-postcss/src/ast.ts @@ -5,10 +5,15 @@ import postcss, { type Source as PostcssSource, } from 'postcss' import { atRule, comment, decl, rule, type AstNode } from '../../tailwindcss/src/ast' +import { createLineTable, type LineTable } from '../../tailwindcss/src/source-maps/line-table' +import { DefaultMap } from '../../tailwindcss/src/utils/default-map' const EXCLAMATION_MARK = 0x21 export function cssAstToPostCssAst(ast: AstNode[], source: PostcssSource | undefined): PostCssRoot { + // Print the AST + let lineTables = new DefaultMap<string, LineTable>((source) => createLineTable(source)) + let root = postcss.root() root.source = source