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