Skip to content

Commit e871fc9

Browse files
Add support for TypeScript config path mapping in CSS files (#1106)
This is a work in progress with a handful of things that need improvements regarding stylesheet loading / editing when in a v4 project. Fixes #1103 Fixes #1100 - [x] Recover from missing stylesheet imports - [ ] Recover from unparsable stylesheet imports (not sure if possible) - [x] Read path aliases from tsconfig.json - [x] Log errors from analyzing CSS files during the resolve imports stage (the cause of #1100) - [x] Watch for tsconfig.json file changes and reload when they change (or maybe only when the list of seen `paths` do) - [x] Consider path aliases when doing project discovery - [x] Consider path aliases when loading the design system - [x] Allow in `@import` - [x] Allow in `@reference` - [x] Allow in `@config` - [x] Allow in `@plugin` - [ ] Consider path aliases when producing diagnostics - [ ] Allow in `@import` - [ ] Allow in `@reference` - [x] Allow in `@config` (nothing to do here) - [x] Allow in `@plugin` (nothing to do here) - [ ] Consider path aliases when generating document links - [ ] Allow in `@import` (no upstream support; non-trivial) - [ ] Allow in `@reference` (no upstream support in `@import`; non-trivial) - [x] Allow in `@config` - [x] Allow in `@plugin` - [ ] Consider path aliases when offering completions - [ ] Allow in `@import` (no upstream support; non-trivial) - [ ] Allow in `@reference` (no upstream support in `@import`; non-trivial) - [x] Allow in `@config` - [x] Allow in `@plugin`
1 parent 9dfa540 commit e871fc9

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1341
-295
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"@npmcli/package-json": "^5.0.0",
66
"@types/culori": "^2.1.0",
77
"culori": "^4.0.1",
8-
"esbuild": "^0.20.2",
8+
"esbuild": "^0.24.0",
99
"minimist": "^1.2.8",
1010
"prettier": "^3.2.5",
1111
"semver": "^7.5.4"

packages/tailwindcss-language-server/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
"dlv": "1.1.3",
6464
"dset": "3.1.2",
6565
"enhanced-resolve": "^5.16.1",
66-
"esbuild": "^0.20.2",
66+
"esbuild": "^0.24.0",
6767
"fast-glob": "3.2.4",
6868
"find-up": "5.0.0",
6969
"jiti": "^2.3.3",
@@ -81,6 +81,8 @@
8181
"rimraf": "3.0.2",
8282
"stack-trace": "0.0.10",
8383
"tailwindcss": "3.4.4",
84+
"tsconfck": "^3.1.4",
85+
"tsconfig-paths": "^4.2.0",
8486
"typescript": "5.3.3",
8587
"vite-tsconfig-paths": "^4.3.1",
8688
"vitest": "^1.4.0",

packages/tailwindcss-language-server/src/css/fix-relative-paths.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import path from 'node:path'
1+
import * as path from 'node:path'
22
import type { AtRule, Plugin } from 'postcss'
33
import { normalizePath } from '../utils'
44

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,62 @@
1+
import * as fs from 'node:fs/promises'
12
import postcss from 'postcss'
23
import postcssImport from 'postcss-import'
3-
import { createResolver } from '../util/resolve'
44
import { fixRelativePaths } from './fix-relative-paths'
5+
import { Resolver } from '../resolver'
56

6-
const resolver = createResolver({
7-
extensions: ['.css'],
8-
mainFields: ['style'],
9-
conditionNames: ['style'],
10-
})
11-
12-
const resolveImports = postcss([
13-
postcssImport({
14-
resolve: (id, base) => resolveCssFrom(base, id),
15-
}),
16-
fixRelativePaths(),
17-
])
18-
19-
export function resolveCssImports() {
20-
return resolveImports
21-
}
7+
export function resolveCssImports({
8+
resolver,
9+
loose = false,
10+
}: {
11+
resolver: Resolver
12+
loose?: boolean
13+
}) {
14+
return postcss([
15+
// Hoist imports to the top of the file
16+
{
17+
postcssPlugin: 'hoist-at-import',
18+
Once(root, { result }) {
19+
if (!loose) return
20+
21+
let hoist: postcss.AtRule[] = []
22+
let seenImportsAfterOtherNodes = false
23+
24+
for (let node of root.nodes) {
25+
if (node.type === 'atrule' && (node.name === 'import' || node.name === 'charset')) {
26+
hoist.push(node)
27+
} else if (hoist.length > 0 && (node.type === 'atrule' || node.type === 'rule')) {
28+
seenImportsAfterOtherNodes = true
29+
}
30+
}
31+
32+
root.prepend(hoist)
33+
34+
if (!seenImportsAfterOtherNodes) return
35+
36+
console.log(
37+
`hoist-at-import: The file '${result.opts.from}' contains @import rules after other at rules. This is invalid CSS and may cause problems with your build.`,
38+
)
39+
},
40+
},
41+
42+
postcssImport({
43+
async resolve(id, base) {
44+
try {
45+
return await resolver.resolveCssId(id, base)
46+
} catch (e) {
47+
// TODO: Need to test this on windows
48+
return `/virtual:missing/${id}`
49+
}
50+
},
51+
52+
load(filepath) {
53+
if (filepath.startsWith('/virtual:missing/')) {
54+
return Promise.resolve('')
55+
}
2256

23-
export function resolveCssFrom(base: string, id: string) {
24-
return resolver.resolveSync({}, base, id) || id
57+
return fs.readFile(filepath, 'utf-8')
58+
},
59+
}),
60+
fixRelativePaths(),
61+
])
2562
}

packages/tailwindcss-language-server/src/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export const CONFIG_GLOB =
22
'{tailwind,tailwind.config,tailwind.*.config,tailwind.config.*}.{js,cjs,ts,mjs,mts,cts}'
33
export const PACKAGE_LOCK_GLOB = '{package-lock.json,yarn.lock,pnpm-lock.yaml}'
44
export const CSS_GLOB = '*.{css,scss,sass,less,pcss}'
5+
export const TSCONFIG_GLOB = '{tsconfig,tsconfig.*,jsconfig,jsconfig.*}.json'

packages/tailwindcss-language-server/src/lib/hook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Adapted from: https://github.com/elastic/require-in-the-middle
33
*/
4-
import Module from 'module'
4+
import Module from 'node:module'
55
import plugins from './plugins'
66

77
let bundledModules = {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { lte } from 'tailwindcss-language-service/src/util/semver'
1+
import { lte } from '@tailwindcss/language-service/src/util/semver'
22

33
// This covers the Oxide API from v4.0.0-alpha.1 to v4.0.0-alpha.18
44
declare namespace OxideV1 {

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as path from 'node:path'
33
import { ProjectLocator } from './project-locator'
44
import { URL, fileURLToPath } from 'url'
55
import { Settings } from '@tailwindcss/language-service/src/util/state'
6+
import { createResolver } from './resolver'
67

78
let settings: Settings = {
89
tailwindCSS: {
@@ -17,7 +18,8 @@ function testFixture(fixture: string, details: any[]) {
1718
let fixturePath = `${fixtures}/${fixture}`
1819

1920
test.concurrent(fixture, async ({ expect }) => {
20-
let locator = new ProjectLocator(fixturePath, settings)
21+
let resolver = await createResolver({ root: fixturePath, tsconfig: true })
22+
let locator = new ProjectLocator(fixturePath, settings, resolver)
2123
let projects = await locator.search()
2224

2325
for (let i = 0; i < Math.max(projects.length, details.length); i++) {
@@ -195,3 +197,33 @@ testFixture('v4/custom-source', [
195197
],
196198
},
197199
])
200+
201+
testFixture('v4/missing-files', [
202+
//
203+
{
204+
config: 'app.css',
205+
content: ['{URL}/package.json'],
206+
},
207+
])
208+
209+
testFixture('v4/path-mappings', [
210+
//
211+
{
212+
config: 'app.css',
213+
content: [
214+
'{URL}/package.json',
215+
'{URL}/src/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}',
216+
'{URL}/src/a/my-config.ts',
217+
'{URL}/src/a/my-plugin.ts',
218+
'{URL}/tsconfig.json',
219+
],
220+
},
221+
])
222+
223+
testFixture('v4/invalid-import-order', [
224+
//
225+
{
226+
config: 'tailwind.css',
227+
content: ['{URL}/package.json'],
228+
},
229+
])

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

Lines changed: 49 additions & 20 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 type { 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'
@@ -46,6 +45,7 @@ export class ProjectLocator {
4645
constructor(
4746
private base: string,
4847
private settings: Settings,
48+
private resolver: Resolver,
4949
) {}
5050

5151
async search(): Promise<ProjectConfig[]> {
@@ -130,7 +130,12 @@ export class ProjectLocator {
130130
private async createProject(config: ConfigEntry): Promise<ProjectConfig | null> {
131131
let tailwind = await this.detectTailwindVersion(config)
132132

133-
console.log(JSON.stringify({ tailwind }))
133+
console.log(
134+
JSON.stringify({
135+
tailwind,
136+
path: config.path,
137+
}),
138+
)
134139

135140
// A JS/TS config file was loaded from an `@config` directive in a CSS file
136141
// This is only relevant for v3 projects so we'll do some feature detection
@@ -191,7 +196,11 @@ export class ProjectLocator {
191196
})
192197

193198
// - Content patterns from config
194-
for await (let selector of contentSelectorsFromConfig(config, tailwind.features)) {
199+
for await (let selector of contentSelectorsFromConfig(
200+
config,
201+
tailwind.features,
202+
this.resolver,
203+
)) {
195204
selectors.push(selector)
196205
}
197206

@@ -321,8 +330,6 @@ export class ProjectLocator {
321330
// we'll verify after config resolution.
322331
let configPath = file.configPathInCss()
323332
if (configPath) {
324-
// We don't need the content for this file anymore
325-
file.content = null
326333
file.configs.push(
327334
configs.remember(configPath, () => ({
328335
// A CSS file produced a JS config file
@@ -340,7 +347,7 @@ export class ProjectLocator {
340347
}
341348

342349
// Resolve imports in all the CSS files
343-
await Promise.all(imports.map((file) => file.resolveImports()))
350+
await Promise.all(imports.map((file) => file.resolveImports(this.resolver)))
344351

345352
// Resolve real paths for all the files in the CSS import graph
346353
await Promise.all(imports.map((file) => file.resolveRealpaths()))
@@ -418,7 +425,10 @@ export class ProjectLocator {
418425

419426
private async detectTailwindVersion(config: ConfigEntry) {
420427
try {
421-
let metadataPath = resolveFrom(path.dirname(config.path), 'tailwindcss/package.json')
428+
let metadataPath = await this.resolver.resolveJsId(
429+
'tailwindcss/package.json',
430+
path.dirname(config.path),
431+
)
422432
let { version } = require(metadataPath)
423433
let features = supportedFeatures(version)
424434

@@ -445,14 +455,14 @@ export class ProjectLocator {
445455
function contentSelectorsFromConfig(
446456
entry: ConfigEntry,
447457
features: Feature[],
448-
actualConfig?: any,
458+
resolver: Resolver,
449459
): AsyncIterable<DocumentSelector> {
450460
if (entry.type === 'css') {
451-
return contentSelectorsFromCssConfig(entry)
461+
return contentSelectorsFromCssConfig(entry, resolver)
452462
}
453463

454464
if (entry.type === 'js') {
455-
return contentSelectorsFromJsConfig(entry, features, actualConfig)
465+
return contentSelectorsFromJsConfig(entry, features)
456466
}
457467
}
458468

@@ -497,7 +507,10 @@ async function* contentSelectorsFromJsConfig(
497507
}
498508
}
499509

500-
async function* contentSelectorsFromCssConfig(entry: ConfigEntry): AsyncIterable<DocumentSelector> {
510+
async function* contentSelectorsFromCssConfig(
511+
entry: ConfigEntry,
512+
resolver: Resolver,
513+
): AsyncIterable<DocumentSelector> {
501514
let auto = false
502515
for (let item of entry.content) {
503516
if (item.kind === 'file') {
@@ -513,7 +526,12 @@ async function* contentSelectorsFromCssConfig(entry: ConfigEntry): AsyncIterable
513526
// other entries should have sources.
514527
let sources = entry.entries.flatMap((entry) => entry.sources)
515528

516-
for await (let pattern of detectContentFiles(entry.packageRoot, entry.path, sources)) {
529+
for await (let pattern of detectContentFiles(
530+
entry.packageRoot,
531+
entry.path,
532+
sources,
533+
resolver,
534+
)) {
517535
yield {
518536
pattern,
519537
priority: DocumentSelectorPriority.CONTENT_FILE,
@@ -527,11 +545,15 @@ async function* detectContentFiles(
527545
base: string,
528546
inputFile: string,
529547
sources: string[],
548+
resolver: Resolver,
530549
): AsyncIterable<string> {
531550
try {
532-
let oxidePath = resolveFrom(path.dirname(base), '@tailwindcss/oxide')
551+
let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', path.dirname(base))
533552
oxidePath = pathToFileURL(oxidePath).href
534-
let oxidePackageJsonPath = resolveFrom(path.dirname(base), '@tailwindcss/oxide/package.json')
553+
let oxidePackageJsonPath = await resolver.resolveJsId(
554+
'@tailwindcss/oxide/package.json',
555+
path.dirname(base),
556+
)
535557
let oxidePackageJson = JSON.parse(await fs.readFile(oxidePackageJsonPath, 'utf8'))
536558

537559
let result = await oxide.scan({
@@ -594,19 +616,26 @@ class FileEntry {
594616
}
595617
}
596618

597-
async resolveImports() {
619+
async resolveImports(resolver: Resolver) {
598620
try {
599-
let result = await resolveCssImports().process(this.content, { from: this.path })
621+
let result = await resolveCssImports({ resolver, loose: true }).process(this.content, {
622+
from: this.path,
623+
})
600624
let deps = result.messages.filter((msg) => msg.type === 'dependency')
601625

626+
deps = deps.filter((msg) => {
627+
return !msg.file.startsWith('/virtual:missing/')
628+
})
629+
602630
// Record entries for each of the dependencies
603631
this.deps = deps.map((msg) => new FileEntry('css', normalizePath(msg.file)))
604632

605633
// Replace the file content with the processed CSS
606634
this.content = result.css
607-
} catch {
608-
// TODO: Errors here should be surfaced in tests and possibly the user in
609-
// `trace` logs or something like that
635+
} catch (err) {
636+
console.debug(`Unable to resolve imports for ${this.path}.`)
637+
console.debug(`This may result in failure to locate Tailwind CSS projects.`)
638+
console.error(err)
610639
}
611640
}
612641

0 commit comments

Comments
 (0)