From 366ee81d198dfd0053db58da236a0ce41c890c8d Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 2 Feb 2026 14:29:37 +0300 Subject: [PATCH] scss support --- packages/@tailwindcss-postcss/package.json | 1 + packages/@tailwindcss-postcss/src/ast.ts | 333 ++++++++++++++++++++- packages/@tailwindcss-postcss/src/index.ts | 106 ++++++- packages/@tailwindcss-vite/src/index.ts | 51 +++- pnpm-lock.yaml | 3 + 5 files changed, 484 insertions(+), 10 deletions(-) diff --git a/packages/@tailwindcss-postcss/package.json b/packages/@tailwindcss-postcss/package.json index 22e775d728ae..e86999cdd2d2 100644 --- a/packages/@tailwindcss-postcss/package.json +++ b/packages/@tailwindcss-postcss/package.json @@ -34,6 +34,7 @@ "@tailwindcss/node": "workspace:*", "@tailwindcss/oxide": "workspace:*", "postcss": "^8.4.41", + "source-map-js": "^1.2.1", "tailwindcss": "workspace:*" }, "devDependencies": { diff --git a/packages/@tailwindcss-postcss/src/ast.ts b/packages/@tailwindcss-postcss/src/ast.ts index 7ca9e2addedf..31feb123b309 100644 --- a/packages/@tailwindcss-postcss/src/ast.ts +++ b/packages/@tailwindcss-postcss/src/ast.ts @@ -1,10 +1,14 @@ +import path from 'node:path' import type * as postcss from 'postcss' +import { SourceMapConsumer, type RawSourceMap } from 'source-map-js' import { atRule, comment, decl, rule, type AstNode } from '../../tailwindcss/src/ast' import { createLineTable, type LineTable } from '../../tailwindcss/src/source-maps/line-table' import type { Source, SourceLocation } from '../../tailwindcss/src/source-maps/source' import { DefaultMap } from '../../tailwindcss/src/utils/default-map' const EXCLAMATION_MARK = 0x21 +const DEBUG = + process.env.DEBUG?.includes('@tailwindcss/postcss') || process.env.DEBUG?.includes('tailwindcss') export function cssAstToPostCssAst( postcss: postcss.Postcss, @@ -12,8 +16,29 @@ export function cssAstToPostCssAst( source?: postcss.Source, ): postcss.Root { let inputMap = new DefaultMap((src) => { + let map: postcss.Result['map'] | undefined + + if (source?.input.map && typeof source.input.map.toJSON === 'function') { + let sources = source.input.map.toJSON().sources ?? [] + let file = src.file ?? undefined + + let shouldUseMap = false + if (!file) { + shouldUseMap = true + } else if (sources.includes(file)) { + shouldUseMap = true + } else { + let fileName = path.basename(file) + shouldUseMap = sources.some((sourceFile) => path.basename(sourceFile) === fileName) + } + + if (shouldUseMap) { + map = source.input.map + } + } + return new postcss.Input(src.code, { - map: source?.input.map, + map, from: src.file ?? undefined, }) }) @@ -124,10 +149,211 @@ export function cssAstToPostCssAst( } export function postCssAstToCssAst(root: postcss.Root): AstNode[] { - let inputMap = new DefaultMap((input) => ({ - file: input.file ?? input.id ?? null, - code: input.css, - })) + function getRawMap(input: postcss.Input): RawSourceMap | null { + let map = input.map + if (!map) return null + + if (typeof map.toJSON === 'function') { + return map.toJSON() as RawSourceMap + } + + if ('sources' in map) { + return map as RawSourceMap + } + + if (typeof map === 'object' && map !== null && 'text' in map) { + let text = (map as { text?: unknown }).text + if (typeof text === 'string') { + try { + return JSON.parse(text) as RawSourceMap + } catch { + // Ignore invalid JSON + } + } else if (text && typeof text === 'object' && 'sources' in (text as object)) { + return text as RawSourceMap + } + } + + DEBUG && + console.warn('[tw-postcss:sourcemap] unrecognized input.map', { + inputFile: input.file ?? input.id ?? null, + mapType: typeof map, + mapKeys: typeof map === 'object' && map !== null ? Object.keys(map as object) : null, + }) + + return null + } + + let rawMapCache = new DefaultMap((input) => getRawMap(input)) + + let consumerCache = new DefaultMap((input) => { + let rawMap = rawMapCache.get(input) + if (!rawMap) return null + return new SourceMapConsumer(rawMap) + }) + + function normalizeSourceName(source: string) { + let cleaned = source + let queryIndex = cleaned.indexOf('?') + if (queryIndex !== -1) cleaned = cleaned.slice(0, queryIndex) + let hashIndex = cleaned.indexOf('#') + if (hashIndex !== -1) cleaned = cleaned.slice(0, hashIndex) + + if (cleaned.startsWith('file://')) { + try { + return decodeURIComponent(new URL(cleaned).pathname) + } catch { + return cleaned.slice('file://'.length) + } + } + if (/^[a-z]+:\/\//i.test(cleaned)) { + cleaned = cleaned.replace(/^[a-z]+:\/\//i, '') + cleaned = cleaned.replace(/^\/+/, '') + } + + cleaned = cleaned.replace(/\/\.(?=\/)/g, '') + + if (cleaned.startsWith('./')) cleaned = cleaned.slice(2) + + return cleaned + } + + let sourcesContentCache = new DefaultMap>((input) => { + let map = new Map() + + let consumer = consumerCache.get(input) + if (consumer) { + for (let source of consumer.sources) { + let content: string | null + try { + content = consumer.sourceContentFor(source, true) ?? null + } catch { + content = null + } + map.set(source, content) + } + return map + } + + let rawMap = rawMapCache.get(input) + let sources = rawMap?.sources ?? [] + let contents = rawMap?.sourcesContent ?? [] + + for (let i = 0; i < sources.length; i++) { + map.set(sources[i], contents[i] ?? null) + } + + return map + }) + + let normalizedSourcesCache = new DefaultMap>((input) => { + let map = new Map() + + let consumer = consumerCache.get(input) + let sources = consumer?.sources ?? rawMapCache.get(input)?.sources ?? [] + + for (let source of sources) { + let normalized = normalizeSourceName(source) + map.set(source, source) + map.set(normalized, source) + map.set(path.basename(source), source) + map.set(path.basename(normalized), source) + } + + return map + }) + + function resolveSourceContent(input: postcss.Input, file: string) { + let rawMap = rawMapCache.get(input) + if (!rawMap) { + DEBUG && + console.warn('[tw-postcss:sourcemap] missing raw map', { + file, + inputFile: input.file ?? input.id ?? null, + }) + return { + sourceName: file, + content: null as string | null, + } + } + + let normalizedSources = normalizedSourcesCache.get(input) + let matchedSource = normalizedSources.get(file) + if (!matchedSource) { + let normalized = normalizeSourceName(file) + matchedSource = + normalizedSources.get(normalized) ?? + normalizedSources.get(path.basename(normalized)) ?? + normalizedSources.get(path.basename(file)) + + if (!matchedSource) { + for (let [key, value] of normalizedSources) { + if (key.endsWith(normalized) || normalized.endsWith(key)) { + matchedSource = value + break + } + } + } + } + + let sourceName = matchedSource ?? file + let content = sourcesContentCache.get(input).get(sourceName) ?? null + + if (input.file) { + let inputBase = path.basename(input.file) + let sourceBase = path.basename(sourceName) + + if (inputBase === sourceBase) { + let normalizedSource = normalizeSourceName(sourceName) + if (normalizedSource === sourceBase) { + sourceName = input.file + } + } + } + + if (DEBUG) { + let normalized = normalizeSourceName(file) + let sources = consumerCache.get(input)?.sources ?? rawMap.sources + console.warn('[tw-postcss:sourcemap] resolve', { + file, + normalized, + matchedSource: matchedSource ?? null, + sourceName, + hasContent: content !== null, + sourcesCount: sources.length, + }) + } + + return { + sourceName, + content, + } + } + + let sourceObjectCache = new DefaultMap>( + (input) => + new DefaultMap((file) => { + let { sourceName, content } = resolveSourceContent(input, file) + return { + file: sourceName, + code: content ?? input.css, + } + }), + ) + + let inputMap = new DefaultMap((input) => { + let file = input.file ?? input.id ?? null + + let rawMap = rawMapCache.get(input) + if (rawMap && rawMap.sources.length > 0) { + file = rawMap.sources[0] + } + + return { + file, + code: input.css, + } + }) function toSource(node: postcss.ChildNode): SourceLocation | undefined { let source = node.source @@ -138,6 +364,103 @@ export function postCssAstToCssAst(root: postcss.Root): AstNode[] { if (source.start === undefined) return if (source.end === undefined) return + let consumer = consumerCache.get(input) + + if (DEBUG && !consumer) { + let rawMap = rawMapCache.get(input) + console.warn('[tw-postcss:sourcemap] no consumer', { + inputFile: input.file ?? input.id ?? null, + hasRawMap: rawMap !== null, + sourcesCount: rawMap?.sources?.length ?? 0, + sourcesContentCount: rawMap?.sourcesContent?.length ?? 0, + sourceRoot: rawMap?.sourceRoot ?? null, + }) + } + + if (consumer) { + let start = consumer.originalPositionFor( + { + line: source.start.line, + column: Math.max(source.start.column - 1, 0), + }, + SourceMapConsumer.LEAST_UPPER_BOUND, + ) + let end = consumer.originalPositionFor( + { + line: source.end.line, + column: Math.max(source.end.column - 1, 0), + }, + SourceMapConsumer.GREATEST_LOWER_BOUND, + ) + + if (!end.source) { + let endUpper = consumer.originalPositionFor( + { + line: source.end.line, + column: Math.max(source.end.column - 1, 0), + }, + SourceMapConsumer.LEAST_UPPER_BOUND, + ) + + if (endUpper.source) { + end = endUpper + } + } + + if (!start.source) { + let startLower = consumer.originalPositionFor( + { + line: source.start.line, + column: Math.max(source.start.column - 1, 0), + }, + SourceMapConsumer.GREATEST_LOWER_BOUND, + ) + + if (startLower.source) { + start = startLower + } + } + + if (start.source && !end.source) { + end = start + } else if (!start.source && end.source) { + start = end + } + + if (DEBUG && (!start.source || !end.source)) { + let rawMap = rawMapCache.get(input) + console.warn('[tw-postcss:sourcemap] missing original source', { + inputFile: input.file ?? input.id ?? null, + start, + end, + sourcesCount: rawMap?.sources?.length ?? 0, + sourcesContentCount: rawMap?.sourcesContent?.length ?? 0, + sourceRoot: rawMap?.sourceRoot ?? null, + }) + } + + if (start.source && end.source) { + let file = start.source + let sourceObject = sourceObjectCache.get(input).get(file) + let table = createLineTable(sourceObject.code) + + let startOffset = table.findOffset({ + line: start.line ?? 1, + column: start.column ?? 0, + }) + let endOffset = table.findOffset({ + line: end.line ?? 1, + column: end.column ?? 0, + }) + + if (endOffset < startOffset) { + endOffset = startOffset + } + + return [sourceObject, startOffset, endOffset] + } + } + return [inputMap.get(input), source.start.offset, source.end.offset] } diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts index 2ff4405b66b6..392fc0095046 100644 --- a/packages/@tailwindcss-postcss/src/index.ts +++ b/packages/@tailwindcss-postcss/src/index.ts @@ -11,12 +11,14 @@ import { clearRequireCache } from '@tailwindcss/node/require-cache' import { Scanner } from '@tailwindcss/oxide' import fs from 'node:fs' import path, { relative } from 'node:path' -import type { AcceptedPlugin, PluginCreator, Postcss, Root } from 'postcss' +import type { AcceptedPlugin, AtRule, ChildNode, PluginCreator, Postcss, Root, Rule } from 'postcss' import { toCss, type AstNode } from '../../tailwindcss/src/ast' import { cssAstToPostCssAst, postCssAstToCssAst } from './ast' import fixRelativePathsPlugin from './postcss-fix-relative-paths' const DEBUG = env.DEBUG +const DEBUG_MAP = + process.env.DEBUG?.includes('@tailwindcss/postcss') || process.env.DEBUG?.includes('tailwindcss') interface CacheEntry { mtimes: Map @@ -73,13 +75,65 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { let optimize = opts.optimize ?? process.env.NODE_ENV === 'production' let shouldRewriteUrls = opts.transformAssetUrls ?? true + function normalizeVariantAtRules(root: Root) { + function isRule(node: ChildNode): node is Rule { + return node.type === 'rule' + } + + function isAtRule(node: ChildNode): node is AtRule { + return node.type === 'atrule' + } + + root.walkAtRules('variant', (atRule) => { + if (!atRule.nodes || atRule.nodes.length === 0) return + + let ruleNodes = atRule.nodes.filter(isRule) + if (ruleNodes.length === 0) return + + let nestedVariantNodes = atRule.nodes.filter( + (node): node is AtRule => isAtRule(node) && node.name === 'variant', + ) + + let convertedRules = ruleNodes.map((rule) => { + let nextAtRule = atRule.clone({ nodes: [] }) as AtRule + + for (let child of rule.nodes ?? []) { + nextAtRule.append(child.clone()) + } + + for (let nested of nestedVariantNodes) { + if (!nested.nodes) continue + + let nestedRule = nested.nodes.find( + (node): node is Rule => isRule(node) && node.selector === rule.selector, + ) + + if (!nestedRule) continue + + let nestedClone = nested.clone({ nodes: [] }) as AtRule + for (let child of nestedRule.nodes ?? []) { + nestedClone.append(child.clone()) + } + nextAtRule.append(nestedClone) + } + + rule.removeAll() + rule.append(nextAtRule) + return rule + }) + + atRule.replaceWith(...convertedRules) + return false + }) + } + return { postcssPlugin: '@tailwindcss/postcss', plugins: [ // We need to handle the case where `postcss-import` might have run before // the Tailwind CSS plugin is run. In this case, we need to manually fix // relative paths before processing it in core. - fixRelativePathsPlugin(), + // fixRelativePathsPlugin(), { postcssPlugin: 'tailwindcss', @@ -87,9 +141,46 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { using I = new Instrumentation() let inputFile = result.opts.from ?? '' - let isCSSModuleFile = inputFile.endsWith('.module.css') + if (DEBUG_MAP) { + let map = result.opts.map as undefined | { prev?: unknown } | true + console.warn('[tw-postcss:sourcemap] input', { + from: inputFile, + hasInputMap: Boolean(root.source?.input.map), + hasMapPrev: Boolean(map && typeof map === 'object' && 'prev' in map && map.prev), + mapType: map === true ? 'true' : typeof map, + }) + } + + // Rspack can pass previous maps via result.opts.map.prev without + // populating root.source.input.map. Hydrate it so our mapper can + // access the original sources. + if (root.source?.input && !root.source.input.map) { + let map = result.opts.map as undefined | { prev?: unknown } | true + let prev = map && typeof map === 'object' && 'prev' in map ? map.prev : undefined + + if (prev) { + try { + let newInput = new postcss.Input(root.source.input.css, { + from: root.source.input.file ?? undefined, + map: { prev }, + }) + root.source.input = newInput + } catch (error) { + DEBUG_MAP && + console.warn('[tw-postcss:sourcemap] failed to hydrate input map', { + from: inputFile, + error, + }) + } + } + } + let isCSSModuleFile = + inputFile.endsWith('.module.css') || + inputFile.endsWith('.module.scss') || + inputFile.endsWith('.module.sass') DEBUG && I.start(`[@tailwindcss/postcss] ${relative(base, inputFile)}`) + let hasTailwindDirective = false // Bail out early if this is guaranteed to be a non-Tailwind CSS file. { @@ -101,12 +192,15 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { node.name === 'reference' || node.name === 'theme' || node.name === 'variant' || + node.name === 'custom-variant' || + node.name === 'utility' || node.name === 'config' || node.name === 'plugin' || node.name === 'apply' || node.name === 'tailwind' ) { canBail = false + hasTailwindDirective = true return false } }) @@ -114,6 +208,10 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { DEBUG && I.end('Quick bail check') } + // Sass can bubble nested @variant rules to the top-level. Normalize + // them back into nested form so variants apply to local selectors. + normalizeVariantAtRules(root) + let context = getContextFromCache(postcss, inputFile, opts) let inputBasePath = path.dirname(path.resolve(inputFile)) @@ -155,7 +253,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { // guarantee a `build()` function is available. context.compiler ??= createCompiler() - if ((await context.compiler).features === Features.None) { + if ((await context.compiler).features === Features.None && !hasTailwindDirective) { return } diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 341c662b164e..bd19002bc107 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -35,7 +35,7 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { let shouldOptimize = true let minify = true - function createRoot(env: Environment | null, id: string) { + function createResolvers(env: Environment | null) { type ResolveFn = (id: string, base: string) => Promise let customCssResolver: ResolveFn @@ -74,6 +74,12 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { customJsResolver = (id: string, base: string) => jsResolver(env, id, base, true) } + return { customCssResolver, customJsResolver } + } + + function createRoot(env: Environment | null, id: string) { + let { customCssResolver, customJsResolver } = createResolvers(env) + return new Root( id, config!.root, @@ -116,6 +122,44 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { }, }, + { + // Step 1.5: Resolve @apply in non-root stylesheets (e.g. CSS modules) + name: '@tailwindcss/vite:apply', + enforce: 'pre', + transform: { + filter: { + id: { + exclude: [/\/\.vite\//, SPECIAL_QUERY_RE, COMMON_JS_PROXY_RE], + include: [/\.(?:css|scss|sass)(?:\?.*)?$/], + }, + }, + async handler(src, id) { + if (!config) return + if (isPotentialCssRootFile(id)) return + if (hasTailwindRootDirective(src)) return + + let inputBase = path.dirname(path.resolve(idToPath(id))) + let { customCssResolver, customJsResolver } = createResolvers(this.environment ?? null) + + let compiler = await compile(src, { + from: config.css.devSourcemap ? id : undefined, + base: inputBase, + shouldRewriteUrls: true, + onDependency: (file) => this.addWatchFile(file), + customCssResolver, + customJsResolver, + }) + + if (!(compiler.features & Features.AtApply)) return + + return { + code: compiler.build([]), + map: config.css.devSourcemap ? toSourceMap(compiler.buildSourceMap()).raw : null, + } + }, + }, + }, + { // Step 2 (serve mode): Generate CSS name: '@tailwindcss/vite:generate:serve', @@ -219,6 +263,11 @@ function isPotentialCssRootFile(id: string) { return isCssFile } +function hasTailwindRootDirective(src: string) { + return /@tailwind\b/.test(src) || /@import\s+['"]tailwindcss(?:\/[^'"]+)?['"]/.test(src) +} + + function idToPath(id: string) { return path.resolve(id.replace(/\?.*$/, '')) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d32bc3c6495f..c87cf44b6c1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,9 @@ importers: postcss: specifier: ^8.4.41 version: 8.4.41 + source-map-js: + specifier: ^1.2.1 + version: 1.2.1 tailwindcss: specifier: workspace:* version: link:../tailwindcss