Skip to content

Commit f31db8f

Browse files
committed
Add support for JS APIs in the v4 fallback build
wip wip
1 parent 72a0dd6 commit f31db8f

File tree

5 files changed

+215
-2
lines changed

5 files changed

+215
-2
lines changed

packages/tailwindcss-language-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"homepage": "https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme",
1515
"scripts": {
1616
"build": "pnpm run clean && pnpm run _esbuild && pnpm run hashbang",
17-
"_esbuild": "node ../../esbuild.mjs src/server.ts --outfile=bin/tailwindcss-language-server --minify",
17+
"_esbuild": "node ../../esbuild.mjs src/server.ts --outfile=bin/tailwindcss-language-server",
1818
"clean": "rimraf bin",
1919
"hashbang": "node scripts/hashbang.mjs",
2020
"create-notices-file": "node scripts/createNoticesFile.mjs",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { defineModules } from './define-modules'
2+
3+
export const loadBundledModules = defineModules({
4+
// Plugins
5+
'@tailwindcss/forms': require('@tailwindcss/forms'),
6+
'@tailwindcss/typography': require('@tailwindcss/typography'),
7+
'@tailwindcss/aspect-ratio': require('@tailwindcss/aspect-ratio'),
8+
9+
// v4 API support
10+
tailwindcss: require('tailwindcss-v4'),
11+
'tailwindcss/colors': require('tailwindcss-v4/colors'),
12+
'tailwindcss/colors.js': require('tailwindcss-v4/colors'),
13+
'tailwindcss/plugin': require('tailwindcss-v4/plugin'),
14+
'tailwindcss/plugin.js': require('tailwindcss-v4/plugin'),
15+
'tailwindcss/package.json': require('tailwindcss-v4/package.json'),
16+
'tailwindcss/lib/util/flattenColorPalette': require('tailwindcss-v4/lib/util/flattenColorPalette'),
17+
'tailwindcss/lib/util/flattenColorPalette.js': require('tailwindcss-v4/lib/util/flattenColorPalette'),
18+
'tailwindcss/defaultTheme': require('tailwindcss-v4/defaultTheme'),
19+
'tailwindcss/defaultTheme.js': require('tailwindcss-v4/defaultTheme'),
20+
})
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { Module, register } from 'node:module'
2+
import { pathToFileURL } from 'node:url'
3+
import { MessageChannel, type MessagePort } from 'node:worker_threads'
4+
import * as fs from 'node:fs/promises'
5+
6+
// NOTE: If the minimum supported Node version gets above v23.6.0+ then this
7+
// entire file can be massively simplified by using `registerHooks(…)`
8+
interface LoaderState {
9+
/**
10+
* Whether or not the loader is enabled
11+
*/
12+
enabled: boolean
13+
14+
/**
15+
* The list of "hooked" module IDs
16+
*/
17+
modules: string[]
18+
19+
/**
20+
* The port used to communicate with the main thread
21+
*/
22+
port?: MessagePort | null
23+
}
24+
25+
export interface ModuleHook {
26+
enable: () => Promise<void>
27+
disable: () => Promise<void>
28+
during: <T>(fn: () => Promise<T>) => Promise<T>
29+
}
30+
31+
/**
32+
* Hooks `require(…)`, `await import(…)`, and `import from "…"` so that the
33+
* given modules are returned instead of being loaded from disk.
34+
*/
35+
export function defineModules(modules: Record<string, unknown>): ModuleHook {
36+
// The channel used to communicate between the main and loader threads
37+
// - port1: used by the main thread
38+
// - port2: used by the loader thread
39+
let channel = new MessageChannel()
40+
41+
// The current state of the loader
42+
// A copy of this is kept in and used by the loader thread
43+
let state: LoaderState = {
44+
enabled: false,
45+
modules: Object.keys(modules),
46+
}
47+
48+
function update(partial: Partial<LoaderState>) {
49+
Object.assign(state, partial)
50+
channel.port1.postMessage({ state })
51+
}
52+
53+
// Define a global function that can be used to load bundled modules
54+
// This is used by both the require() replacement and the ESM loader
55+
globalThis.__tw_load__ = (id: string) => modules[id]
56+
57+
// Hook into require() and createRequire() so they can load the given modules
58+
function wrapRequire(original: NodeJS.Require) {
59+
function customRequire(id: string) {
60+
fs.appendFile('loader.log', 'loader require(' + id + ')\n')
61+
62+
if (!state.enabled) return original.call(this, id)
63+
if (!state.modules.includes(id)) return original.call(this, id)
64+
return globalThis.__tw_load__(id)
65+
}
66+
67+
function customresolve(id: string) {
68+
if (!state.enabled) return original.resolve.apply(this, arguments)
69+
if (!state.modules.includes(id)) return original.resolve.apply(this, arguments)
70+
return id
71+
}
72+
73+
return Object.assign(
74+
customRequire,
75+
// Make sure we carry over other properties of the original require(…)
76+
original,
77+
// Replace `require.resolve(…)` with our custom resolver
78+
{ resolve: customresolve },
79+
)
80+
}
81+
82+
let origRequire = Module.prototype.require
83+
let origCreateRequire = Module.createRequire
84+
85+
// Augment the default "require" available in every CJSS module
86+
Module.prototype.require = wrapRequire(origRequire)
87+
88+
// Augment any "require" created by the "createRequire" method so require
89+
// calls used by ES modules are also intercepted.
90+
Module.createRequire = function () {
91+
return wrapRequire(origCreateRequire.apply(this, arguments))
92+
}
93+
94+
// Hook into the static and dynamic ESM imports so that they can load bundled modules
95+
let uri = `data:text/javascript;base64,${btoa(loader)}`
96+
channel.port2.unref()
97+
register(uri, {
98+
parentURL: pathToFileURL(__filename),
99+
transferList: [channel.port2],
100+
data: {
101+
state: {
102+
...state,
103+
port: channel.port2,
104+
},
105+
},
106+
})
107+
108+
let enable = async () => {
109+
await fs.appendFile('loader.log', 'loader enable' + '\n')
110+
update({ enabled: true })
111+
}
112+
113+
let disable = async () => {
114+
update({ enabled: false })
115+
await fs.appendFile('loader.log', 'loader disable' + '\n')
116+
}
117+
118+
let during = async <T>(fn: () => Promise<T>) => {
119+
await enable()
120+
try {
121+
return await fn()
122+
} finally {
123+
await disable()
124+
}
125+
}
126+
127+
return { enable, disable, during }
128+
}
129+
130+
/**
131+
* The loader here is embedded as a string rather than a separate JS file because that complicates
132+
* the build process. We can turn this into a data URI and use it directly. It lets us keep this
133+
* file entirely self-contained feature-wise.
134+
*/
135+
const js = String.raw
136+
const loader = js`
137+
import { Module } from "node:module";
138+
import * as fs from "node:fs/promises";
139+
140+
/** @type {LoaderState} */
141+
const state = {
142+
enabled: false,
143+
modules: [],
144+
port: null,
145+
};
146+
147+
/** Updates the current state of the loader */
148+
function sync(data) {
149+
Object.assign(state, data.state ?? {})
150+
}
151+
152+
/** Set up communication with the main thread */
153+
export async function initialize(data) {
154+
sync(data);
155+
state.port?.on("message", sync);
156+
}
157+
158+
/** Returns the a special ID for known, bundled modules */
159+
export async function resolve(id, context, next) {
160+
await fs.appendFile("loader.log", "loader resolve " + id + "\n");
161+
162+
if (!state.enabled) return next(id, context);
163+
if (!state.modules.includes(id)) return next(id, context);
164+
165+
return {
166+
shortCircuit: true,
167+
url: 'bundled:' + id,
168+
};
169+
}
170+
171+
/* Loads a bundled module using a global handler */
172+
export async function load(url, context, next) {
173+
await fs.appendFile("loader.log", "loader load " + url + "\n");
174+
175+
if (!state.enabled) return next(url, context);
176+
if (!url.startsWith("bundled:")) return next(url, context);
177+
178+
let id = url.slice(8);
179+
if (!state.modules.includes(id)) return next(url, context);
180+
181+
let source = 'export default globalThis.__tw_load__(';
182+
source += JSON.stringify(id);
183+
source += ')';
184+
185+
return {
186+
shortCircuit: true,
187+
format: "module",
188+
source,
189+
};
190+
}
191+
`

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Resolver } from '../../resolver'
99
import { pathToFileURL } from '../../utils'
1010
import type { Jiti } from 'jiti/lib/types'
1111
import { assets } from './assets'
12+
import { loadBundledModules } from '../../lib/bundled'
1213

1314
const HAS_V4_IMPORT = /@import\s*(?:'tailwindcss'|"tailwindcss")/
1415
const HAS_V4_THEME = /@theme\s*\{/
@@ -112,6 +113,7 @@ export async function loadDesignSystem(
112113
let jiti = createJiti(__filename, {
113114
moduleCache: false,
114115
fsCache: false,
116+
nativeModules: ['tailwindcss/plugin'],
115117
})
116118

117119
// Step 3: Take the resolved CSS and pass it to v4's `loadDesignSystem`

packages/tailwindcss-language-server/tests/env/v4.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ defineTest({
7373
* close to that by using async loaders but there's still a lot of work to do
7474
* to make that a workable solution.
7575
*/
76-
options: { skip: true },
76+
options: { skip: false, only: true },
7777

7878
name: 'v4, no npm, with plugins',
7979
fs: {

0 commit comments

Comments
 (0)