Skip to content

Add support for TypeScript config path mapping in CSS files #1106

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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