Skip to content

Commit 65658da

Browse files
committed
fix(tailwindcss-patch): ship oxide runtime dependency
1 parent 0a13df3 commit 65658da

5 files changed

Lines changed: 199 additions & 7 deletions

File tree

.changeset/curly-oxide-runtime.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tailwindcss-patch": patch
3+
---
4+
5+
Move `@tailwindcss/oxide` to runtime dependencies and improve source candidate scanner failures so clean downstream installs no longer need to install oxide manually.

packages/tailwindcss-patch/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"@babel/types": "^7.29.0",
9696
"@tailwindcss-mangle/config": "workspace:*",
9797
"@tailwindcss/node": "^4.2.4",
98+
"@tailwindcss/oxide": "^4.2.4",
9899
"cac": "6.7.14",
99100
"consola": "^3.4.2",
100101
"fs-extra": "^11.3.4",
@@ -105,7 +106,6 @@
105106
"tailwindcss-config": "^1.1.5"
106107
},
107108
"devDependencies": {
108-
"@tailwindcss/oxide": "^4.2.4",
109109
"@tailwindcss/postcss": "^4.2.4",
110110
"@tailwindcss/vite": "^4.2.4",
111111
"tailwindcss": "catalog:tailwindcss4",

packages/tailwindcss-patch/src/extraction/candidate-extractor.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,31 @@ import { getTailwindV4DesignSystemCacheKey, loadTailwindV4DesignSystem } from '.
1414
let oxideImportPromise: ReturnType<typeof importOxide> | undefined
1515
const designSystemCandidateCache = new Map<string, Map<string, boolean>>()
1616

17+
function createOxideRuntimeDependencyError(cause: unknown) {
18+
return new Error(
19+
[
20+
'tailwindcss-patch could not load @tailwindcss/oxide, which is required for source candidate scanning.',
21+
'This dependency should be installed automatically by tailwindcss-patch.',
22+
'Reinstall dependencies without disabling optional dependencies, or install @tailwindcss/oxide@^4.2.4 manually if your package manager omitted it.',
23+
].join(' '),
24+
{ cause },
25+
)
26+
}
27+
1728
async function importOxide() {
18-
return import('@tailwindcss/oxide')
29+
try {
30+
return await import('@tailwindcss/oxide')
31+
}
32+
catch (error) {
33+
throw createOxideRuntimeDependencyError(error)
34+
}
1935
}
2036

2137
function getOxideModule() {
2238
oxideImportPromise ??= importOxide()
39+
oxideImportPromise.catch(() => {
40+
oxideImportPromise = undefined
41+
})
2342
return oxideImportPromise
2443
}
2544

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { execFileSync } from 'node:child_process'
2+
import fsSync, { promises as fs } from 'node:fs'
3+
import { createRequire } from 'node:module'
4+
import os from 'node:os'
5+
import path from 'pathe'
6+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7+
8+
const require = createRequire(import.meta.url)
9+
const packageDir = path.resolve(__dirname, '..')
10+
const repoRoot = path.resolve(packageDir, '../..')
11+
12+
let tempDir: string
13+
14+
function run(command: string, args: string[], cwd: string, timeout = 180_000) {
15+
try {
16+
return execFileSync(command, args, {
17+
cwd,
18+
encoding: 'utf8',
19+
env: {
20+
...process.env,
21+
CI: '1',
22+
npm_config_update_notifier: 'false',
23+
},
24+
timeout,
25+
}).trim()
26+
}
27+
catch (error) {
28+
const output = error && typeof error === 'object'
29+
? [
30+
'stdout' in error ? String(error.stdout ?? '') : '',
31+
'stderr' in error ? String(error.stderr ?? '') : '',
32+
].filter(Boolean).join('\n')
33+
: ''
34+
throw new Error(`${command} ${args.join(' ')} failed\n${output}`)
35+
}
36+
}
37+
38+
async function packTailwindcssPatch() {
39+
const packDir = path.join(tempDir, 'pack')
40+
await fs.mkdir(packDir, { recursive: true })
41+
const output = run('pnpm', ['--dir', packageDir, 'pack', '--json', '--pack-destination', packDir], repoRoot)
42+
const result = JSON.parse(output) as { filename: string }
43+
return result.filename
44+
}
45+
46+
async function createProject(name: string) {
47+
const projectDir = path.join(tempDir, name)
48+
await fs.mkdir(projectDir, { recursive: true })
49+
await fs.writeFile(
50+
path.join(projectDir, 'package.json'),
51+
`${JSON.stringify({
52+
name,
53+
private: true,
54+
type: 'module',
55+
}, null, 2)}\n`,
56+
'utf8',
57+
)
58+
return projectDir
59+
}
60+
61+
function installProject(projectDir: string, tarball: string, tailwindVersion: string) {
62+
run('pnpm', [
63+
'add',
64+
'--ignore-workspace',
65+
tarball,
66+
`tailwindcss@${tailwindVersion}`,
67+
], projectDir)
68+
}
69+
70+
function runProjectScript(projectDir: string, source: string) {
71+
const scriptPath = path.join(projectDir, 'run.mjs')
72+
fsSync.writeFileSync(scriptPath, source, 'utf8')
73+
return JSON.parse(run(process.execPath, [scriptPath], projectDir))
74+
}
75+
76+
describe('packed tailwindcss-patch runtime dependencies', () => {
77+
beforeEach(async () => {
78+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tw-patch-packaged-'))
79+
})
80+
81+
afterEach(async () => {
82+
await fs.rm(tempDir, { force: true, recursive: true })
83+
})
84+
85+
it('publishes @tailwindcss/oxide as a runtime dependency for package-manager installs', async () => {
86+
const tarball = await packTailwindcssPatch()
87+
const manifest = JSON.parse(
88+
run('tar', ['-xOf', tarball, 'package/package.json'], repoRoot),
89+
)
90+
91+
expect(manifest.dependencies['@tailwindcss/oxide']).toBe('^4.2.4')
92+
expect(manifest.devDependencies?.['@tailwindcss/oxide']).toBeUndefined()
93+
94+
const oxideManifestPath = require.resolve('@tailwindcss/oxide/package.json')
95+
const oxideManifest = JSON.parse(await fs.readFile(oxideManifestPath, 'utf8'))
96+
expect(Object.keys(oxideManifest.optionalDependencies ?? {}).length).toBeGreaterThan(0)
97+
})
98+
99+
it('runs source candidate scanning from a clean Tailwind CSS v3.4.19 project without explicitly installing oxide', async () => {
100+
const tarball = await packTailwindcssPatch()
101+
const projectDir = await createProject('tailwind-v3-consumer')
102+
installProject(projectDir, tarball, '3.4.19')
103+
104+
const result = runProjectScript(projectDir, `
105+
import fs from 'node:fs/promises'
106+
import path from 'node:path'
107+
import { createRequire } from 'node:module'
108+
import { TailwindcssPatcher } from 'tailwindcss-patch'
109+
110+
const cwd = process.cwd()
111+
await fs.writeFile(path.join(cwd, 'page.html'), '<div class="text-red-500 font-bold unknown-token"></div>')
112+
113+
const require = createRequire(import.meta.url)
114+
const oxidePackageJson = require.resolve('@tailwindcss/oxide/package.json')
115+
const patcher = new TailwindcssPatcher({
116+
projectRoot: cwd,
117+
cache: false,
118+
apply: { overwrite: false },
119+
tailwindcss: { version: 3 },
120+
})
121+
const report = await patcher.collectContentTokens({
122+
cwd,
123+
sources: [{ base: cwd, pattern: 'page.html', negated: false }],
124+
})
125+
126+
console.log(JSON.stringify({
127+
majorVersion: patcher.majorVersion,
128+
hasOxidePackage: oxidePackageJson.includes('node_modules'),
129+
rawCandidates: report.entries.map(entry => entry.rawCandidate),
130+
}))
131+
`)
132+
133+
expect(result.majorVersion).toBe(3)
134+
expect(result.hasOxidePackage).toBe(true)
135+
expect(result.rawCandidates).toContain('text-red-500')
136+
expect(result.rawCandidates).toContain('font-bold')
137+
})
138+
139+
it('keeps the Tailwind CSS v4 oxide source scanner path working from a clean project', async () => {
140+
const tarball = await packTailwindcssPatch()
141+
const projectDir = await createProject('tailwind-v4-consumer')
142+
installProject(projectDir, tarball, '4.2.4')
143+
144+
const result = runProjectScript(projectDir, `
145+
import { createTailwindV4Engine, resolveTailwindV4Source } from 'tailwindcss-patch'
146+
147+
const source = await resolveTailwindV4Source({
148+
projectRoot: process.cwd(),
149+
css: '@import "tailwindcss";',
150+
packageName: 'tailwindcss',
151+
})
152+
const engine = createTailwindV4Engine(source)
153+
const generated = await engine.generate({
154+
sources: [{ content: '<div class="text-red-500"></div>', extension: 'html' }],
155+
})
156+
157+
console.log(JSON.stringify({
158+
rawCandidates: Array.from(generated.rawCandidates),
159+
classList: Array.from(generated.classSet),
160+
cssContainsUtility: generated.css.includes('.text-red-500'),
161+
}))
162+
`)
163+
164+
expect(result.rawCandidates).toContain('text-red-500')
165+
expect(result.classList).toContain('text-red-500')
166+
expect(result.cssContainsUtility).toBe(true)
167+
})
168+
})

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)