Skip to content

Commit

Permalink
Add support for TypeScript config path mapping in CSS files (#1106)
Browse files Browse the repository at this point in the history
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`
  • Loading branch information
thecrypticace authored Jan 21, 2025
1 parent 9dfa540 commit e871fc9
Show file tree
Hide file tree
Showing 44 changed files with 1,341 additions and 295 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"@npmcli/package-json": "^5.0.0",
"@types/culori": "^2.1.0",
"culori": "^4.0.1",
"esbuild": "^0.20.2",
"esbuild": "^0.24.0",
"minimist": "^1.2.8",
"prettier": "^3.2.5",
"semver": "^7.5.4"
Expand Down
4 changes: 3 additions & 1 deletion packages/tailwindcss-language-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"dlv": "1.1.3",
"dset": "3.1.2",
"enhanced-resolve": "^5.16.1",
"esbuild": "^0.20.2",
"esbuild": "^0.24.0",
"fast-glob": "3.2.4",
"find-up": "5.0.0",
"jiti": "^2.3.3",
Expand All @@ -81,6 +81,8 @@
"rimraf": "3.0.2",
"stack-trace": "0.0.10",
"tailwindcss": "3.4.4",
"tsconfck": "^3.1.4",
"tsconfig-paths": "^4.2.0",
"typescript": "5.3.3",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.4.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import path from 'node:path'
import * as path from 'node:path'
import type { AtRule, Plugin } from 'postcss'
import { normalizePath } from '../utils'

Expand Down
75 changes: 56 additions & 19 deletions packages/tailwindcss-language-server/src/css/resolve-css-imports.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,62 @@
import * as fs from 'node:fs/promises'
import postcss from 'postcss'
import postcssImport from 'postcss-import'
import { createResolver } from '../util/resolve'
import { fixRelativePaths } from './fix-relative-paths'
import { Resolver } from '../resolver'

const resolver = createResolver({
extensions: ['.css'],
mainFields: ['style'],
conditionNames: ['style'],
})

const resolveImports = postcss([
postcssImport({
resolve: (id, base) => resolveCssFrom(base, id),
}),
fixRelativePaths(),
])

export function resolveCssImports() {
return resolveImports
}
export function resolveCssImports({
resolver,
loose = false,
}: {
resolver: Resolver
loose?: boolean
}) {
return postcss([
// Hoist imports to the top of the file
{
postcssPlugin: 'hoist-at-import',
Once(root, { result }) {
if (!loose) return

let hoist: postcss.AtRule[] = []
let seenImportsAfterOtherNodes = false

for (let node of root.nodes) {
if (node.type === 'atrule' && (node.name === 'import' || node.name === 'charset')) {
hoist.push(node)
} else if (hoist.length > 0 && (node.type === 'atrule' || node.type === 'rule')) {
seenImportsAfterOtherNodes = true
}
}

root.prepend(hoist)

if (!seenImportsAfterOtherNodes) return

console.log(
`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.`,
)
},
},

postcssImport({
async resolve(id, base) {
try {
return await resolver.resolveCssId(id, base)
} catch (e) {
// TODO: Need to test this on windows
return `/virtual:missing/${id}`
}
},

load(filepath) {
if (filepath.startsWith('/virtual:missing/')) {
return Promise.resolve('')
}

export function resolveCssFrom(base: string, id: string) {
return resolver.resolveSync({}, base, id) || id
return fs.readFile(filepath, 'utf-8')
},
}),
fixRelativePaths(),
])
}
1 change: 1 addition & 0 deletions packages/tailwindcss-language-server/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export const CONFIG_GLOB =
'{tailwind,tailwind.config,tailwind.*.config,tailwind.config.*}.{js,cjs,ts,mjs,mts,cts}'
export const PACKAGE_LOCK_GLOB = '{package-lock.json,yarn.lock,pnpm-lock.yaml}'
export const CSS_GLOB = '*.{css,scss,sass,less,pcss}'
export const TSCONFIG_GLOB = '{tsconfig,tsconfig.*,jsconfig,jsconfig.*}.json'
2 changes: 1 addition & 1 deletion packages/tailwindcss-language-server/src/lib/hook.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Adapted from: https://github.com/elastic/require-in-the-middle
*/
import Module from 'module'
import Module from 'node:module'
import plugins from './plugins'

let bundledModules = {
Expand Down
2 changes: 1 addition & 1 deletion packages/tailwindcss-language-server/src/oxide.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lte } from 'tailwindcss-language-service/src/util/semver'
import { lte } from '@tailwindcss/language-service/src/util/semver'

// This covers the Oxide API from v4.0.0-alpha.1 to v4.0.0-alpha.18
declare namespace OxideV1 {
Expand Down
34 changes: 33 additions & 1 deletion packages/tailwindcss-language-server/src/project-locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'node:path'
import { ProjectLocator } from './project-locator'
import { URL, fileURLToPath } from 'url'
import { Settings } from '@tailwindcss/language-service/src/util/state'
import { createResolver } from './resolver'

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

test.concurrent(fixture, async ({ expect }) => {
let locator = new ProjectLocator(fixturePath, settings)
let resolver = await createResolver({ root: fixturePath, tsconfig: true })
let locator = new ProjectLocator(fixturePath, settings, resolver)
let projects = await locator.search()

for (let i = 0; i < Math.max(projects.length, details.length); i++) {
Expand Down Expand Up @@ -195,3 +197,33 @@ testFixture('v4/custom-source', [
],
},
])

testFixture('v4/missing-files', [
//
{
config: 'app.css',
content: ['{URL}/package.json'],
},
])

testFixture('v4/path-mappings', [
//
{
config: 'app.css',
content: [
'{URL}/package.json',
'{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}',
'{URL}/src/a/my-config.ts',
'{URL}/src/a/my-plugin.ts',
'{URL}/tsconfig.json',
],
},
])

testFixture('v4/invalid-import-order', [
//
{
config: 'tailwind.css',
content: ['{URL}/package.json'],
},
])
69 changes: 49 additions & 20 deletions packages/tailwindcss-language-server/src/project-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ import type { Settings } from '@tailwindcss/language-service/src/util/state'
import { CONFIG_GLOB, CSS_GLOB } from './lib/constants'
import { readCssFile } from './util/css'
import { Graph } from './graph'
import type { AtRule, Message } from 'postcss'
import { type DocumentSelector, DocumentSelectorPriority } from './projects'
import { CacheMap } from './cache-map'
import { getPackageRoot } from './util/get-package-root'
import { resolveFrom } from './util/resolveFrom'
import type { Resolver } from './resolver'
import { type Feature, supportedFeatures } from '@tailwindcss/language-service/src/features'
import { extractSourceDirectives, resolveCssImports } from './css'
import { normalizeDriveLetter, normalizePath, pathToFileURL } from './utils'
Expand Down Expand Up @@ -46,6 +45,7 @@ export class ProjectLocator {
constructor(
private base: string,
private settings: Settings,
private resolver: Resolver,
) {}

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

console.log(JSON.stringify({ tailwind }))
console.log(
JSON.stringify({
tailwind,
path: config.path,
}),
)

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

// - Content patterns from config
for await (let selector of contentSelectorsFromConfig(config, tailwind.features)) {
for await (let selector of contentSelectorsFromConfig(
config,
tailwind.features,
this.resolver,
)) {
selectors.push(selector)
}

Expand Down Expand Up @@ -321,8 +330,6 @@ export class ProjectLocator {
// we'll verify after config resolution.
let configPath = file.configPathInCss()
if (configPath) {
// We don't need the content for this file anymore
file.content = null
file.configs.push(
configs.remember(configPath, () => ({
// A CSS file produced a JS config file
Expand All @@ -340,7 +347,7 @@ export class ProjectLocator {
}

// Resolve imports in all the CSS files
await Promise.all(imports.map((file) => file.resolveImports()))
await Promise.all(imports.map((file) => file.resolveImports(this.resolver)))

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

private async detectTailwindVersion(config: ConfigEntry) {
try {
let metadataPath = resolveFrom(path.dirname(config.path), 'tailwindcss/package.json')
let metadataPath = await this.resolver.resolveJsId(
'tailwindcss/package.json',
path.dirname(config.path),
)
let { version } = require(metadataPath)
let features = supportedFeatures(version)

Expand All @@ -445,14 +455,14 @@ export class ProjectLocator {
function contentSelectorsFromConfig(
entry: ConfigEntry,
features: Feature[],
actualConfig?: any,
resolver: Resolver,
): AsyncIterable<DocumentSelector> {
if (entry.type === 'css') {
return contentSelectorsFromCssConfig(entry)
return contentSelectorsFromCssConfig(entry, resolver)
}

if (entry.type === 'js') {
return contentSelectorsFromJsConfig(entry, features, actualConfig)
return contentSelectorsFromJsConfig(entry, features)
}
}

Expand Down Expand Up @@ -497,7 +507,10 @@ async function* contentSelectorsFromJsConfig(
}
}

async function* contentSelectorsFromCssConfig(entry: ConfigEntry): AsyncIterable<DocumentSelector> {
async function* contentSelectorsFromCssConfig(
entry: ConfigEntry,
resolver: Resolver,
): AsyncIterable<DocumentSelector> {
let auto = false
for (let item of entry.content) {
if (item.kind === 'file') {
Expand All @@ -513,7 +526,12 @@ async function* contentSelectorsFromCssConfig(entry: ConfigEntry): AsyncIterable
// other entries should have sources.
let sources = entry.entries.flatMap((entry) => entry.sources)

for await (let pattern of detectContentFiles(entry.packageRoot, entry.path, sources)) {
for await (let pattern of detectContentFiles(
entry.packageRoot,
entry.path,
sources,
resolver,
)) {
yield {
pattern,
priority: DocumentSelectorPriority.CONTENT_FILE,
Expand All @@ -527,11 +545,15 @@ async function* detectContentFiles(
base: string,
inputFile: string,
sources: string[],
resolver: Resolver,
): AsyncIterable<string> {
try {
let oxidePath = resolveFrom(path.dirname(base), '@tailwindcss/oxide')
let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', path.dirname(base))
oxidePath = pathToFileURL(oxidePath).href
let oxidePackageJsonPath = resolveFrom(path.dirname(base), '@tailwindcss/oxide/package.json')
let oxidePackageJsonPath = await resolver.resolveJsId(
'@tailwindcss/oxide/package.json',
path.dirname(base),
)
let oxidePackageJson = JSON.parse(await fs.readFile(oxidePackageJsonPath, 'utf8'))

let result = await oxide.scan({
Expand Down Expand Up @@ -594,19 +616,26 @@ class FileEntry {
}
}

async resolveImports() {
async resolveImports(resolver: Resolver) {
try {
let result = await resolveCssImports().process(this.content, { from: this.path })
let result = await resolveCssImports({ resolver, loose: true }).process(this.content, {
from: this.path,
})
let deps = result.messages.filter((msg) => msg.type === 'dependency')

deps = deps.filter((msg) => {
return !msg.file.startsWith('/virtual:missing/')
})

// Record entries for each of the dependencies
this.deps = deps.map((msg) => new FileEntry('css', normalizePath(msg.file)))

// Replace the file content with the processed CSS
this.content = result.css
} catch {
// TODO: Errors here should be surfaced in tests and possibly the user in
// `trace` logs or something like that
} catch (err) {
console.debug(`Unable to resolve imports for ${this.path}.`)
console.debug(`This may result in failure to locate Tailwind CSS projects.`)
console.error(err)
}
}

Expand Down
Loading

0 comments on commit e871fc9

Please sign in to comment.