Skip to content

Commit 3fc767b

Browse files
Add support for @source (#1030)
This PR does multiple things: 1. It adds support for the `@plugin` and `@source` directive in the CSS language. 2. We will now scan V4 config files for eventually defined `@source` rules and respect them similarly how we handled custom content paths in V3. 3. Add support for the the Oxide API coming in Alpha 20 (tailwindlabs/tailwindcss#14187) For detecting the right content, we load the Oxide API installed in the user's Tailwind project. To do this in a backward compatible way, we now also load the `package.json` file of the installed Oxide version and support previous alpha releases for a limited amount of time. --------- Co-authored-by: Jordan Pittman <[email protected]>
1 parent 3f79be2 commit 3fc767b

31 files changed

+582
-130
lines changed

packages/tailwindcss-language-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"@tailwindcss/forms": "0.5.3",
3737
"@tailwindcss/language-service": "workspace:*",
3838
"@tailwindcss/line-clamp": "0.4.2",
39-
"@tailwindcss/oxide": "^4.0.0-alpha.16",
39+
"@tailwindcss/oxide": "^4.0.0-alpha.19",
4040
"@tailwindcss/typography": "0.5.7",
4141
"@types/color-name": "^1.1.3",
4242
"@types/culori": "^2.1.0",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Plugin } from 'postcss'
2+
3+
export function extractSourceDirectives(sources: string[]): Plugin {
4+
return {
5+
postcssPlugin: 'extract-at-rules',
6+
AtRule: {
7+
source: ({ params }) => {
8+
if (params[0] !== '"' && params[0] !== "'") return
9+
sources.push(params.slice(1, -1))
10+
},
11+
},
12+
}
13+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import path from 'node:path'
2+
import type { AtRule, Plugin } from 'postcss'
3+
import { normalizePath } from '../utils'
4+
5+
const SINGLE_QUOTE = "'"
6+
const DOUBLE_QUOTE = '"'
7+
8+
export function fixRelativePaths(): Plugin {
9+
// Retain a list of touched at-rules to avoid infinite loops
10+
let touched: WeakSet<AtRule> = new WeakSet()
11+
12+
function fixRelativePath(atRule: AtRule) {
13+
if (touched.has(atRule)) return
14+
15+
let rootPath = atRule.root().source?.input.file
16+
if (!rootPath) return
17+
18+
let inputFilePath = atRule.source?.input.file
19+
if (!inputFilePath) return
20+
21+
let value = atRule.params[0]
22+
23+
let quote =
24+
value[0] === DOUBLE_QUOTE && value[value.length - 1] === DOUBLE_QUOTE
25+
? DOUBLE_QUOTE
26+
: value[0] === SINGLE_QUOTE && value[value.length - 1] === SINGLE_QUOTE
27+
? SINGLE_QUOTE
28+
: null
29+
30+
if (!quote) return
31+
32+
let glob = atRule.params.slice(1, -1)
33+
34+
// Handle eventual negative rules. We only support one level of negation.
35+
let negativePrefix = ''
36+
if (glob.startsWith('!')) {
37+
glob = glob.slice(1)
38+
negativePrefix = '!'
39+
}
40+
41+
// We only want to rewrite relative paths.
42+
if (!glob.startsWith('./') && !glob.startsWith('../')) {
43+
return
44+
}
45+
46+
let absoluteGlob = path.posix.join(normalizePath(path.dirname(inputFilePath)), glob)
47+
let absoluteRootPosixPath = path.posix.dirname(normalizePath(rootPath))
48+
49+
let relative = path.posix.relative(absoluteRootPosixPath, absoluteGlob)
50+
51+
// If the path points to a file in the same directory, `path.relative` will
52+
// remove the leading `./` and we need to add it back in order to still
53+
// consider the path relative
54+
if (!relative.startsWith('.')) {
55+
relative = './' + relative
56+
}
57+
58+
atRule.params = quote + negativePrefix + relative + quote
59+
touched.add(atRule)
60+
}
61+
62+
return {
63+
postcssPlugin: 'tailwindcss-postcss-fix-relative-paths',
64+
AtRule: {
65+
source: fixRelativePath,
66+
plugin: fixRelativePath,
67+
},
68+
}
69+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './resolve-css-imports'
2+
export * from './extract-source-directives'

packages/tailwindcss-language-server/src/resolve-css-imports.ts renamed to packages/tailwindcss-language-server/src/css/resolve-css-imports.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import postcss from 'postcss'
22
import postcssImport from 'postcss-import'
3-
import { createResolver } from './util/resolve'
3+
import { createResolver } from '../util/resolve'
4+
import { fixRelativePaths } from './fix-relative-paths'
45

56
const resolver = createResolver({
67
extensions: ['.css'],
@@ -15,6 +16,7 @@ const resolveImports = postcss([
1516
return paths ? paths : id
1617
},
1718
}),
19+
fixRelativePaths(),
1820
])
1921

2022
export function resolveCssImports() {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ async function validateTextDocument(textDocument: TextDocument): Promise<void> {
381381
.filter((diagnostic) => {
382382
if (
383383
diagnostic.code === 'unknownAtRules' &&
384-
/Unknown at rule @(tailwind|apply|config|theme)/.test(diagnostic.message)
384+
/Unknown at rule @(tailwind|apply|config|theme|plugin|source)/.test(diagnostic.message)
385385
) {
386386
return false
387387
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { lte } from 'tailwindcss-language-service/src/util/semver'
2+
3+
// This covers the Oxide API from v4.0.0-alpha.1 to v4.0.0-alpha.18
4+
declare namespace OxideV1 {
5+
interface GlobEntry {
6+
base: string
7+
glob: string
8+
}
9+
10+
interface ScanOptions {
11+
base: string
12+
globs?: boolean
13+
}
14+
15+
interface ScanResult {
16+
files: Array<string>
17+
globs: Array<GlobEntry>
18+
}
19+
}
20+
21+
// This covers the Oxide API from v4.0.0-alpha.19
22+
declare namespace OxideV2 {
23+
interface GlobEntry {
24+
base: string
25+
pattern: string
26+
}
27+
28+
interface ScanOptions {
29+
base: string
30+
sources: Array<GlobEntry>
31+
}
32+
33+
interface ScanResult {
34+
files: Array<string>
35+
globs: Array<GlobEntry>
36+
}
37+
}
38+
39+
// This covers the Oxide API from v4.0.0-alpha.20+
40+
declare namespace OxideV3 {
41+
interface GlobEntry {
42+
base: string
43+
pattern: string
44+
}
45+
46+
interface ScannerOptions {
47+
detectSources?: { base: string }
48+
sources: Array<GlobEntry>
49+
}
50+
51+
interface ScannerConstructor {
52+
new (options: ScannerOptions): Scanner
53+
}
54+
55+
interface Scanner {
56+
files: Array<string>
57+
globs: Array<GlobEntry>
58+
}
59+
}
60+
61+
interface Oxide {
62+
scanDir?(options: OxideV1.ScanOptions): OxideV1.ScanResult
63+
scanDir?(options: OxideV2.ScanOptions): OxideV2.ScanResult
64+
Scanner?: OxideV3.ScannerConstructor
65+
}
66+
67+
async function loadOxideAtPath(id: string): Promise<Oxide | null> {
68+
let oxide = await import(id)
69+
70+
// This is a much older, unsupported version of Oxide before v4.0.0-alpha.1
71+
if (!oxide.scanDir) return null
72+
73+
return oxide
74+
}
75+
76+
interface GlobEntry {
77+
base: string
78+
pattern: string
79+
}
80+
81+
interface ScanOptions {
82+
oxidePath: string
83+
oxideVersion: string
84+
basePath: string
85+
sources: Array<GlobEntry>
86+
}
87+
88+
interface ScanResult {
89+
files: Array<string>
90+
globs: Array<GlobEntry>
91+
}
92+
93+
/**
94+
* This is a helper function that leverages the Oxide API to scan a directory
95+
* and a set of sources and turn them into files and globs.
96+
*
97+
* Because the Oxide API has changed over time this function presents a unified
98+
* interface that works with all versions of the Oxide API but the results may
99+
* be different depending on the version of Oxide that is being used.
100+
*
101+
* For example, the `sources` option is ignored before v4.0.0-alpha.19.
102+
*/
103+
export async function scan(options: ScanOptions): Promise<ScanResult | null> {
104+
const oxide = await loadOxideAtPath(options.oxidePath)
105+
if (!oxide) return null
106+
107+
// V1
108+
if (lte(options.oxideVersion, '4.0.0-alpha.18')) {
109+
let result = oxide.scanDir?.({
110+
base: options.basePath,
111+
globs: true,
112+
})
113+
114+
return {
115+
files: result.files,
116+
globs: result.globs.map((g) => ({ base: g.base, pattern: g.glob })),
117+
}
118+
}
119+
120+
// V2
121+
if (lte(options.oxideVersion, '4.0.0-alpha.19')) {
122+
let result = oxide.scanDir({
123+
base: options.basePath,
124+
sources: options.sources,
125+
})
126+
127+
return {
128+
files: result.files,
129+
globs: result.globs,
130+
}
131+
}
132+
133+
// V3
134+
let scanner = new oxide.Scanner({
135+
detectSources: { base: options.basePath },
136+
sources: options.sources,
137+
})
138+
139+
return {
140+
files: scanner.files,
141+
globs: scanner.globs,
142+
}
143+
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,29 @@ testFixture('v4/auto-content', [
141141
],
142142
},
143143
])
144+
145+
testFixture('v4/custom-source', [
146+
//
147+
{
148+
config: 'admin/app.css',
149+
content: [
150+
'{URL}/admin/**/*.{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}',
151+
'{URL}/admin/**/*.bin',
152+
'{URL}/admin/foo.bin',
153+
'{URL}/package.json',
154+
'{URL}/shared.html',
155+
'{URL}/web/**/*.{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}',
156+
],
157+
},
158+
{
159+
config: 'web/app.css',
160+
content: [
161+
'{URL}/admin/**/*.{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}',
162+
'{URL}/web/*.bin',
163+
'{URL}/web/bar.bin',
164+
'{URL}/package.json',
165+
'{URL}/shared.html',
166+
'{URL}/web/**/*.{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}',
167+
],
168+
},
169+
])

0 commit comments

Comments
 (0)