From 5d17a056f04bd64f7a37ad95bf74b0e0ef45a151 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Mon, 26 Oct 2026 09:43:05 +0100 Subject: [PATCH 1/4] fix: support Tailwind import options in theme generation - Strip `source()`, `prefix()` and `theme()` options before Lightning CSS parsing - Add coverage for theme variable collection with Tailwind import options --- .../src/bundler/artifacts/css/themes.ts | 6 ++- .../web/bundler/themes-import-options.test.ts | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 packages/uniwind/tests/web/bundler/themes-import-options.test.ts diff --git a/packages/uniwind/src/bundler/artifacts/css/themes.ts b/packages/uniwind/src/bundler/artifacts/css/themes.ts index da0ef3e2..d3889cd2 100644 --- a/packages/uniwind/src/bundler/artifacts/css/themes.ts +++ b/packages/uniwind/src/bundler/artifacts/css/themes.ts @@ -20,6 +20,10 @@ const isExcludedDependency = (url: string) => url.includes('node_modules/uniwind'), ].some(Boolean) +// Lightning CSS only understands the standard `@import` grammar, so Tailwind's +// `source()`, `prefix()` and `theme()` import options are syntax errors there +const stripTailwindImportOptions = (css: string) => css.replace(/@import[^;]+;/g, rule => rule.replace(/\s+(?:source|prefix|theme)\([^)]*\)/g, '')) + export const generateCSSForThemes = async (themes: Array, input: string) => { // css generation const themesVariables = Object.fromEntries(themes.map(theme => [theme, new Set()])) @@ -32,7 +36,7 @@ export const generateCSSForThemes = async (themes: Array, input: string) } const { dependencies } = transform({ - code: Buffer.from(css), + code: Buffer.from(stripTailwindImportOptions(css)), filename: 'uniwind.css', analyzeDependencies: true, visitor: { diff --git a/packages/uniwind/tests/web/bundler/themes-import-options.test.ts b/packages/uniwind/tests/web/bundler/themes-import-options.test.ts new file mode 100644 index 00000000..ca1a2719 --- /dev/null +++ b/packages/uniwind/tests/web/bundler/themes-import-options.test.ts @@ -0,0 +1,37 @@ +import fs from 'fs' +import path from 'path' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import { generateCSSForThemes } from '../../../src/bundler/artifacts/css/themes' + +describe('Tailwind import options', () => { + let dir: string + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(__dirname, '.tmp-themes-')) + }) + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }) + }) + + test.each([ + `source('./')`, + 'source(none)', + 'prefix(tw)', + 'theme(static)', + ])('@import with %s still collects theme variables', async option => { + fs.writeFileSync(path.join(dir, 'theme.css'), '@variant dark { --color-background: black; }') + fs.writeFileSync( + path.join(dir, 'global.css'), + [ + `@import 'tailwindcss' ${option};`, + `@import './theme.css' ${option};`, + '@variant light { --color-background: white; }', + ].join('\n'), + ) + + const css = await generateCSSForThemes(['light', 'dark'], path.join(dir, 'global.css')) + + expect(css).toContain('--color-background: unset;') + }) +}) From 0822a490f7068b34e59e55252060d262e656c158 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Mon, 26 Oct 2026 09:48:49 +0100 Subject: [PATCH 2/4] test: cover multiple variants in imported themes --- .../tests/web/bundler/themes-import-options.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/uniwind/tests/web/bundler/themes-import-options.test.ts b/packages/uniwind/tests/web/bundler/themes-import-options.test.ts index ca1a2719..00d62e80 100644 --- a/packages/uniwind/tests/web/bundler/themes-import-options.test.ts +++ b/packages/uniwind/tests/web/bundler/themes-import-options.test.ts @@ -20,13 +20,18 @@ describe('Tailwind import options', () => { 'prefix(tw)', 'theme(static)', ])('@import with %s still collects theme variables', async option => { - fs.writeFileSync(path.join(dir, 'theme.css'), '@variant dark { --color-background: black; }') + fs.writeFileSync( + path.join(dir, 'theme.css'), + [ + '@variant light { --color-background: white; }', + '@variant dark { --color-background: black; }', + ].join('\n'), + ) fs.writeFileSync( path.join(dir, 'global.css'), [ `@import 'tailwindcss' ${option};`, `@import './theme.css' ${option};`, - '@variant light { --color-background: white; }', ].join('\n'), ) From 98c93f8e19d744cdee3e01ae7eec6a938050bf8d Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Mon, 26 Oct 2026 09:51:25 +0100 Subject: [PATCH 3/4] fix: strip quoted Tailwind import option paths --- packages/uniwind/src/bundler/artifacts/css/themes.ts | 9 +++++++-- .../tests/web/bundler/themes-import-options.test.ts | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/uniwind/src/bundler/artifacts/css/themes.ts b/packages/uniwind/src/bundler/artifacts/css/themes.ts index d3889cd2..02568bbf 100644 --- a/packages/uniwind/src/bundler/artifacts/css/themes.ts +++ b/packages/uniwind/src/bundler/artifacts/css/themes.ts @@ -21,8 +21,13 @@ const isExcludedDependency = (url: string) => ].some(Boolean) // Lightning CSS only understands the standard `@import` grammar, so Tailwind's -// `source()`, `prefix()` and `theme()` import options are syntax errors there -const stripTailwindImportOptions = (css: string) => css.replace(/@import[^;]+;/g, rule => rule.replace(/\s+(?:source|prefix|theme)\([^)]*\)/g, '')) +// `source()`, `prefix()` and `theme()` import options are syntax errors there. +// Quoted strings are matched as a whole, so paths like `source("./src/(app)")` are stripped correctly +const QUOTED_OR = `"[^"]*"|'[^']*'` +const importRuleRegex = new RegExp(`@import(?:${QUOTED_OR}|[^;"'])+;`, 'g') +const importOptionRegex = new RegExp(`\\s+(?:source|prefix|theme)\\((?:${QUOTED_OR}|[^)"'])*\\)`, 'g') + +const stripTailwindImportOptions = (css: string) => css.replace(importRuleRegex, rule => rule.replace(importOptionRegex, '')) export const generateCSSForThemes = async (themes: Array, input: string) => { // css generation diff --git a/packages/uniwind/tests/web/bundler/themes-import-options.test.ts b/packages/uniwind/tests/web/bundler/themes-import-options.test.ts index 00d62e80..bb2bb5bb 100644 --- a/packages/uniwind/tests/web/bundler/themes-import-options.test.ts +++ b/packages/uniwind/tests/web/bundler/themes-import-options.test.ts @@ -16,6 +16,8 @@ describe('Tailwind import options', () => { test.each([ `source('./')`, + `source("./src/(app)")`, + `source('./src/(app);')`, 'source(none)', 'prefix(tw)', 'theme(static)', From 2c77152c50fd2d27cd4baebaed35346fe7eee048 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Mon, 26 Oct 2026 10:19:54 +0100 Subject: [PATCH 4/4] fix: preserve comments when stripping Tailwind import options --- .../src/bundler/artifacts/css/themes.ts | 15 +++--- .../web/bundler/themes-import-options.test.ts | 49 +++++++++++++------ 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/uniwind/src/bundler/artifacts/css/themes.ts b/packages/uniwind/src/bundler/artifacts/css/themes.ts index 02568bbf..5003b50d 100644 --- a/packages/uniwind/src/bundler/artifacts/css/themes.ts +++ b/packages/uniwind/src/bundler/artifacts/css/themes.ts @@ -20,14 +20,13 @@ const isExcludedDependency = (url: string) => url.includes('node_modules/uniwind'), ].some(Boolean) -// Lightning CSS only understands the standard `@import` grammar, so Tailwind's -// `source()`, `prefix()` and `theme()` import options are syntax errors there. -// Quoted strings are matched as a whole, so paths like `source("./src/(app)")` are stripped correctly -const QUOTED_OR = `"[^"]*"|'[^']*'` -const importRuleRegex = new RegExp(`@import(?:${QUOTED_OR}|[^;"'])+;`, 'g') -const importOptionRegex = new RegExp(`\\s+(?:source|prefix|theme)\\((?:${QUOTED_OR}|[^)"'])*\\)`, 'g') - -const stripTailwindImportOptions = (css: string) => css.replace(importRuleRegex, rule => rule.replace(importOptionRegex, '')) +// Strips Tailwind's `source()`, `prefix()` and `theme()` @import options, which Lightning CSS can't parse +const QUOTED_OR_COMMENT = `/\\*[\\s\\S]*?\\*/|"[^"]*"|'[^']*'` +const importRuleRegex = new RegExp(`@import(?:${QUOTED_OR_COMMENT}|[^;"'])+;`, 'g') +const importOptionRegex = new RegExp(`(${QUOTED_OR_COMMENT})|\\s+(?:source|prefix|theme)\\((?:${QUOTED_OR_COMMENT}|[^)"'])*\\)`, 'g') + +const stripTailwindImportOptions = (css: string) => + css.replace(importRuleRegex, rule => rule.replace(importOptionRegex, (_, kept?: string) => kept ?? '')) export const generateCSSForThemes = async (themes: Array, input: string) => { // css generation diff --git a/packages/uniwind/tests/web/bundler/themes-import-options.test.ts b/packages/uniwind/tests/web/bundler/themes-import-options.test.ts index bb2bb5bb..d9019b9f 100644 --- a/packages/uniwind/tests/web/bundler/themes-import-options.test.ts +++ b/packages/uniwind/tests/web/bundler/themes-import-options.test.ts @@ -14,6 +14,19 @@ describe('Tailwind import options', () => { fs.rmSync(dir, { recursive: true, force: true }) }) + const generate = async (globalCSS: Array, themeFile = 'theme.css') => { + fs.writeFileSync( + path.join(dir, themeFile), + [ + '@variant light { --color-background: white; }', + '@variant dark { --color-background: black; }', + ].join('\n'), + ) + fs.writeFileSync(path.join(dir, 'global.css'), globalCSS.join('\n')) + + return generateCSSForThemes(['light', 'dark'], path.join(dir, 'global.css')) + } + test.each([ `source('./')`, `source("./src/(app)")`, @@ -22,22 +35,28 @@ describe('Tailwind import options', () => { 'prefix(tw)', 'theme(static)', ])('@import with %s still collects theme variables', async option => { - fs.writeFileSync( - path.join(dir, 'theme.css'), - [ - '@variant light { --color-background: white; }', - '@variant dark { --color-background: black; }', - ].join('\n'), - ) - fs.writeFileSync( - path.join(dir, 'global.css'), - [ - `@import 'tailwindcss' ${option};`, - `@import './theme.css' ${option};`, - ].join('\n'), - ) + const css = await generate([ + `@import 'tailwindcss' ${option};`, + `@import './theme.css' ${option};`, + ]) + + expect(css).toContain('--color-background: unset;') + }) + + test('quotes inside comments do not break option stripping', async () => { + const css = await generate([ + `@import 'tailwindcss' /* don't */ source('./');`, + `@import './theme.css';`, + ]) + + expect(css).toContain('--color-background: unset;') + }) - const css = await generateCSSForThemes(['light', 'dark'], path.join(dir, 'global.css')) + test('option-like text inside the import path is kept', async () => { + const css = await generate([ + `@import 'tailwindcss' source('./');`, + `@import './theme source(none).css';`, + ], 'theme source(none).css') expect(css).toContain('--color-background: unset;') })