Skip to content

Commit 725b746

Browse files
committed
Refactor module resolution
1 parent 010e13a commit 725b746

File tree

7 files changed

+268
-28
lines changed

7 files changed

+268
-28
lines changed
Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
import * as fs from 'node:fs/promises'
22
import postcss from 'postcss'
33
import postcssImport from 'postcss-import'
4-
import { createResolver } from '../util/resolve'
54
import { fixRelativePaths } from './fix-relative-paths'
5+
import { createResolver, Resolver } from '../resolver'
66

7-
const resolver = createResolver({
8-
extensions: ['.css'],
9-
mainFields: ['style'],
10-
conditionNames: ['style'],
11-
})
7+
let _resolver: Awaited<ReturnType<typeof createResolver>>
128

13-
export function resolveCssImports() {
9+
export function resolveCssImports(resolver?: Resolver) {
1410
return postcss([
1511
postcssImport({
1612
async resolve(id, base) {
13+
if (!resolver) {
14+
_resolver ??= await createResolver({
15+
root: process.cwd(),
16+
})
17+
18+
resolver = _resolver
19+
}
20+
1721
try {
18-
return await resolveCssFrom(base, id)
22+
return await resolver.resolveCssId(id, base)
1923
} catch (e) {
2024
// TODO: Need to test this on windows
2125
return `/virtual:missing/${id}`
@@ -33,7 +37,3 @@ export function resolveCssImports() {
3337
fixRelativePaths(),
3438
])
3539
}
36-
37-
export async function resolveCssFrom(base: string, id: string) {
38-
return resolver.resolveSync({}, base, id) || id
39-
}

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

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ import type { Settings } from '@tailwindcss/language-service/src/util/state'
77
import { CONFIG_GLOB, CSS_GLOB } from './lib/constants'
88
import { readCssFile } from './util/css'
99
import { Graph } from './graph'
10-
import type { AtRule, Message } from 'postcss'
1110
import { type DocumentSelector, DocumentSelectorPriority } from './projects'
1211
import { CacheMap } from './cache-map'
1312
import { getPackageRoot } from './util/get-package-root'
14-
import { resolveFrom } from './util/resolveFrom'
13+
import { createResolver, Resolver } from './resolver'
1514
import { type Feature, supportedFeatures } from '@tailwindcss/language-service/src/features'
1615
import { extractSourceDirectives, resolveCssImports } from './css'
1716
import { normalizeDriveLetter, normalizePath, pathToFileURL } from './utils'
@@ -43,6 +42,8 @@ export interface ProjectConfig {
4342
}
4443

4544
export class ProjectLocator {
45+
private resolver: Resolver | null = null
46+
4647
constructor(
4748
private base: string,
4849
private settings: Settings,
@@ -191,7 +192,11 @@ export class ProjectLocator {
191192
})
192193

193194
// - Content patterns from config
194-
for await (let selector of contentSelectorsFromConfig(config, tailwind.features)) {
195+
for await (let selector of contentSelectorsFromConfig(
196+
config,
197+
tailwind.features,
198+
await this.getResolver(),
199+
)) {
195200
selectors.push(selector)
196201
}
197202

@@ -418,7 +423,11 @@ export class ProjectLocator {
418423

419424
private async detectTailwindVersion(config: ConfigEntry) {
420425
try {
421-
let metadataPath = resolveFrom(path.dirname(config.path), 'tailwindcss/package.json')
426+
let resolver = await this.getResolver()
427+
let metadataPath = await resolver.resolveJsId(
428+
'tailwindcss/package.json',
429+
path.dirname(config.path),
430+
)
422431
let { version } = require(metadataPath)
423432
let features = supportedFeatures(version)
424433

@@ -440,19 +449,28 @@ export class ProjectLocator {
440449
isDefaultVersion: true,
441450
}
442451
}
452+
453+
private async getResolver() {
454+
this.resolver ??= await createResolver({
455+
root: process.cwd(),
456+
pnp: true,
457+
})
458+
459+
return this.resolver
460+
}
443461
}
444462

445463
function contentSelectorsFromConfig(
446464
entry: ConfigEntry,
447465
features: Feature[],
448-
actualConfig?: any,
466+
resolver: Resolver,
449467
): AsyncIterable<DocumentSelector> {
450468
if (entry.type === 'css') {
451-
return contentSelectorsFromCssConfig(entry)
469+
return contentSelectorsFromCssConfig(entry, resolver)
452470
}
453471

454472
if (entry.type === 'js') {
455-
return contentSelectorsFromJsConfig(entry, features, actualConfig)
473+
return contentSelectorsFromJsConfig(entry, features)
456474
}
457475
}
458476

@@ -497,7 +515,10 @@ async function* contentSelectorsFromJsConfig(
497515
}
498516
}
499517

500-
async function* contentSelectorsFromCssConfig(entry: ConfigEntry): AsyncIterable<DocumentSelector> {
518+
async function* contentSelectorsFromCssConfig(
519+
entry: ConfigEntry,
520+
resolver: Resolver,
521+
): AsyncIterable<DocumentSelector> {
501522
let auto = false
502523
for (let item of entry.content) {
503524
if (item.kind === 'file') {
@@ -513,7 +534,12 @@ async function* contentSelectorsFromCssConfig(entry: ConfigEntry): AsyncIterable
513534
// other entries should have sources.
514535
let sources = entry.entries.flatMap((entry) => entry.sources)
515536

516-
for await (let pattern of detectContentFiles(entry.packageRoot, entry.path, sources)) {
537+
for await (let pattern of detectContentFiles(
538+
entry.packageRoot,
539+
entry.path,
540+
sources,
541+
resolver,
542+
)) {
517543
yield {
518544
pattern,
519545
priority: DocumentSelectorPriority.CONTENT_FILE,
@@ -527,11 +553,15 @@ async function* detectContentFiles(
527553
base: string,
528554
inputFile: string,
529555
sources: string[],
556+
resolver: Resolver,
530557
): AsyncIterable<string> {
531558
try {
532-
let oxidePath = resolveFrom(path.dirname(base), '@tailwindcss/oxide')
559+
let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', path.dirname(base))
533560
oxidePath = pathToFileURL(oxidePath).href
534-
let oxidePackageJsonPath = resolveFrom(path.dirname(base), '@tailwindcss/oxide/package.json')
561+
let oxidePackageJsonPath = await resolver.resolveJsId(
562+
'@tailwindcss/oxide/package.json',
563+
path.dirname(base),
564+
)
535565
let oxidePackageJson = JSON.parse(await fs.readFile(oxidePackageJsonPath, 'utf8'))
536566

537567
let result = await oxide.scan({

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import stackTrace from 'stack-trace'
3535
import extractClassNames from './lib/extractClassNames'
3636
import { klona } from 'klona/full'
3737
import { doHover } from '@tailwindcss/language-service/src/hoverProvider'
38+
import { createResolver, Resolver } from './resolver'
3839
import {
3940
doComplete,
4041
resolveCompletionItem,
@@ -259,6 +260,17 @@ export async function createProjectService(
259260
])
260261
}
261262

263+
let resolver!: Resolver
264+
265+
async function getResolver() {
266+
resolver ??= await createResolver({
267+
root: projectConfig.folder,
268+
pnp: true,
269+
})
270+
271+
return resolver
272+
}
273+
262274
function log(...args: string[]): void {
263275
console.log(
264276
`[${path.relative(projectConfig.folder, projectConfig.configPath)}] ${args.join(' ')}`,
@@ -748,8 +760,10 @@ export async function createProjectService(
748760

749761
if (state.isCssConfig) {
750762
try {
763+
let resolver = await getResolver()
751764
let css = await readCssFile(state.configPath)
752765
let designSystem = await loadDesignSystem(
766+
resolver,
753767
state.modules.tailwindcss.module,
754768
state.configPath,
755769
css,
@@ -1001,7 +1015,9 @@ export async function createProjectService(
10011015

10021016
console.log('---- RELOADING DESIGN SYSTEM ----')
10031017
let css = await readCssFile(state.configPath)
1018+
let resolver = await getResolver()
10041019
let designSystem = await loadDesignSystem(
1020+
resolver,
10051021
state.modules.tailwindcss.module,
10061022
state.configPath,
10071023
css,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import * as fs from 'node:fs'
2+
import * as path from 'node:path'
3+
import { CachedInputFileSystem, ResolverFactory, Resolver as BaseResolver } from 'enhanced-resolve'
4+
import { loadPnPApi } from './pnp'
5+
6+
export interface ResolverOptions {
7+
/**
8+
* The root directory for the resolver
9+
*/
10+
root: string
11+
12+
/**
13+
* Whether or not the resolver should attempt to use PnP resolution
14+
*/
15+
pnp?: boolean
16+
}
17+
18+
export interface Resolver {
19+
/**
20+
* Sets up the PnP API if it is available such that globals like `require`
21+
* have been monkey-patched to use PnP resolution.
22+
*
23+
* This function does nothing if PnP resolution is not enabled or if the PnP
24+
* API is not available.
25+
*/
26+
setupPnP(): Promise<void>
27+
28+
/**
29+
* Resolves a JavaScript module to a file path.
30+
*
31+
* Assumes dynamic imports or some other ESM-captable mechanism will be used
32+
* to load the module. Tries to resolve the ESM module first, then falls back
33+
* to the CommonJS module if the ESM module is not found.
34+
*
35+
* @param id The module or file to resolve
36+
* @param base The base directory to resolve the module from
37+
*/
38+
resolveJsId(id: string, base: string): Promise<string>
39+
40+
/**
41+
* Resolves a CSS module to a file path.
42+
*
43+
* @param id The module or file to resolve
44+
* @param base The base directory to resolve the module from
45+
*/
46+
resolveCssId(id: string, base: string): Promise<string>
47+
}
48+
49+
export async function createResolver(opts: ResolverOptions) {
50+
let fileSystem = new CachedInputFileSystem(fs, 4000)
51+
52+
// Load PnP API if requested
53+
let pnpApi = opts.pnp ? await loadPnPApi(opts.root) : null
54+
55+
let esmResolver = ResolverFactory.createResolver({
56+
fileSystem,
57+
extensions: ['.mjs', '.js'],
58+
mainFields: ['module'],
59+
conditionNames: ['node', 'import'],
60+
pnpApi,
61+
})
62+
63+
let cjsResolver = ResolverFactory.createResolver({
64+
fileSystem,
65+
extensions: ['.cjs', '.js'],
66+
mainFields: ['main'],
67+
conditionNames: ['node', 'require'],
68+
pnpApi,
69+
})
70+
71+
let cssResolver = ResolverFactory.createResolver({
72+
fileSystem,
73+
extensions: ['.css'],
74+
mainFields: ['style'],
75+
conditionNames: ['style'],
76+
pnpApi,
77+
})
78+
79+
async function resolveId(
80+
resolver: BaseResolver,
81+
base: string,
82+
id: string,
83+
): Promise<string | false> {
84+
// Windows-specific path tweaks
85+
if (path.sep === '\\') {
86+
// Absolute path on Network Share
87+
if (id.startsWith('\\\\')) return id
88+
89+
// Absolute path on Network Share (normalized)
90+
if (id.startsWith('//')) return id
91+
92+
// Relative to Network Share (normalized)
93+
if (base.startsWith('//')) base = `\\\\${base.slice(2)}`
94+
}
95+
96+
return new Promise((resolve, reject) => {
97+
resolver.resolve({}, base, id, {}, (err, res) => {
98+
if (err) {
99+
reject(err)
100+
} else {
101+
resolve(res)
102+
}
103+
})
104+
})
105+
}
106+
107+
async function resolveJsId(id: string, base: string): Promise<string> {
108+
try {
109+
return (await resolveId(esmResolver, base, id)) || id
110+
} catch {
111+
return (await resolveId(cjsResolver, base, id)) || id
112+
}
113+
}
114+
115+
async function resolveCssId(id: string, base: string): Promise<string> {
116+
return (await resolveId(cssResolver, base, id)) || id
117+
}
118+
119+
async function setupPnP() {
120+
pnpApi?.setup()
121+
}
122+
123+
return {
124+
setupPnP,
125+
resolveJsId,
126+
resolveCssId,
127+
}
128+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import findUp from 'find-up'
2+
import * as path from 'node:path'
3+
4+
export interface PnpApi {
5+
setup(): void
6+
resolveToUnqualified: (arg0: string, arg1: string, arg2: object) => null | string
7+
}
8+
9+
const cache = new Map<string, PnpApi | null>()
10+
11+
/**
12+
* Loads the PnP API from the given directory if found.
13+
* We intentionally do not call `setup` to monkey patch global APIs
14+
* TODO: Verify that we can get by without doing this
15+
*/
16+
export async function loadPnPApi(root: string): Promise<PnpApi | null> {
17+
let existing = cache.get(root)
18+
if (existing !== undefined) {
19+
return existing
20+
}
21+
22+
let pnpPath = await findPnPApi(path.normalize(root))
23+
if (!pnpPath) {
24+
cache.set(root, null)
25+
return null
26+
}
27+
28+
let mod = await import(pnpPath)
29+
let api = mod.default
30+
cache.set(root, api)
31+
return api
32+
}
33+
34+
/**
35+
* Locates the PnP API file for a given directory
36+
*/
37+
async function findPnPApi(root: string): Promise<string | null> {
38+
let names = ['.pnp.js', '.pnp.cjs']
39+
40+
for (let name of names) {
41+
let filepath = path.join(root, name)
42+
43+
if (await findUp.exists(filepath)) {
44+
return filepath
45+
}
46+
}
47+
48+
return null
49+
}

0 commit comments

Comments
 (0)