Skip to content

Commit 3ba67b2

Browse files
committed
Add support for v4 fallback
1 parent 3a2705f commit 3ba67b2

File tree

9 files changed

+308
-2
lines changed

9 files changed

+308
-2
lines changed

packages/tailwindcss-language-server/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"color-name": "1.1.4",
6060
"culori": "^4.0.1",
6161
"debounce": "1.2.0",
62+
"dedent": "^1.5.3",
6263
"deepmerge": "4.2.2",
6364
"dlv": "1.1.3",
6465
"dset": "3.1.2",
@@ -81,6 +82,7 @@
8182
"rimraf": "3.0.2",
8283
"stack-trace": "0.0.10",
8384
"tailwindcss": "3.4.17",
85+
"tailwindcss-v4": "npm:[email protected]",
8486
"tsconfck": "^3.1.4",
8587
"tsconfig-paths": "^4.2.0",
8688
"typescript": "5.3.3",

packages/tailwindcss-language-server/src/project-locator.ts

+18
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,24 @@ export class ProjectLocator {
454454
}
455455
} catch {}
456456

457+
// A local version of Tailwind CSS was not found so we need to use the
458+
// fallback bundled with the language server. This is especially important
459+
// for projects using the standalone CLI.
460+
461+
// This is a v4-style CSS config
462+
if (config.type === 'css') {
463+
let { version } = require('tailwindcss-v4/package.json')
464+
// @ts-ignore
465+
let mod = await import('tailwindcss-v4')
466+
let features = supportedFeatures(version, mod)
467+
468+
return {
469+
version,
470+
features,
471+
isDefaultVersion: true,
472+
}
473+
}
474+
457475
let { version } = require('tailwindcss/package.json')
458476
let mod = require('tailwindcss')
459477
let features = supportedFeatures(version, mod)

packages/tailwindcss-language-server/src/projects.ts

+29-2
Original file line numberDiff line numberDiff line change
@@ -489,8 +489,8 @@ export async function createProjectService(
489489
log('CSS-based configuration is not supported before Tailwind CSS v4')
490490
state.enabled = false
491491
enabled = false
492-
// CSS-based configuration is not supported before Tailwind CSS v4 so bail
493-
// TODO: Fall back to built-in version of v4
492+
493+
// The fallback to a bundled v4 is in the catch block
494494
return
495495
}
496496

@@ -673,6 +673,31 @@ export async function createProjectService(
673673
} catch (_) {}
674674
}
675675
} catch (error) {
676+
if (projectConfig.config.source === 'css') {
677+
// @ts-ignore
678+
let tailwindcss = await import('tailwindcss-v4')
679+
let tailwindcssVersion = require('tailwindcss-v4/package.json').version
680+
let features = supportedFeatures(tailwindcssVersion, tailwindcss)
681+
682+
log('Failed to load workspace modules.')
683+
log(`Using bundled version of \`tailwindcss\`: v${tailwindcssVersion}`)
684+
685+
state.configPath = configPath
686+
state.version = tailwindcssVersion
687+
state.isCssConfig = true
688+
state.v4 = true
689+
state.v4Fallback = true
690+
state.jit = true
691+
state.modules = {
692+
tailwindcss: { version: tailwindcssVersion, module: tailwindcss },
693+
postcss: { version: null, module: null },
694+
resolveConfig: { module: null },
695+
loadConfig: { module: null },
696+
}
697+
698+
return tryRebuild()
699+
}
700+
676701
let util = await import('node:util')
677702

678703
console.error(util.format(error))
@@ -786,6 +811,7 @@ export async function createProjectService(
786811
state.modules.tailwindcss.module,
787812
state.configPath,
788813
css,
814+
state.v4Fallback ?? false,
789815
)
790816

791817
state.designSystem = designSystem
@@ -1063,6 +1089,7 @@ export async function createProjectService(
10631089
state.modules.tailwindcss.module,
10641090
state.configPath,
10651091
css,
1092+
state.v4Fallback ?? false,
10661093
)
10671094
} catch (err) {
10681095
console.error(err)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { afterAll, onTestFinished, test, TestOptions } from 'vitest'
2+
import * as fs from 'node:fs/promises'
3+
import * as path from 'node:path'
4+
import * as proc from 'node:child_process'
5+
import dedent from 'dedent'
6+
7+
export interface TestUtils {
8+
/** The "cwd" for this test */
9+
root: string
10+
}
11+
12+
export interface Storage {
13+
/** A list of files and their content */
14+
[filePath: string]: string | Uint8Array
15+
}
16+
17+
export interface TestConfig<Extras extends {}> {
18+
name: string
19+
fs: Storage
20+
prepare?(utils: TestUtils): Promise<Extras>
21+
handle(utils: TestUtils & Extras): void | Promise<void>
22+
23+
options?: TestOptions
24+
}
25+
26+
export function defineTest<T>(config: TestConfig<T>) {
27+
return test(config.name, config.options ?? {}, async ({ expect }) => {
28+
let utils = await setup(config)
29+
let extras = await config.prepare?.(utils)
30+
31+
await config.handle({
32+
...utils,
33+
...extras,
34+
})
35+
})
36+
}
37+
38+
async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {
39+
let randomId = Math.random().toString(36).substring(7)
40+
41+
let baseDir = path.resolve(process.cwd(), `../../.debug/${randomId}`)
42+
let doneDir = path.resolve(process.cwd(), `../../.debug/${randomId}-done`)
43+
44+
await fs.mkdir(baseDir, { recursive: true })
45+
46+
await prepareFileSystem(baseDir, config.fs)
47+
await installDependencies(baseDir, config.fs)
48+
49+
onTestFinished(async (result) => {
50+
// Once done, move all the files to a new location
51+
await fs.rename(baseDir, doneDir)
52+
53+
if (result.state === 'fail') return
54+
55+
if (path.sep === '\\') return
56+
57+
// Remove the directory on *nix systems. Recursive removal on Windows will
58+
// randomly fail b/c its slow and buggy.
59+
await fs.rm(doneDir, { recursive: true })
60+
})
61+
62+
return {
63+
root: baseDir,
64+
}
65+
}
66+
67+
async function prepareFileSystem(base: string, storage: Storage) {
68+
// Create a temporary directory to store the test files
69+
await fs.mkdir(base, { recursive: true })
70+
71+
// Write the files to disk
72+
for (let [filepath, content] of Object.entries(storage)) {
73+
let fullPath = path.resolve(base, filepath)
74+
await fs.mkdir(path.dirname(fullPath), { recursive: true })
75+
await fs.writeFile(fullPath, content, { encoding: 'utf-8' })
76+
}
77+
}
78+
79+
async function installDependencies(base: string, storage: Storage) {
80+
for (let filepath of Object.keys(storage)) {
81+
if (!filepath.endsWith('package.json')) continue
82+
83+
let pkgDir = path.dirname(filepath)
84+
let basePath = path.resolve(pkgDir, base)
85+
86+
await installDependenciesIn(basePath)
87+
}
88+
}
89+
90+
async function installDependenciesIn(dir: string) {
91+
console.log(`Installing dependencies in ${dir}`)
92+
93+
await new Promise((resolve, reject) => {
94+
proc.exec('npm install --package-lock=false', { cwd: dir }, (err, res) => {
95+
if (err) {
96+
reject(err)
97+
} else {
98+
resolve(res)
99+
}
100+
})
101+
})
102+
}
103+
104+
export const css = dedent
105+
export const html = dedent
106+
export const js = dedent
107+
export const json = dedent
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import index from 'tailwindcss-v4/index.css'
2+
import preflight from 'tailwindcss-v4/preflight.css'
3+
import theme from 'tailwindcss-v4/theme.css'
4+
import utilities from 'tailwindcss-v4/utilities.css'
5+
6+
export const assets = {
7+
tailwindcss: index,
8+
'tailwindcss/index': index,
9+
'tailwindcss/index.css': index,
10+
11+
'tailwindcss/preflight': preflight,
12+
'tailwindcss/preflight.css': preflight,
13+
14+
'tailwindcss/theme': theme,
15+
'tailwindcss/theme.css': theme,
16+
17+
'tailwindcss/utilities': utilities,
18+
'tailwindcss/utilities.css': utilities,
19+
}

packages/tailwindcss-language-server/src/util/v4/design-system.ts

+8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { resolveCssImports } from '../../css'
88
import { Resolver } from '../../resolver'
99
import { pathToFileURL } from '../../utils'
1010
import type { Jiti } from 'jiti/lib/types'
11+
import { assets } from './assets'
1112

1213
const HAS_V4_IMPORT = /@import\s*(?:'tailwindcss'|"tailwindcss")/
1314
const HAS_V4_THEME = /@theme\s*\{/
@@ -79,6 +80,7 @@ export async function loadDesignSystem(
7980
tailwindcss: any,
8081
filepath: string,
8182
css: string,
83+
isFallback: boolean,
8284
): Promise<DesignSystem | null> {
8385
// This isn't a v4 project
8486
if (!tailwindcss.__unstable__loadDesignSystem) return null
@@ -151,6 +153,12 @@ export async function loadDesignSystem(
151153
content: await fs.readFile(resolved, 'utf-8'),
152154
}
153155
} catch (err) {
156+
if (isFallback && id in assets) {
157+
console.error(`Loading fallback stylesheet for: ${id}`)
158+
159+
return { base, content: assets[id] }
160+
}
161+
154162
console.error(`Unable to load stylesheet: ${id}`, err)
155163
return { base, content: '' }
156164
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { expect } from 'vitest'
2+
import { init } from '../common'
3+
import { HoverRequest } from 'vscode-languageserver'
4+
import { defineTest, json } from '../../src/testing'
5+
import dedent from 'dedent'
6+
import { CompletionRequest } from 'vscode-languageserver-protocol'
7+
8+
defineTest({
9+
name: 'v4, no npm, uses fallback',
10+
fs: {
11+
'app.css': `@import "tailwindcss";`,
12+
},
13+
prepare: async ({ root }) => ({ c: await init(root) }),
14+
handle: async ({ c }) => {
15+
let textDocument = await c.openDocument({
16+
lang: 'html',
17+
text: '<div class="bg-[#000]/25 hover:">',
18+
})
19+
20+
expect(c.project).toMatchObject({
21+
tailwind: {
22+
version: '4.0.0',
23+
isDefaultVersion: true,
24+
},
25+
})
26+
27+
let hover = await c.sendRequest(HoverRequest.type, {
28+
textDocument,
29+
30+
// <div class="bg-[#000]/25 hover:
31+
// ^
32+
position: { line: 0, character: 13 },
33+
})
34+
35+
let completion = await c.sendRequest(CompletionRequest.type, {
36+
textDocument,
37+
context: { triggerKind: 1 },
38+
39+
// <div class="bg-[#000]/25 hover:
40+
// ^
41+
position: { line: 0, character: 31 },
42+
})
43+
44+
expect(hover).toEqual({
45+
contents: {
46+
language: 'css',
47+
value: dedent`
48+
.bg-\[\#000\]\/25 {
49+
background-color: color-mix(in oklab, #000 25%, transparent);
50+
}
51+
`,
52+
},
53+
range: {
54+
start: { line: 0, character: 12 },
55+
end: { line: 0, character: 24 },
56+
},
57+
})
58+
59+
expect(completion.items.length).toBe(12286)
60+
},
61+
})
62+
63+
defineTest({
64+
name: 'v4, with npm, uses local',
65+
fs: {
66+
'package.json': json`
67+
{
68+
"dependencies": {
69+
"tailwindcss": "4.0.1"
70+
}
71+
}
72+
`,
73+
'app.css': `@import "tailwindcss";`,
74+
},
75+
prepare: async ({ root }) => ({ c: await init(root) }),
76+
handle: async ({ c }) => {
77+
let textDocument = await c.openDocument({
78+
lang: 'html',
79+
text: '<div class="bg-[#000]/25 hover:">',
80+
})
81+
82+
expect(c.project).toMatchObject({
83+
tailwind: {
84+
version: '4.0.1',
85+
isDefaultVersion: false,
86+
},
87+
})
88+
89+
let hover = await c.sendRequest(HoverRequest.type, {
90+
textDocument,
91+
92+
// <div class="bg-[#000]/25 hover:
93+
// ^
94+
position: { line: 0, character: 13 },
95+
})
96+
97+
let completion = await c.sendRequest(CompletionRequest.type, {
98+
textDocument,
99+
context: { triggerKind: 1 },
100+
101+
// <div class="bg-[#000]/25 hover:
102+
// ^
103+
position: { line: 0, character: 31 },
104+
})
105+
106+
expect(hover).toEqual({
107+
contents: {
108+
language: 'css',
109+
value: dedent`
110+
.bg-\[\#000\]\/25 {
111+
background-color: color-mix(in oklab, #000 25%, transparent);
112+
}
113+
`,
114+
},
115+
range: {
116+
start: { line: 0, character: 12 },
117+
end: { line: 0, character: 24 },
118+
},
119+
})
120+
121+
expect(completion.items.length).toBe(12288)
122+
},
123+
})

packages/tailwindcss-language-server/vitest.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import tsconfigPaths from 'vite-tsconfig-paths'
44
export default defineConfig({
55
test: {
66
testTimeout: 15000,
7+
css: true,
78
},
89

910
plugins: [tsconfigPaths()],

packages/tailwindcss-language-service/src/util/state.ts

+1
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export interface State {
126126
}
127127

128128
v4?: boolean
129+
v4Fallback?: boolean
129130
designSystem?: DesignSystem
130131

131132
browserslist?: string[]

0 commit comments

Comments
 (0)