Skip to content

Commit 99cd433

Browse files
committed
fix: support tailwind directives in preprocessors
1 parent f6cb5d5 commit 99cd433

11 files changed

Lines changed: 259 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"weapp-tailwindcss": patch
3+
---
4+
5+
增强 Sass/Less 等预处理器样式入口的 Tailwind 指令识别与改写能力,避免将预处理器私有语法直接交给 Tailwind 解析。

packages/weapp-tailwindcss/src/bundlers/shared/generator-css/directives.ts

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const TAILWIND_ROOT_DIRECTIVE_NAMES = new Set([
3030
'variant',
3131
])
3232
const TAILWIND_ROOT_DIRECTIVE_RE = /@(?:import\s+(?:url\(\s*)?["']?tailwindcss4?(?:\/[^"')\s]*)?|tailwind|config|custom-variant|plugin|source|theme|utility|variant)\b/
33+
const TAILWIND_EXTRACTABLE_DIRECTIVE_RE = /^\s*@(?:import|tailwind|config|source|reference|plugin)\b[\s\S]*?(?:;|$)/
34+
const TAILWIND_EXTRACTABLE_BLOCK_DIRECTIVE_RE = /^\s*@(?:theme|utility|variant|custom-variant)\b[\s\S]*$/
3335

3436
interface TailwindDirectiveOptions {
3537
importFallback?: boolean | undefined
@@ -103,6 +105,82 @@ function normalizeTailwindImportAtRules(root: postcss.Root, options: TailwindDir
103105
return changed
104106
}
105107

108+
function normalizeTailwindDirectiveLine(line: string, options: TailwindDirectiveOptions = {}) {
109+
if (!options.importFallback || !line.trimStart().startsWith('@import')) {
110+
return line
111+
}
112+
const request = parseImportRequest(line.trimStart().replace(/^@import\b/, ''))
113+
if (!isWeappTailwindcssImportRequest(request)) {
114+
return line
115+
}
116+
return replaceImportRequest(line, request, request.replace(/^weapp-tailwindcss/, 'tailwindcss'))
117+
}
118+
119+
function extractTailwindDirectiveLines(
120+
rawSource: string,
121+
options: TailwindDirectiveOptions & { removeConfig?: boolean } = {},
122+
) {
123+
const directives: string[] = []
124+
const seenImports = new Set<string>()
125+
for (const line of stripGeneratorPlaceholderMarkers(rawSource).split(/\r?\n/)) {
126+
const trimmed = line.trim()
127+
if (!trimmed || trimmed.startsWith('//')) {
128+
continue
129+
}
130+
const directive = TAILWIND_EXTRACTABLE_DIRECTIVE_RE.exec(line)?.[0]
131+
?? TAILWIND_EXTRACTABLE_BLOCK_DIRECTIVE_RE.exec(line)?.[0]
132+
if (!directive) {
133+
continue
134+
}
135+
const normalized = normalizeTailwindDirectiveLine(directive.trimEnd(), options)
136+
const normalizedTrimmed = normalized.trim()
137+
if (options.removeConfig && normalizedTrimmed.startsWith('@config')) {
138+
continue
139+
}
140+
const request = /^@import\b/.test(normalizedTrimmed)
141+
? parseImportRequest(normalizedTrimmed.replace(/^@import\b/, ''))
142+
: undefined
143+
if (request && !isTailwindImportRequest(request) && !isPackageJsonImportRequest(request)) {
144+
continue
145+
}
146+
if (/^@import\b/.test(normalizedTrimmed) && !request) {
147+
continue
148+
}
149+
if (request && isTailwindImportRequest(request)) {
150+
const key = normalizedTrimmed
151+
if (seenImports.has(key)) {
152+
continue
153+
}
154+
seenImports.add(key)
155+
}
156+
directives.push(normalized)
157+
}
158+
return directives
159+
}
160+
161+
function extractTailwindSourceForPostcssFallback(
162+
rawSource: string,
163+
options: TailwindDirectiveOptions & { removeConfig?: boolean } = {},
164+
) {
165+
const directives = extractTailwindDirectiveLines(rawSource, options)
166+
return directives.length > 0 ? directives.join('\n') : undefined
167+
}
168+
169+
function extractConfigRequestFromSource(rawSource: string) {
170+
for (const line of rawSource.split(/\r?\n/)) {
171+
const match = /^\s*@config\b([\s\S]*?)(?:;|$)/.exec(line)
172+
const request = match ? parseConfigRequest(match[1] ?? '') : undefined
173+
if (request) {
174+
return request
175+
}
176+
}
177+
return undefined
178+
}
179+
180+
function hasPreprocessorOnlySyntax(rawSource: string) {
181+
return /(?:^|\n)\s*(?:\/\/|\$[\w-]+\s*:|@[\w-]+\s*:|@(?:mixin|include|function|use|forward)\b)/.test(rawSource)
182+
}
183+
106184
export function normalizeTailwindSourceDirectives(rawSource: string, options: TailwindDirectiveOptions = {}) {
107185
if (!options.importFallback) {
108186
return rawSource
@@ -112,7 +190,7 @@ export function normalizeTailwindSourceDirectives(rawSource: string, options: Ta
112190
return normalizeTailwindImportAtRules(root, options) ? root.toString() : rawSource
113191
}
114192
catch {
115-
return rawSource
193+
return extractTailwindSourceForPostcssFallback(rawSource, options) ?? rawSource
116194
}
117195
}
118196

@@ -204,7 +282,7 @@ export function hasTailwindSourceDirectives(rawSource: string, options: Tailwind
204282
return found
205283
}
206284
catch {
207-
return false
285+
return extractTailwindDirectiveLines(rawSource, options).length > 0
208286
}
209287
}
210288

@@ -234,7 +312,7 @@ export function hasTailwindRootDirectives(rawSource: string, options: TailwindDi
234312
return found
235313
}
236314
catch {
237-
return true
315+
return extractTailwindDirectiveLines(rawSource, options).length > 0
238316
}
239317
}
240318

@@ -294,6 +372,17 @@ export function resolveCssEntrySource(
294372
if (!found) {
295373
return undefined
296374
}
375+
if (hasPreprocessorOnlySyntax(rawSource)) {
376+
const css = extractTailwindSourceForPostcssFallback(rawSource, { ...options, removeConfig })
377+
if (css) {
378+
return {
379+
css,
380+
config,
381+
configRequest,
382+
base,
383+
}
384+
}
385+
}
297386
return {
298387
css: removedConfig || normalizedImports ? root.toString() : rawSource,
299388
config,
@@ -302,6 +391,20 @@ export function resolveCssEntrySource(
302391
}
303392
}
304393
catch {
305-
return undefined
394+
const css = extractTailwindSourceForPostcssFallback(rawSource, options)
395+
const configRequest = extractConfigRequestFromSource(rawSource)
396+
const config = configRequest && !isPackageJsonImportRequest(configRequest)
397+
? path.isAbsolute(configRequest)
398+
? configRequest
399+
: path.resolve(base, configRequest)
400+
: undefined
401+
return css
402+
? {
403+
css,
404+
config,
405+
configRequest,
406+
base,
407+
}
408+
: undefined
306409
}
307410
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const SOURCE_STYLE_EXT_RE = /\.(?:css|scss|sass|less|styl|stylus|pcss|postcss)$/i
2+
const STYLE_QUERY_RE = /(?:^|&)type=styles?(?:&|$)/
3+
const STYLE_LANG_QUERY_RE = /(?:^|&)lang(?:[.=](?:css|scss|sass|less|styl|stylus|pcss|postcss))?(?:&|$)/
4+
5+
function stripHash(request: string) {
6+
const hashIndex = request.indexOf('#')
7+
return hashIndex === -1 ? request : request.slice(0, hashIndex)
8+
}
9+
10+
export function stripRequestQuery(request: string) {
11+
const normalized = stripHash(request)
12+
const queryIndex = normalized.indexOf('?')
13+
return queryIndex === -1 ? normalized : normalized.slice(0, queryIndex)
14+
}
15+
16+
export function isSourceStyleRequest(request: string | undefined) {
17+
if (typeof request !== 'string' || request.length === 0) {
18+
return false
19+
}
20+
const normalized = stripHash(request)
21+
const queryIndex = normalized.indexOf('?')
22+
const pathname = queryIndex === -1 ? normalized : normalized.slice(0, queryIndex)
23+
if (SOURCE_STYLE_EXT_RE.test(pathname)) {
24+
return true
25+
}
26+
if (queryIndex === -1) {
27+
return false
28+
}
29+
const query = normalized.slice(queryIndex + 1)
30+
return STYLE_QUERY_RE.test(query) || STYLE_LANG_QUERY_RE.test(query)
31+
}

packages/weapp-tailwindcss/src/bundlers/vite/rewrite-css-imports.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { AppType } from '@/types'
33
import { vitePluginName } from '@/constants'
44
import { resolveTailwindcssImport, rewriteTailwindcssImportsInCode } from '../shared/css-imports'
55
import { hasTailwindRootDirectives } from '../shared/generator-css/directives'
6+
import { isSourceStyleRequest } from '../shared/style-requests'
67
import { cleanUrl, isCSSRequest } from './utils'
78

89
function joinPosixPath(base: string, subpath: string) {
@@ -17,7 +18,7 @@ function isCssLikeImporter(importer?: string | null) {
1718
return false
1819
}
1920
const normalized = cleanUrl(importer)
20-
return isCSSRequest(normalized) || normalized.endsWith('/*')
21+
return isSourceStyleRequest(importer) || isCSSRequest(normalized) || normalized.endsWith('/*')
2122
}
2223

2324
interface RewriteCssImportsOptions {

packages/weapp-tailwindcss/src/bundlers/vite/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ExistingRawSourceMap } from 'rollup'
22
import path from 'node:path'
33
import process from 'node:process'
44
import { cleanUrl, ensurePosix } from '@weapp-tailwindcss/shared'
5+
import { isSourceStyleRequest } from '../shared/style-requests'
56

67
export function slash(p: string): string {
78
return ensurePosix(p)
@@ -12,7 +13,7 @@ export const isWindows = process.platform === 'win32'
1213
const cssLangs = `\\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\\?)`
1314
export const cssLangRE = new RegExp(cssLangs)
1415
export function isCSSRequest(request: string): boolean {
15-
return cssLangRE.test(request)
16+
return cssLangRE.test(request) || isSourceStyleRequest(request)
1617
}
1718

1819
export function normalizePath(id: string): string {

packages/weapp-tailwindcss/src/bundlers/webpack/BaseUnifiedPlugin/shared.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AppType } from '@/types'
2+
import { isSourceStyleRequest, stripRequestQuery } from '@/bundlers/shared/style-requests'
23

34
const MPX_STYLE_RESOURCE_QUERY_RE = /(?:\?|&)type=styles\b/
45

@@ -10,15 +11,7 @@ export function stripResourceQuery(resource?: string): string | undefined {
1011
if (typeof resource !== 'string') {
1112
return resource
1213
}
13-
const queryIndex = resource.indexOf('?')
14-
if (queryIndex !== -1) {
15-
return resource.slice(0, queryIndex)
16-
}
17-
const hashIndex = resource.indexOf('#')
18-
if (hashIndex !== -1) {
19-
return resource.slice(0, hashIndex)
20-
}
21-
return resource
14+
return stripRequestQuery(resource)
2215
}
2316

2417
export function isCssLikeModuleResource(
@@ -33,6 +26,9 @@ export function isCssLikeModuleResource(
3326
if (normalizedResource && cssMatcher(normalizedResource)) {
3427
return true
3528
}
29+
if (isSourceStyleRequest(resource)) {
30+
return true
31+
}
3632
if (appType === 'mpx') {
3733
return MPX_STYLE_RESOURCE_QUERY_RE.test(resource)
3834
}

packages/weapp-tailwindcss/src/bundlers/webpack/loaders/weapp-tw-css-import-rewrite-loader.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { inspect } from 'node:util'
77
import { ensurePosix } from '@weapp-tailwindcss/shared'
88
import loaderUtils from 'loader-utils'
99
import { rewriteTailwindcssImportsInCode } from '@/bundlers/shared/css-imports'
10-
11-
const TAILWIND_ROOT_DIRECTIVE_RE = /@(?:import\s+(?:url\(\s*)?["']?tailwindcss4?(?:\/[^"')\s]*)?|tailwind|config|custom-variant|plugin|source|theme|utility|variant)\b/
10+
import { hasTailwindRootDirectives } from '@/bundlers/shared/generator-css/directives'
1211

1312
interface CssImportRewriteLoaderOptions {
1413
tailwindcssImportRewrite?: {
@@ -75,7 +74,7 @@ const WeappTwCssImportRewriteLoader: webpack.LoaderDefinitionFunction<CssImportR
7574
}
7675
const opt = getLoaderOptions(this)
7776
const input = Buffer.isBuffer(source) ? source.toString('utf-8') : source
78-
const registerTask = typeof input === 'string' && TAILWIND_ROOT_DIRECTIVE_RE.test(input)
77+
const registerTask = typeof input === 'string' && hasTailwindRootDirectives(input, { importFallback: true })
7978
? opt?.tailwindcssImportRewrite?.registerCssSource?.({
8079
file: this.resourcePath,
8180
css: input,

packages/weapp-tailwindcss/test/bundlers/generator-css.unit.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,62 @@ describe('bundlers/shared generator css', () => {
390390
expect(source?.css).toBe('@import "tailwindcss";\n@import "tailwindcss/theme.css";')
391391
})
392392

393+
it('extracts Tailwind directives from Sass and Less sources when PostCSS cannot parse them', async () => {
394+
const { hasTailwindSourceDirectives, resolveCssEntrySource } = await import('@/bundlers/shared/generator-css')
395+
const rawSource = [
396+
'$brand: #123456;',
397+
'// sass comment',
398+
'@import "tailwindcss";',
399+
'@import "weapp-tailwindcss";',
400+
'@config "./tailwind.config.ts";',
401+
'@source inline("w-[100px] text-[#123456]");',
402+
'@reference "tailwindcss";',
403+
'.card {',
404+
' color: $brand;',
405+
' @include rounded;',
406+
' .title { color: red; }',
407+
'}',
408+
].join('\n')
409+
410+
expect(hasTailwindSourceDirectives(rawSource, { importFallback: true })).toBe(true)
411+
412+
const source = resolveCssEntrySource(rawSource, __dirname, {
413+
importFallback: true,
414+
removeConfig: false,
415+
})
416+
417+
expect(source).toEqual(expect.objectContaining({
418+
css: [
419+
'@import "tailwindcss";',
420+
'@config "./tailwind.config.ts";',
421+
'@source inline("w-[100px] text-[#123456]");',
422+
'@reference "tailwindcss";',
423+
].join('\n'),
424+
base: __dirname,
425+
}))
426+
expect(source?.css).not.toContain('$brand')
427+
expect(source?.css).not.toContain('@include')
428+
})
429+
430+
it('extracts Tailwind v3 @tailwind directives from Less sources', async () => {
431+
const { resolveCssEntrySource } = await import('@/bundlers/shared/generator-css')
432+
const rawSource = [
433+
'@brand: #123456;',
434+
'// less comment',
435+
'@tailwind base;',
436+
'@tailwind components;',
437+
'@tailwind utilities;',
438+
'.card { color: @brand; }',
439+
].join('\n')
440+
const source = resolveCssEntrySource(rawSource, __dirname)
441+
442+
expect(source?.css).toBe([
443+
'@tailwind base;',
444+
'@tailwind components;',
445+
'@tailwind utilities;',
446+
].join('\n'))
447+
})
448+
393449
it('generates Tailwind v4 css from package.json subpath imports in default auto mode', async () => {
394450
const runtimeSet = new Set(['w-[100px]'])
395451
const rawSource = '@import "#tailwind.css";\n.card{color:red}'

packages/weapp-tailwindcss/test/bundlers/vite-plugin.rewrite.unit.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,26 @@ describe('bundlers/vite WeappTailwindcss rewrite', () => {
9191
expect(result?.code).toContain(`@import url("${pkgDir}/utilities");`)
9292
}, TEST_TIMEOUT_MS)
9393

94+
it('rewrites tailwindcss imports in preprocessor and SFC style requests', async () => {
95+
const [rewritePlugin] = createRewriteCssImportsPlugins({
96+
shouldRewrite: true,
97+
weappTailwindcssDirPosix: '/virtual/weapp-tailwindcss',
98+
})
99+
const resolveId = getResolveIdHandler(rewritePlugin!)
100+
const transform = getTransformHandler(rewritePlugin!)
101+
102+
expect(await resolveId?.('tailwindcss', '/src/app.scss?inline')).toBe('/virtual/weapp-tailwindcss/index.css')
103+
expect(await resolveId?.('weapp-tailwindcss', '/src/component.vue?vue&type=style&index=0&lang.scss')).toBe('/virtual/weapp-tailwindcss/index.css')
104+
105+
const scss = await transform?.('$color: red;\n@import "tailwindcss";\n.app { color: $color; }', '/src/app.scss') as TransformResult
106+
expect(scss?.code).toContain('@import "/virtual/weapp-tailwindcss/index.css";')
107+
expect(scss?.code).toContain('$color: red;')
108+
109+
const less = await transform?.('@import "weapp-tailwindcss";\n@color: red;\n.app { color: @color; }', '/src/component.vue?vue&type=style&index=0&lang=less') as TransformResult
110+
expect(less?.code).toContain('@import "/virtual/weapp-tailwindcss/index.css";')
111+
expect(less?.code).toContain('@color: red;')
112+
})
113+
94114
it('rewrites tailwindcss root imports to generator placeholder in force generator mode', async () => {
95115
const WeappTailwindcss = await loadUnifiedVitePlugin()
96116
const currentContext = createContext({

packages/weapp-tailwindcss/test/bundlers/webpack.shared.unit.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ describe('bundlers/webpack shared helpers', () => {
2222

2323
expect(isCssLikeModuleResource(undefined, cssMatcher)).toBe(false)
2424
expect(isCssLikeModuleResource('app.css?type=style', cssMatcher)).toBe(true)
25+
expect(isCssLikeModuleResource('app.scss?inline', cssMatcher)).toBe(true)
26+
expect(isCssLikeModuleResource('component.vue?vue&type=style&index=0&lang.scss', cssMatcher)).toBe(true)
27+
expect(isCssLikeModuleResource('component.vue?vue&type=style&index=0&lang=less', cssMatcher)).toBe(true)
2528
expect(isCssLikeModuleResource('component.mpx?type=styles&index=0', cssMatcher, 'mpx')).toBe(true)
26-
expect(isCssLikeModuleResource('component.mpx?type=styles&index=0', cssMatcher)).toBe(false)
29+
expect(isCssLikeModuleResource('component.mpx?type=styles&index=0', cssMatcher)).toBe(true)
2730

2831
expect(hasLoaderEntry([{ loader: '/virtual/runtime-loader.js?abc' }], 'runtime-loader.js')).toBe(true)
2932
expect(hasLoaderEntry([{ loader: '/virtual/runtime-loader.js?abc' }])).toBe(false)

0 commit comments

Comments
 (0)