Resolve @import in core (#14446)

This PR brings `@import` resolution into Tailwind CSS core. This means
that our clients (PostCSS, Vite, and CLI) no longer need to depend on
`postcss` and `postcss-import` to resolve `@import`. Furthermore this
simplifies the handling of relative paths for `@source`, `@plugin`, or
`@config` in transitive CSS files (where the relative root should always
be relative to the CSS file that contains the directive). This PR also
fixes a plugin resolution bug where non-relative imports (e.g. directly
importing node modules like `@plugin '@tailwindcss/typography';`) would
not work in CSS files that are based in a different npm package.

### Resolving `@import`

The core of the `@import` resolution is inside
`packages/tailwindcss/src/at-import.ts`. There, to keep things
performant, we do a two-step process to resolve imports. Imagine the
following input CSS file:

```css
@import "tailwindcss/theme.css";
@import "tailwindcss/utilities.css";
```

Since our AST walks are synchronous, we will do a first traversal where
we start a loading request for each `@import` directive. Once all loads
are started, we will await the promise and do a second walk where we
actually replace the AST nodes with their resolved stylesheets. All of
this is recursive, so that `@import`-ed files can again `@import` other
files.

The core `@import` resolver also includes extensive test cases for
[various combinations of media query and supports conditionals as well
als layered
imports](https://developer.mozilla.org/en-US/docs/Web/CSS/@import).

When the same file is imported multiple times, the AST nodes are
duplicated but duplicate I/O is avoided on a per-file basis, so this
will only load one file, but include the `@theme` rules twice:

```css
@import "tailwindcss/theme.css";
@import "tailwindcss/theme.css";
```

### Adding a new `context` node to the AST

One limitation we had when working with the `postcss-import` plugin was
the need to do an additional traversal to rewrite relative `@source`,
`@plugin`, and `@config` directives. This was needed because we want
these paths to be relative to the CSS file that defines the directive
but when flattening a CSS file, this information is no longer part of
the stringifed CSS representation. We worked around this by rewriting
the content of these directives to be relative to the input CSS file,
which resulted in added complexity and caused a lot of issues with
Windows paths in the beginning.

Now that we are doing the `@import` resolution in core, we can use a
different data structure to persist this information. This PR adds a new
`context` node so that we can store arbitrary context like this inside
the Ast directly. This allows us to share information with the sub tree
_while doing the Ast walk_.

Here's an example of how the new `context` node can be used to share
information with subtrees:

```ts
const ast = [
  rule('.foo', [decl('color', 'red')]),
  context({ value: 'a' }, [
    rule('.bar', [
      decl('color', 'blue'),
      context({ value: 'b' }, [
        rule('.baz', [decl('color', 'green')]),
      ]),
    ]),
  ]),
]

walk(ast, (node, { context }) => {
  if (node.kind !== 'declaration') return
  switch (node.value) {
    case 'red':   assert(context.value === undefined)
    case 'blue':  assert(context.value === 'a')
    case 'green': assert(context.value === 'b')
  }
})
```

In core, we use this new Ast node specifically to persist the `base`
path of the current CSS file. We put the input CSS file `base` at the
root of the Ast and then overwrite the `base` on every `@import`
substitution.

### Removing the dependency on `postcss-import`

Now that we support `@import` resolution in core, our clients no longer
need a dependency on `postcss-import`. Furthermore, most dependencies
also don't need to know about `postcss` at all anymore (except the
PostCSS client, of course!).

This also means that our workaround for rewriting `@source`, the
`postcss-fix-relative-paths` plugin, can now go away as a shared
dependency between all of our clients. Note that we still have it for
the PostCSS plugin only, where it's possible that users already have
`postcss-import` running _before_ the `@tailwindcss/postcss` plugin.

Here's an example of the changes to the dependencies for our Vite client
 :

<img width="854" alt="Screenshot 2024-09-19 at 16 59 45"
src="https://github.com/user-attachments/assets/ae1f9d5f-d93a-4de9-9244-61af3aff1237">

### Performance

Since our Vite and CLI clients now no longer need to use `postcss` at
all, we have also measured a significant improvement to the initial
build times. For a small test setup that contains only a hand full of
files (nothing super-complex), we measured an improvement in the
**3.5x** range:

<img width="1334" alt="Screenshot 2024-09-19 at 14 52 49"
src="https://github.com/user-attachments/assets/06071fb0-7f2a-4de6-8ec8-f202d2cc78e5">

The code for this is in the commit history if you want to reproduce the
results. The test was based on the Vite client.

### Caveats

One thing to note is that we previously relied on finding specific
symbols in the input CSS to _bail out of Tailwind processing
completely_. E.g. if a file does not contain a `@tailwind` or `@apply`
directive, it can never be a Tailwind file.

Since we no longer have a string representation of the flattened CSS
file, we can no longer do this check. However, the current
implementation was already inconsistent with differences on the allowed
symbol list between our clients. Ideally, Tailwind CSS should figure out
wether a CSS file is a Tailwind CSS file. This, however, is left as an
improvement for a future API since it goes hand-in-hand with our planned
API changes for the core `tailwindcss` package.

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Philipp Spiess 2024-09-23 17:05:55 +02:00 committed by GitHub
parent 6d43a8be99
commit 79794744a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 2705 additions and 1572 deletions

View File

@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Disallow negative bare values in core utilities and variants ([#14453](https://github.com/tailwindlabs/tailwindcss/pull/14453))
- Preserve explicit shadow color when overriding shadow size ([#14458](https://github.com/tailwindlabs/tailwindcss/pull/14458))
- Preserve explicit transition duration and timing function when overriding transition property ([#14490](https://github.com/tailwindlabs/tailwindcss/pull/14490))
- Change the implementation for `@import` resolution to speed up initial builds ([#14446](https://github.com/tailwindlabs/tailwindcss/pull/14446))
## [4.0.0-alpha.24] - 2024-09-11

View File

@ -79,6 +79,86 @@ test(
},
)
test(
'production build with `postcss-import` (string)',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"dependencies": {
"postcss": "^8",
"postcss-cli": "^10",
"postcss-import": "^16",
"tailwindcss": "workspace:^",
"@tailwindcss/postcss": "workspace:^"
}
}
`,
'project-a/postcss.config.js': js`
module.exports = {
plugins: {
'postcss-import': {},
'@tailwindcss/postcss': {},
},
}
`,
'project-a/index.html': html`
<div
class="underline 2xl:font-bold hocus:underline inverted:flex"
></div>
`,
'project-a/plugin.js': js`
module.exports = function ({ addVariant }) {
addVariant('inverted', '@media (inverted-colors: inverted)')
addVariant('hocus', ['&:focus', '&:hover'])
}
`,
'project-a/tailwind.config.js': js`
module.exports = {
content: ['../project-b/src/**/*.js'],
}
`,
'project-a/src/index.css': css`
@import 'tailwindcss/utilities';
@config '../tailwind.config.js';
@source '../../project-b/src/**/*.html';
@plugin '../plugin.js';
`,
'project-a/src/index.js': js`
const className = "content-['a/src/index.js']"
module.exports = { className }
`,
'project-b/src/index.html': html`
<div class="flex" />
`,
'project-b/src/index.js': js`
const className = "content-['b/src/index.js']"
module.exports = { className }
`,
},
},
async ({ root, fs, exec }) => {
await exec('pnpm postcss src/index.css --output dist/out.css', {
cwd: path.join(root, 'project-a'),
})
await fs.expectFileToContain('project-a/dist/out.css', [
candidate`underline`,
candidate`flex`,
candidate`content-['a/src/index.js']`,
candidate`content-['b/src/index.js']`,
candidate`inverted:flex`,
candidate`hocus:underline`,
])
},
)
test(
'production build (ESM)',
{

View File

@ -36,12 +36,6 @@
"lightningcss": "catalog:",
"mri": "^1.2.0",
"picocolors": "^1.0.1",
"postcss-import": "^16.1.0",
"postcss": "^8.4.41",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@types/postcss-import": "^14.0.3",
"internal-postcss-fix-relative-paths": "workspace:^"
}
}

View File

@ -2,13 +2,10 @@ import watcher from '@parcel/watcher'
import { compile } from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner, type ChangedContent } from '@tailwindcss/oxide'
import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import { existsSync, readFileSync } from 'node:fs'
import { existsSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import postcss from 'postcss'
import atImport from 'postcss-import'
import type { Arg, Result } from '../../utils/args'
import { Disposables } from '../../utils/disposables'
import {
@ -19,7 +16,6 @@ import {
println,
relative,
} from '../../utils/renderer'
import { resolveCssId } from '../../utils/resolve'
import { drainStdin, outputFile } from './utils'
const css = String.raw
@ -83,17 +79,13 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let start = process.hrtime.bigint()
// Resolve the input
let [input, cssImportPaths] = await handleImports(
args['--input']
? args['--input'] === '-'
? await drainStdin()
: await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`,
args['--input'] ?? base,
)
let input = args['--input']
? args['--input'] === '-'
? await drainStdin()
: await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`
let previous = {
css: '',
@ -128,7 +120,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let inputFile = args['--input'] && args['--input'] !== '-' ? args['--input'] : process.cwd()
let inputBasePath = path.dirname(path.resolve(inputFile))
let fullRebuildPaths: string[] = cssImportPaths.slice()
let fullRebuildPaths: string[] = []
function createCompiler(css: string) {
return compile(css, {
@ -143,12 +135,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let compiler = await createCompiler(input)
let scanner = new Scanner({
detectSources: { base },
sources: compiler.globs.map(({ origin, pattern }) => ({
// Ensure the glob is relative to the input CSS file or the config file
// where it is specified.
base: origin ? path.dirname(path.resolve(inputBasePath, origin)) : inputBasePath,
pattern,
})),
sources: compiler.globs,
})
// Watch for changes
@ -196,17 +183,16 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Clear all watchers
cleanupWatchers()
// Collect the new `input` and `cssImportPaths`.
;[input, cssImportPaths] = await handleImports(
args['--input']
? await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`,
args['--input'] ?? base,
)
// Read the new `input`.
let input = args['--input']
? args['--input'] === '-'
? await drainStdin()
: await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`
clearRequireCache(resolvedFullRebuildPaths)
fullRebuildPaths = cssImportPaths.slice()
fullRebuildPaths = []
// Create a new compiler, given the new `input`
compiler = await createCompiler(input)
@ -214,12 +200,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Re-scan the directory to get the new `candidates`
scanner = new Scanner({
detectSources: { base },
sources: compiler.globs.map(({ origin, pattern }) => ({
// Ensure the glob is relative to the input CSS file or the
// config file where it is specified.
base: origin ? path.dirname(path.resolve(inputBasePath, origin)) : inputBasePath,
pattern,
})),
sources: compiler.globs,
})
// Scan the directory for candidates
@ -367,51 +348,6 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
}
}
function handleImports(
input: string,
file: string,
): [css: string, paths: string[]] | Promise<[css: string, paths: string[]]> {
// TODO: Should we implement this ourselves instead of relying on PostCSS?
//
// Relevant specification:
// - CSS Import Resolve: https://csstools.github.io/css-import-resolve/
if (!input.includes('@import')) {
return [input, [file]]
}
return postcss()
.use(
atImport({
resolve(id, basedir) {
let resolved = resolveCssId(id, basedir)
if (!resolved) {
throw new Error(`Could not resolve ${id} from ${basedir}`)
}
return resolved
},
load(id) {
// We need to synchronously read the file here because when bundled
// with bun, some of the ids might resolve to files inside the bun
// embedded files root which can only be read by `node:fs` and not
// `node:fs/promises`.
return readFileSync(id, 'utf-8')
},
}),
)
.use(fixRelativePathsPlugin())
.process(input, { from: file })
.then((result) => [
result.css,
// Use `result.messages` to get the imported files. This also includes the
// current file itself.
[file].concat(
result.messages.filter((msg) => msg.type === 'dependency').map((msg) => msg.file),
),
])
}
function optimizeCss(
input: string,
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},

View File

@ -5,5 +5,4 @@ export default defineConfig({
clean: true,
minify: true,
entry: ['src/index.ts'],
noExternal: ['internal-postcss-fix-relative-paths'],
})

View File

@ -40,6 +40,7 @@
"tailwindcss": "workspace:^"
},
"dependencies": {
"enhanced-resolve": "^5.17.1",
"jiti": "^2.0.0-beta.3"
}
}

View File

@ -1,5 +1,8 @@
import EnhancedResolve from 'enhanced-resolve'
import { createJiti, type Jiti } from 'jiti'
import path from 'node:path'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import path, { dirname, extname } from 'node:path'
import { pathToFileURL } from 'node:url'
import { compile as _compile } from 'tailwindcss'
import { getModuleDependencies } from './get-module-dependencies'
@ -9,12 +12,25 @@ export async function compile(
{ base, onDependency }: { base: string; onDependency: (path: string) => void },
) {
return await _compile(css, {
loadPlugin: async (pluginPath) => {
if (pluginPath[0] !== '.') {
return importModule(pluginPath).then((m) => m.default ?? m)
base,
async loadModule(id, base) {
if (id[0] !== '.') {
let resolvedPath = await resolveJsId(id, base)
if (!resolvedPath) {
throw new Error(`Could not resolve '${id}' from '${base}'`)
}
let module = await importModule(pathToFileURL(resolvedPath).href)
return {
base: dirname(resolvedPath),
module: module.default ?? module,
}
}
let resolvedPath = path.resolve(base, pluginPath)
let resolvedPath = await resolveJsId(id, base)
if (!resolvedPath) {
throw new Error(`Could not resolve '${id}' from '${base}'`)
}
let [module, moduleDependencies] = await Promise.all([
importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()),
getModuleDependencies(resolvedPath),
@ -24,25 +40,31 @@ export async function compile(
for (let file of moduleDependencies) {
onDependency(file)
}
return module.default ?? module
return {
base: dirname(resolvedPath),
module: module.default ?? module,
}
},
loadConfig: async (configPath) => {
if (configPath[0] !== '.') {
return importModule(configPath).then((m) => m.default ?? m)
async loadStylesheet(id, basedir) {
let resolvedPath = await resolveCssId(id, basedir)
if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${basedir}'`)
if (typeof globalThis.__tw_readFile === 'function') {
let file = await globalThis.__tw_readFile(resolvedPath, 'utf-8')
if (file) {
return {
base: path.dirname(resolvedPath),
content: file,
}
}
}
let resolvedPath = path.resolve(base, configPath)
let [module, moduleDependencies] = await Promise.all([
importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()),
getModuleDependencies(resolvedPath),
])
onDependency(resolvedPath)
for (let file of moduleDependencies) {
onDependency(file)
let file = await fsPromises.readFile(resolvedPath, 'utf-8')
return {
base: path.dirname(resolvedPath),
content: file,
}
return module.default ?? module
},
})
}
@ -62,3 +84,58 @@ async function importModule(path: string): Promise<any> {
throw error
}
}
const cssResolver = EnhancedResolve.ResolverFactory.createResolver({
fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000),
useSyncFileSystemCalls: true,
extensions: ['.css'],
mainFields: ['style'],
conditionNames: ['style'],
})
async function resolveCssId(id: string, base: string): Promise<string | false | undefined> {
if (typeof globalThis.__tw_resolve === 'function') {
let resolved = globalThis.__tw_resolve(id, base)
if (resolved) {
return Promise.resolve(resolved)
}
}
// CSS imports that do not have a dir prefix are considered relative. Since
// the resolver does not account for this, we need to do a first pass with an
// assumed relative import by prefixing `./${path}`. We don't have to do this
// when the path starts with a `.` or when the path has no extension (at which
// case it's likely an npm package and not a relative stylesheet).
let skipRelativeCheck = extname(id) === '' || id.startsWith('.')
if (!skipRelativeCheck) {
try {
let dotResolved = await runResolver(cssResolver, `./${id}`, base)
if (!dotResolved) throw new Error()
return dotResolved
} catch {}
}
return runResolver(cssResolver, id, base)
}
const jsResolver = EnhancedResolve.ResolverFactory.createResolver({
fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000),
useSyncFileSystemCalls: true,
})
function resolveJsId(id: string, base: string): Promise<string | false | undefined> {
return runResolver(jsResolver, id, base)
}
function runResolver(
resolver: EnhancedResolve.Resolver,
id: string,
base: string,
): Promise<string | false | undefined> {
return new Promise((resolve, reject) =>
resolver.resolve({}, base, id, {}, (err, result) => {
if (err) return reject(err)
resolve(result)
}),
)
}

View File

@ -1,6 +1,7 @@
import * as Module from 'node:module'
import { pathToFileURL } from 'node:url'
export * from './compile'
export * from './normalize-path'
// In Bun, ESM modules will also populate `require.cache`, so the module hook is
// not necessary.

View File

@ -1,6 +1,7 @@
import * as Module from 'node:module'
import { pathToFileURL } from 'node:url'
export * from './compile'
export * from './normalize-path'
// In Bun, ESM modules will also populate `require.cache`, so the module hook is
// not necessary.

View File

@ -33,14 +33,13 @@
"@tailwindcss/node": "workspace:^",
"@tailwindcss/oxide": "workspace:^",
"lightningcss": "catalog:",
"postcss-import": "^16.1.0",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/postcss-import": "^14.0.3",
"postcss": "^8.4.41",
"internal-example-plugin": "workspace:*",
"internal-postcss-fix-relative-paths": "workspace:^"
"postcss-import": "^16.1.0",
"@types/postcss-import": "14.0.3",
"internal-example-plugin": "workspace:*"
}
}

View File

@ -2,11 +2,10 @@ import { compile } from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import fs from 'fs'
import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import path from 'path'
import postcss, { AtRule, type AcceptedPlugin, type PluginCreator } from 'postcss'
import postcssImport from 'postcss-import'
import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss'
import fixRelativePathsPlugin from './postcss-fix-relative-paths'
/**
* A Map that can generate default values for keys that don't exist.
@ -51,30 +50,16 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
}
})
let hasApply: boolean, hasTailwind: boolean
return {
postcssPlugin: '@tailwindcss/postcss',
plugins: [
// We need to run `postcss-import` first to handle `@import` rules.
postcssImport(),
// We need to handle the case where `postcss-import` might have run before the Tailwind CSS
// plugin is run. In this case, we need to manually fix relative paths before processing it
// in core.
fixRelativePathsPlugin(),
{
postcssPlugin: 'tailwindcss',
Once() {
// Reset some state between builds
hasApply = false
hasTailwind = false
},
AtRule(rule: AtRule) {
if (rule.name === 'apply') {
hasApply = true
} else if (rule.name === 'tailwind') {
hasApply = true
hasTailwind = true
}
},
async OnceExit(root, { result }) {
let inputFile = result.opts.from ?? ''
let context = cache.get(inputFile)
@ -133,23 +118,14 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
}
}
// Do nothing if neither `@tailwind` nor `@apply` is used
if (!hasTailwind && !hasApply) return
let css = ''
// Look for candidates used to generate the CSS
let scanner = new Scanner({
detectSources: { base },
sources: context.compiler.globs.map(({ origin, pattern }) => ({
// Ensure the glob is relative to the input CSS file or the config
// file where it is specified.
base: origin ? path.dirname(path.resolve(inputBasePath, origin)) : inputBasePath,
pattern,
})),
sources: context.compiler.globs,
})
//
let candidates = scanner.scan()
// Add all found files as direct dependencies
@ -177,10 +153,8 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
if (rebuildStrategy === 'full') {
context.compiler = await createCompiler()
css = context.compiler.build(hasTailwind ? candidates : [])
} else if (rebuildStrategy === 'incremental') {
css = context.compiler.build!(candidates)
}
css = context.compiler.build(candidates)
// Replace CSS
if (css !== context.css && optimize) {

View File

@ -1,12 +1,10 @@
import { normalizePath } from '@tailwindcss/node'
import path from 'node:path'
import type { AtRule, Plugin } from 'postcss'
import { normalizePath } from './normalize-path'
const SINGLE_QUOTE = "'"
const DOUBLE_QUOTE = '"'
export { normalizePath }
export default function fixRelativePathsPlugin(): Plugin {
// Retain a list of touched at-rules to avoid infinite loops
let touched: WeakSet<AtRule> = new WeakSet()

View File

@ -7,7 +7,6 @@ export default defineConfig([
cjsInterop: true,
dts: true,
entry: ['src/index.ts'],
noExternal: ['internal-postcss-fix-relative-paths'],
},
{
format: ['cjs'],
@ -15,6 +14,5 @@ export default defineConfig([
cjsInterop: true,
dts: true,
entry: ['src/index.cts'],
noExternal: ['internal-postcss-fix-relative-paths'],
},
])

View File

@ -1,3 +1,4 @@
import fs from 'node:fs'
import { createRequire } from 'node:module'
import packageJson from 'tailwindcss/package.json'
@ -42,5 +43,14 @@ globalThis.__tw_resolve = (id, baseDir) => {
}
}
globalThis.__tw_version = packageJson.version
globalThis.__tw_readFile = async (path, encoding) => {
// When reading a file from the `$bunfs`, we need to use the synchronous
// `readFileSync` API
let isEmbeddedFileBase = path.includes('/$bunfs/root') || path.includes(':/~BUN/root')
if (!isEmbeddedFileBase) {
return
}
return fs.readFileSync(path, encoding)
}
await import('../../@tailwindcss-cli/src/index.ts')

View File

@ -5,3 +5,6 @@ declare module '*.css' {
declare var __tw_version: string | undefined
declare var __tw_resolve: undefined | ((id: string, base?: string) => string | false)
declare var __tw_readFile:
| undefined
| ((path: string, encoding: BufferEncoding) => Promise<string | undefined>)

View File

@ -31,14 +31,10 @@
"@tailwindcss/node": "workspace:^",
"@tailwindcss/oxide": "workspace:^",
"lightningcss": "catalog:",
"postcss": "^8.4.41",
"postcss-import": "^16.1.0",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/postcss-import": "^14.0.3",
"internal-postcss-fix-relative-paths": "workspace:^",
"vite": "catalog:"
},
"peerDependencies": {

View File

@ -1,13 +1,9 @@
import { compile } from '@tailwindcss/node'
import { compile, normalizePath } from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import fixRelativePathsPlugin, { normalizePath } from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import fs from 'node:fs/promises'
import path from 'path'
import postcss from 'postcss'
import postcssImport from 'postcss-import'
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
export default function tailwindcss(): Plugin[] {
@ -269,18 +265,6 @@ function isPotentialCssRootFile(id: string) {
return isCssFile
}
function isCssRootFile(content: string) {
return (
content.includes('@tailwind') ||
content.includes('@config') ||
content.includes('@plugin') ||
content.includes('@apply') ||
content.includes('@theme') ||
content.includes('@variant') ||
content.includes('@utility')
)
}
function optimizeCss(
input: string,
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},
@ -378,30 +362,7 @@ class Root {
clearRequireCache(Array.from(this.dependencies))
this.dependencies = new Set([idToPath(inputPath)])
let postcssCompiled = await postcss([
postcssImport({
load: (path) => {
this.dependencies.add(path)
addWatchFile(path)
return fs.readFile(path, 'utf8')
},
}),
fixRelativePathsPlugin(),
]).process(content, {
from: inputPath,
to: inputPath,
})
let css = postcssCompiled.css
// This is done inside the Root#generate() method so that we can later use
// information from the Tailwind compiler to determine if the file is a
// CSS root (necessary because we will probably inline the `@import`
// resolution at some point).
if (!isCssRootFile(css)) {
return false
}
this.compiler = await compile(css, {
this.compiler = await compile(content, {
base: inputBase,
onDependency: (path) => {
addWatchFile(path)
@ -410,12 +371,7 @@ class Root {
})
this.scanner = new Scanner({
sources: this.compiler.globs.map(({ origin, pattern }) => ({
// Ensure the glob is relative to the input CSS file or the config
// file where it is specified.
base: origin ? path.dirname(path.resolve(inputBase, origin)) : inputBase,
pattern,
})),
sources: this.compiler.globs,
})
}

View File

@ -6,5 +6,4 @@ export default defineConfig({
minify: true,
dts: true,
entry: ['src/index.ts'],
noExternal: ['internal-postcss-fix-relative-paths'],
})

View File

@ -1,27 +0,0 @@
{
"name": "internal-postcss-fix-relative-paths",
"version": "0.0.0",
"private": true,
"scripts": {
"lint": "tsc --noEmit",
"build": "tsup-node ./src/index.ts --format cjs,esm --dts --cjsInterop --splitting --minify --clean",
"dev": "pnpm run build -- --watch"
},
"files": [
"dist/"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"dependencies": {},
"devDependencies": {
"@types/node": "catalog:",
"@types/postcss-import": "^14.0.3",
"postcss": "8.4.41",
"postcss-import": "^16.1.0"
}
}

View File

@ -1,3 +0,0 @@
{
"extends": "../tsconfig.base.json",
}

View File

@ -89,6 +89,7 @@
"devDependencies": {
"@tailwindcss/oxide": "workspace:^",
"@types/node": "catalog:",
"lightningcss": "catalog:"
"lightningcss": "catalog:",
"dedent": "1.5.3"
}
}

View File

@ -1,5 +1,5 @@
import { expect, it } from 'vitest'
import { toCss } from './ast'
import { context, decl, rule, toCss, walk } from './ast'
import * as CSS from './css-parser'
it('should pretty print an AST', () => {
@ -13,3 +13,54 @@ it('should pretty print an AST', () => {
"
`)
})
it('allows the placement of context nodes', () => {
const ast = [
rule('.foo', [decl('color', 'red')]),
context({ context: 'a' }, [
rule('.bar', [
decl('color', 'blue'),
context({ context: 'b' }, [
//
rule('.baz', [decl('color', 'green')]),
]),
]),
]),
]
let redContext
let blueContext
let greenContext
walk(ast, (node, { context }) => {
if (node.kind !== 'declaration') return
switch (node.value) {
case 'red':
redContext = context
break
case 'blue':
blueContext = context
break
case 'green':
greenContext = context
break
}
})
expect(redContext).toEqual({})
expect(blueContext).toEqual({ context: 'a' })
expect(greenContext).toEqual({ context: 'b' })
expect(toCss(ast)).toMatchInlineSnapshot(`
".foo {
color: red;
}
.bar {
color: blue;
.baz {
color: green;
}
}
"
`)
})

View File

@ -16,7 +16,13 @@ export type Comment = {
value: string
}
export type AstNode = Rule | Declaration | Comment
export type Context = {
kind: 'context'
context: Record<string, string>
nodes: AstNode[]
}
export type AstNode = Rule | Declaration | Comment | Context
export function rule(selector: string, nodes: AstNode[]): Rule {
return {
@ -42,6 +48,14 @@ export function comment(value: string): Comment {
}
}
export function context(context: Record<string, string>, nodes: AstNode[]): Context {
return {
kind: 'context',
context,
nodes,
}
}
export enum WalkAction {
/** Continue walking, which is the default */
Continue,
@ -60,12 +74,23 @@ export function walk(
utils: {
parent: AstNode | null
replaceWith(newNode: AstNode | AstNode[]): void
context: Record<string, string>
},
) => void | WalkAction,
parent: AstNode | null = null,
context: Record<string, string> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
// We want context nodes to be transparent in walks. This means that
// whenever we encounter one, we immediately walk through its children and
// furthermore we also don't update the parent.
if (node.kind === 'context') {
walk(node.nodes, visit, parent, { ...context, ...node.context })
continue
}
let status =
visit(node, {
parent,
@ -76,6 +101,7 @@ export function walk(
// will process this position (containing the replaced node) again.
i--
},
context,
}) ?? WalkAction.Continue
// Stop the walk entirely
@ -85,7 +111,7 @@ export function walk(
if (status === WalkAction.Skip) continue
if (node.kind === 'rule') {
walk(node.nodes, visit, node)
walk(node.nodes, visit, node, context)
}
}
}
@ -171,6 +197,13 @@ export function toCss(ast: AstNode[]) {
css += `${indent}/*${node.value}*/\n`
}
// Context Node
else if (node.kind === 'context') {
for (let child of node.nodes) {
css += stringify(child, depth)
}
}
// Declaration
else if (node.property !== '--tw-sort' && node.value !== undefined && node.value !== null) {
css += `${indent}${node.property}: ${node.value}${node.important ? '!important' : ''};\n`

View File

@ -0,0 +1,572 @@
import { expect, test, vi } from 'vitest'
import type { Plugin } from './compat/plugin-api'
import { compile, type Config } from './index'
import plugin from './plugin'
import { optimizeCss } from './test-utils/run'
let css = String.raw
async function run(
css: string,
{
loadStylesheet = () => Promise.reject(new Error('Unexpected stylesheet')),
loadModule = () => Promise.reject(new Error('Unexpected module')),
candidates = [],
optimize = true,
}: {
loadStylesheet?: (id: string, base: string) => Promise<{ content: string; base: string }>
loadModule?: (
id: string,
base: string,
resourceHint: 'plugin' | 'config',
) => Promise<{ module: Config | Plugin; base: string }>
candidates?: string[]
optimize?: boolean
},
) {
let compiler = await compile(css, { base: '/root', loadStylesheet, loadModule })
let result = compiler.build(candidates)
return optimize ? optimizeCss(result) : result
}
test('can resolve relative @imports', async () => {
let loadStylesheet = async (id: string, base: string) => {
expect(base).toBe('/root')
expect(id).toBe('./foo/bar.css')
return {
content: css`
.foo {
color: red;
}
`,
base: '/root/foo',
}
}
await expect(
run(
css`
@import './foo/bar.css';
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
".foo {
color: red;
}
"
`)
})
test('can recursively resolve relative @imports', async () => {
let loadStylesheet = async (id: string, base: string) => {
if (base === '/root' && id === './foo/bar.css') {
return {
content: css`
@import './bar/baz.css';
`,
base: '/root/foo',
}
} else if (base === '/root/foo' && id === './bar/baz.css') {
return {
content: css`
.baz {
color: blue;
}
`,
base: '/root/foo/bar',
}
}
throw new Error(`Unexpected import: ${id}`)
}
await expect(
run(
css`
@import './foo/bar.css';
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
".baz {
color: #00f;
}
"
`)
})
let exampleCSS = css`
a {
color: red;
}
`
let loadStylesheet = async (id: string) => {
if (!id.endsWith('example.css')) throw new Error('Unexpected import: ' + id)
return {
content: exampleCSS,
base: '/root',
}
}
test('extracts path from @import nodes', async () => {
await expect(
run(
css`
@import 'example.css';
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"a {
color: red;
}
"
`)
await expect(
run(
css`
@import './example.css';
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"a {
color: red;
}
"
`)
await expect(
run(
css`
@import '/example.css';
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"a {
color: red;
}
"
`)
})
test('url() imports are passed-through', async () => {
await expect(
run(
css`
@import url('example.css');
`,
{ loadStylesheet: () => Promise.reject(new Error('Unexpected stylesheet')), optimize: false },
),
).resolves.toMatchInlineSnapshot(`
"@import url('example.css');
"
`)
await expect(
run(
css`
@import url('./example.css');
`,
{ loadStylesheet: () => Promise.reject(new Error('Unexpected stylesheet')), optimize: false },
),
).resolves.toMatchInlineSnapshot(`
"@import url('./example.css');
"
`)
await expect(
run(
css`
@import url('/example.css');
`,
{ loadStylesheet: () => Promise.reject(new Error('Unexpected stylesheet')), optimize: false },
),
).resolves.toMatchInlineSnapshot(`
"@import url('/example.css');
"
`)
await expect(
run(
css`
@import url(example.css);
`,
{ loadStylesheet: () => Promise.reject(new Error('Unexpected stylesheet')), optimize: false },
),
).resolves.toMatchInlineSnapshot(`
"@import url(example.css);
"
`)
await expect(
run(
css`
@import url(./example.css);
`,
{ loadStylesheet: () => Promise.reject(new Error('Unexpected stylesheet')), optimize: false },
),
).resolves.toMatchInlineSnapshot(`
"@import url(./example.css);
"
`)
await expect(
run(
css`
@import url(/example.css);
`,
{ loadStylesheet: () => Promise.reject(new Error('Unexpected stylesheet')), optimize: false },
),
).resolves.toMatchInlineSnapshot(`
"@import url(/example.css);
"
`)
})
test('handles case-insensitive @import directive', async () => {
await expect(
run(
css`
@import 'example.css';
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"a {
color: red;
}
"
`)
})
test('@media', async () => {
await expect(
run(
css`
@import 'example.css' print;
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"@media print {
a {
color: red;
}
}
"
`)
await expect(
run(
css`
@import 'example.css' print, screen;
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"@media print, screen {
a {
color: red;
}
}
"
`)
await expect(
run(
css`
@import 'example.css' screen and (orientation: landscape);
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"@media screen and (orientation: landscape) {
a {
color: red;
}
}
"
`)
await expect(
run(
css`
@import 'example.css' foo(bar);
`,
{ loadStylesheet, optimize: false },
),
).resolves.toMatchInlineSnapshot(`
"@media foo(bar) {
a {
color: red;
}
}
"
`)
})
test('@supports', async () => {
await expect(
run(
css`
@import 'example.css' supports(display: grid);
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"@supports (display: grid) {
a {
color: red;
}
}
"
`)
await expect(
run(
css`
@import 'example.css' supports(display: grid) screen and (max-width: 400px);
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"@supports (display: grid) {
@media screen and (width <= 400px) {
a {
color: red;
}
}
}
"
`)
await expect(
run(
css`
@import 'example.css' supports((not (display: grid)) and (display: flex)) screen and
(max-width: 400px);
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"@supports (not (display: grid)) and (display: flex) {
@media screen and (width <= 400px) {
a {
color: red;
}
}
}
"
`)
await expect(
run(
// prettier-ignore
css`
@import 'example.css'
supports((selector(h2 > p)) and (font-tech(color-COLRv1)));
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"@supports selector(h2 > p) and font-tech(color-COLRv1) {
a {
color: red;
}
}
"
`)
})
test('@layer', async () => {
await expect(
run(
css`
@import 'example.css' layer(utilities);
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"@layer utilities {
a {
color: red;
}
}
"
`)
await expect(
run(
css`
@import 'example.css' layer();
`,
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"@layer {
a {
color: red;
}
}
"
`)
})
test('supports theme(reference) imports', async () => {
expect(
run(
css`
@tailwind utilities;
@import 'example.css' theme(reference);
`,
{
loadStylesheet: () =>
Promise.resolve({
content: css`
@theme {
--color-red-500: red;
}
`,
base: '',
}),
candidates: ['text-red-500'],
},
),
).resolves.toMatchInlineSnapshot(`
".text-red-500 {
color: var(--color-red-500, red);
}
"
`)
})
test('updates the base when loading modules inside nested files', async () => {
let loadStylesheet = () =>
Promise.resolve({
content: css`
@config './nested-config.js';
@plugin './nested-plugin.js';
`,
base: '/root/foo',
})
let loadModule = vi.fn().mockResolvedValue({ base: '', module: () => {} })
expect(
(
await run(
css`
@import './foo/bar.css';
@config './root-config.js';
@plugin './root-plugin.js';
`,
{ loadStylesheet, loadModule },
)
).trim(),
).toBe('')
expect(loadModule).toHaveBeenNthCalledWith(1, './nested-config.js', '/root/foo', 'config')
expect(loadModule).toHaveBeenNthCalledWith(2, './root-config.js', '/root', 'config')
expect(loadModule).toHaveBeenNthCalledWith(3, './nested-plugin.js', '/root/foo', 'plugin')
expect(loadModule).toHaveBeenNthCalledWith(4, './root-plugin.js', '/root', 'plugin')
})
test('emits the right base for @source directives inside nested files', async () => {
let loadStylesheet = () =>
Promise.resolve({
content: css`
@source './nested/**/*.css';
`,
base: '/root/foo',
})
let compiler = await compile(
css`
@import './foo/bar.css';
@source './root/**/*.css';
`,
{ base: '/root', loadStylesheet },
)
expect(compiler.globs).toEqual([
{ pattern: './nested/**/*.css', base: '/root/foo' },
{ pattern: './root/**/*.css', base: '/root' },
])
})
test('emits the right base for @source found inside JS configs and plugins from nested imports', async () => {
let loadStylesheet = () =>
Promise.resolve({
content: css`
@config './nested-config.js';
@plugin './nested-plugin.js';
`,
base: '/root/foo',
})
let loadModule = vi.fn().mockImplementation((id: string) => {
let base = id.includes('nested') ? '/root/foo' : '/root'
if (id.includes('config')) {
let glob = id.includes('nested') ? './nested-config/*.html' : './root-config/*.html'
let pluginGlob = id.includes('nested')
? './nested-config-plugin/*.html'
: './root-config-plugin/*.html'
return {
module: {
content: [glob],
plugins: [plugin(() => {}, { content: [pluginGlob] })],
} satisfies Config,
base: base + '-config',
}
} else {
let glob = id.includes('nested') ? './nested-plugin/*.html' : './root-plugin/*.html'
return {
module: plugin(() => {}, { content: [glob] }),
base: base + '-plugin',
}
}
})
let compiler = await compile(
css`
@import './foo/bar.css';
@config './root-config.js';
@plugin './root-plugin.js';
`,
{ base: '/root', loadStylesheet, loadModule },
)
expect(compiler.globs).toEqual([
{ pattern: './nested-plugin/*.html', base: '/root/foo-plugin' },
{ pattern: './root-plugin/*.html', base: '/root-plugin' },
{ pattern: './nested-config-plugin/*.html', base: '/root/foo-config' },
{ pattern: './nested-config/*.html', base: '/root/foo-config' },
{ pattern: './root-config-plugin/*.html', base: '/root-config' },
{ pattern: './root-config/*.html', base: '/root-config' },
])
})
test('it crashes when inside a cycle', async () => {
let loadStylesheet = () =>
Promise.resolve({
content: css`
@import 'foo.css';
`,
base: '/root',
})
expect(
run(
css`
@import 'foo.css';
`,
{ loadStylesheet },
),
).rejects.toMatchInlineSnapshot(
`[Error: Exceeded maximum recursion depth while resolving \`foo.css\` in \`/root\`)]`,
)
})

View File

@ -0,0 +1,147 @@
import { context, rule, walk, WalkAction, type AstNode } from './ast'
import * as CSS from './css-parser'
import * as ValueParser from './value-parser'
type LoadStylesheet = (id: string, basedir: string) => Promise<{ base: string; content: string }>
export async function substituteAtImports(
ast: AstNode[],
base: string,
loadStylesheet: LoadStylesheet,
recurseCount = 0,
) {
let promises: Promise<void>[] = []
walk(ast, (node, { replaceWith }) => {
if (
node.kind === 'rule' &&
node.selector[0] === '@' &&
node.selector.toLowerCase().startsWith('@import ')
) {
try {
let { uri, layer, media, supports } = parseImportParams(
ValueParser.parse(node.selector.slice(8)),
)
// Skip importing data or remote URIs
if (uri.startsWith('data:')) return
if (uri.startsWith('http://') || uri.startsWith('https://')) return
let contextNode = context({}, [])
promises.push(
(async () => {
// Since we do not have fully resolved paths in core, we can't reliably detect circular
// imports. Instead, we try to limit the recursion depth to a number that is too large
// to be reached in practice.
if (recurseCount > 100) {
throw new Error(
`Exceeded maximum recursion depth while resolving \`${uri}\` in \`${base}\`)`,
)
}
const loaded = await loadStylesheet(uri, base)
let ast = CSS.parse(loaded.content)
await substituteAtImports(ast, loaded.base, loadStylesheet, recurseCount + 1)
contextNode.nodes = buildImportNodes(ast, layer, media, supports)
contextNode.context.base = loaded.base
})(),
)
replaceWith(contextNode)
// The resolved Stylesheets already have their transitive @imports
// resolved, so we can skip walking them.
return WalkAction.Skip
} catch (e: any) {
// When an error occurs while parsing the `@import` statement, we skip
// the import.
}
}
})
await Promise.all(promises)
}
// Modified and inlined version of `parse-statements` from
// `postcss-import` <https://github.com/postcss/postcss-import>
// Copyright (c) 2014 Maxime Thirouin, Jason Campbell & Kevin Mårtensson
// Released under the MIT License.
function parseImportParams(params: ValueParser.ValueAstNode[]) {
let uri
let layer: string | null = null
let media: string | null = null
let supports: string | null = null
for (let i = 0; i < params.length; i++) {
const node = params[i]
if (node.kind === 'separator') continue
if (node.kind === 'word' && !uri) {
if (!node.value) throw new Error(`Unable to find uri`)
if (node.value[0] !== '"' && node.value[0] !== "'") throw new Error('Unable to find uri')
uri = node.value.slice(1, -1)
continue
}
if (node.kind === 'function' && node.value.toLowerCase() === 'url') {
throw new Error('url functions are not supported')
}
if (!uri) throw new Error('Unable to find uri')
if (
(node.kind === 'word' || node.kind === 'function') &&
node.value.toLowerCase() === 'layer'
) {
if (layer) throw new Error('Multiple layers')
if (supports) throw new Error('layers must be defined before support conditions')
if ('nodes' in node) {
layer = ValueParser.toCss(node.nodes)
} else {
layer = ''
}
continue
}
if (node.kind === 'function' && node.value.toLowerCase() === 'supports') {
if (supports) throw new Error('Multiple support conditions')
supports = ValueParser.toCss(node.nodes)
continue
}
media = ValueParser.toCss(params.slice(i))
break
}
if (!uri) throw new Error('Unable to find uri')
return { uri, layer, media, supports }
}
function buildImportNodes(
importedAst: AstNode[],
layer: string | null,
media: string | null,
supports: string | null,
): AstNode[] {
let root = importedAst
if (layer !== null) {
root = [rule('@layer ' + layer, root)]
}
if (media !== null) {
root = [rule('@media ' + media, root)]
}
if (supports !== null) {
root = [rule(`@supports ${supports[0] === '(' ? supports : `(${supports})`}`, root)]
}
return root
}

View File

@ -15,21 +15,25 @@ import { registerThemeVariantOverrides } from './theme-variants'
export async function applyCompatibilityHooks({
designSystem,
base,
ast,
loadPlugin,
loadConfig,
loadModule,
globs,
}: {
designSystem: DesignSystem
base: string
ast: AstNode[]
loadPlugin: (path: string) => Promise<Plugin>
loadConfig: (path: string) => Promise<UserConfig>
loadModule: (
path: string,
base: string,
resourceHint: 'plugin' | 'config',
) => Promise<{ module: any; base: string }>
globs: { origin?: string; pattern: string }[]
}) {
let pluginPaths: [string, CssPluginOptions | null][] = []
let configPaths: string[] = []
let pluginPaths: [{ id: string; base: string }, CssPluginOptions | null][] = []
let configPaths: { id: string; base: string }[] = []
walk(ast, (node, { parent, replaceWith }) => {
walk(ast, (node, { parent, replaceWith, context }) => {
if (node.kind !== 'rule' || node.selector[0] !== '@') return
// Collect paths from `@plugin` at-rules
@ -86,7 +90,10 @@ export async function applyCompatibilityHooks({
options[decl.property] = parts.length === 1 ? parts[0] : parts
}
pluginPaths.push([pluginPath, Object.keys(options).length > 0 ? options : null])
pluginPaths.push([
{ id: pluginPath, base: context.base },
Object.keys(options).length > 0 ? options : null,
])
replaceWith([])
return
@ -102,7 +109,7 @@ export async function applyCompatibilityHooks({
throw new Error('`@config` cannot be nested.')
}
configPaths.push(node.selector.slice(9, -1))
configPaths.push({ id: node.selector.slice(9, -1), base: context.base })
replaceWith([])
return
}
@ -142,38 +149,48 @@ export async function applyCompatibilityHooks({
// any additional backwards compatibility hooks.
if (!pluginPaths.length && !configPaths.length) return
let configs = await Promise.all(
configPaths.map(async (configPath) => ({
path: configPath,
config: await loadConfig(configPath),
})),
)
let pluginDetails = await Promise.all(
pluginPaths.map(async ([pluginPath, pluginOptions]) => ({
path: pluginPath,
plugin: await loadPlugin(pluginPath),
options: pluginOptions,
})),
)
let [configs, pluginDetails] = await Promise.all([
Promise.all(
configPaths.map(async ({ id, base }) => {
let loaded = await loadModule(id, base, 'config')
return {
path: id,
base: loaded.base,
config: loaded.module as UserConfig,
}
}),
),
Promise.all(
pluginPaths.map(async ([{ id, base }, pluginOptions]) => {
let loaded = await loadModule(id, base, 'plugin')
return {
path: id,
base: loaded.base,
plugin: loaded.module as Plugin,
options: pluginOptions,
}
}),
),
])
let plugins = pluginDetails.map((detail) => {
let pluginConfigs = pluginDetails.map((detail) => {
if (!detail.options) {
return detail.plugin
return { config: { plugins: [detail.plugin] }, base: detail.base }
}
if ('__isOptionsFunction' in detail.plugin) {
return detail.plugin(detail.options)
return { config: { plugins: [detail.plugin(detail.options)] }, base: detail.base }
}
throw new Error(`The plugin "${detail.path}" does not accept options`)
})
let userConfig = [{ config: { plugins } }, ...configs]
let userConfig = [...pluginConfigs, ...configs]
let resolvedConfig = resolveConfig(designSystem, [
{ config: createCompatConfig(designSystem.theme) },
{ config: createCompatConfig(designSystem.theme), base },
...userConfig,
{ config: { plugins: [darkModePlugin] } },
{ config: { plugins: [darkModePlugin] }, base },
])
let resolvedUserConfig = resolveConfig(designSystem, userConfig)
@ -221,7 +238,7 @@ export async function applyCompatibilityHooks({
)
}
globs.push({ origin: file.base, pattern: file.pattern })
globs.push(file)
}
}

View File

@ -35,6 +35,7 @@ test('Config values can be merged into the theme', () => {
},
},
},
base: '/root',
},
])
applyConfigToTheme(design, resolvedUserConfig)

View File

@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest'
import { compile } from '..'
import { compile, type Config } from '..'
import plugin from '../plugin'
import { flattenColorPalette } from './flatten-color-palette'
@ -12,10 +12,10 @@ test('Config files can add content', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({ content: ['./file.txt'] }),
loadModule: async () => ({ module: { content: ['./file.txt'] }, base: '/root' }),
})
expect(compiler.globs).toEqual([{ origin: './config.js', pattern: './file.txt' }])
expect(compiler.globs).toEqual([{ base: '/root', pattern: './file.txt' }])
})
test('Config files can change dark mode (media)', async () => {
@ -25,7 +25,7 @@ test('Config files can change dark mode (media)', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({ darkMode: 'media' }),
loadModule: async () => ({ module: { darkMode: 'media' }, base: '/root' }),
})
expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(`
@ -45,7 +45,7 @@ test('Config files can change dark mode (selector)', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({ darkMode: 'selector' }),
loadModule: async () => ({ module: { darkMode: 'selector' }, base: '/root' }),
})
expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(`
@ -65,7 +65,10 @@ test('Config files can change dark mode (variant)', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({ darkMode: ['variant', '&:where(:not(.light))'] }),
loadModule: async () => ({
module: { darkMode: ['variant', '&:where(:not(.light))'] },
base: '/root',
}),
})
expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(`
@ -85,16 +88,19 @@ test('Config files can add plugins', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
plugins: [
plugin(function ({ addUtilities }) {
addUtilities({
'.no-scrollbar': {
'scrollbar-width': 'none',
},
})
}),
],
loadModule: async () => ({
module: {
plugins: [
plugin(function ({ addUtilities }) {
addUtilities({
'.no-scrollbar': {
'scrollbar-width': 'none',
},
})
}),
],
},
base: '/root',
}),
})
@ -113,12 +119,15 @@ test('Plugins loaded from config files can contribute to the config', async () =
`
let compiler = await compile(input, {
loadConfig: async () => ({
plugins: [
plugin(() => {}, {
darkMode: ['variant', '&:where(:not(.light))'],
}),
],
loadModule: async () => ({
module: {
plugins: [
plugin(() => {}, {
darkMode: ['variant', '&:where(:not(.light))'],
}),
],
},
base: '/root',
}),
})
@ -139,12 +148,15 @@ test('Config file presets can contribute to the config', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
presets: [
{
darkMode: ['variant', '&:where(:not(.light))'],
},
],
loadModule: async () => ({
module: {
presets: [
{
darkMode: ['variant', '&:where(:not(.light))'],
},
],
},
base: '/root',
}),
})
@ -165,24 +177,27 @@ test('Config files can affect the theme', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
colors: {
primary: '#c0ffee',
loadModule: async () => ({
module: {
theme: {
extend: {
colors: {
primary: '#c0ffee',
},
},
},
},
plugins: [
plugin(function ({ addUtilities, theme }) {
addUtilities({
'.scrollbar-primary': {
scrollbarColor: theme('colors.primary'),
},
})
}),
],
plugins: [
plugin(function ({ addUtilities, theme }) {
addUtilities({
'.scrollbar-primary': {
scrollbarColor: theme('colors.primary'),
},
})
}),
],
},
base: '/root',
}),
})
@ -206,13 +221,16 @@ test('Variants in CSS overwrite variants from plugins', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
darkMode: ['variant', '&:is(.dark)'],
plugins: [
plugin(function ({ addVariant }) {
addVariant('light', '&:is(.light)')
}),
],
loadModule: async () => ({
module: {
darkMode: ['variant', '&:is(.dark)'],
plugins: [
plugin(function ({ addVariant }) {
addVariant('light', '&:is(.light)')
}),
],
},
base: '/root',
}),
})
@ -253,49 +271,52 @@ describe('theme callbacks', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
fontSize: {
base: ['200rem', { lineHeight: '201rem' }],
md: ['200rem', { lineHeight: '201rem' }],
xl: ['200rem', { lineHeight: '201rem' }],
loadModule: async () => ({
module: {
theme: {
extend: {
fontSize: {
base: ['200rem', { lineHeight: '201rem' }],
md: ['200rem', { lineHeight: '201rem' }],
xl: ['200rem', { lineHeight: '201rem' }],
},
// Direct access
lineHeight: ({ theme }) => ({
base: theme('fontSize.base[1].lineHeight'),
md: theme('fontSize.md[1].lineHeight'),
xl: theme('fontSize.xl[1].lineHeight'),
}),
// Tuple access
typography: ({ theme }) => ({
'[class~=lead-base]': {
fontSize: theme('fontSize.base')[0],
...theme('fontSize.base')[1],
},
'[class~=lead-md]': {
fontSize: theme('fontSize.md')[0],
...theme('fontSize.md')[1],
},
'[class~=lead-xl]': {
fontSize: theme('fontSize.xl')[0],
...theme('fontSize.xl')[1],
},
}),
},
// Direct access
lineHeight: ({ theme }) => ({
base: theme('fontSize.base[1].lineHeight'),
md: theme('fontSize.md[1].lineHeight'),
xl: theme('fontSize.xl[1].lineHeight'),
}),
// Tuple access
typography: ({ theme }) => ({
'[class~=lead-base]': {
fontSize: theme('fontSize.base')[0],
...theme('fontSize.base')[1],
},
'[class~=lead-md]': {
fontSize: theme('fontSize.md')[0],
...theme('fontSize.md')[1],
},
'[class~=lead-xl]': {
fontSize: theme('fontSize.xl')[0],
...theme('fontSize.xl')[1],
},
}),
},
},
plugins: [
plugin(function ({ addUtilities, theme }) {
addUtilities({
'.prose': {
...theme('typography'),
},
})
}),
],
plugins: [
plugin(function ({ addUtilities, theme }) {
addUtilities({
'.prose': {
...theme('typography'),
},
})
}),
],
} satisfies Config,
base: '/root',
}),
})
@ -361,15 +382,18 @@ describe('theme overrides order', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
colors: {
red: 'very-red',
blue: 'very-blue',
loadModule: async () => ({
module: {
theme: {
extend: {
colors: {
red: 'very-red',
blue: 'very-blue',
},
},
},
},
base: '/root',
}),
})
@ -404,35 +428,43 @@ describe('theme overrides order', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
colors: {
slate: {
200: '#200200',
400: '#200400',
600: '#200600',
},
},
},
},
}),
loadPlugin: async () => {
return plugin(({ matchUtilities, theme }) => {
matchUtilities(
{
'hover-bg': (value) => {
return {
'&:hover': {
backgroundColor: value,
loadModule: async (id) => {
if (id.includes('config.js')) {
return {
module: {
theme: {
extend: {
colors: {
slate: {
200: '#200200',
400: '#200400',
600: '#200600',
},
},
}
},
},
},
{ values: flattenColorPalette(theme('colors')) },
)
})
} satisfies Config,
base: '/root',
}
} else {
return {
module: plugin(({ matchUtilities, theme }) => {
matchUtilities(
{
'hover-bg': (value) => {
return {
'&:hover': {
backgroundColor: value,
},
}
},
},
{ values: flattenColorPalette(theme('colors')) },
)
}),
base: '/root',
}
}
},
})
@ -524,12 +556,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
sans: 'Potato Sans',
loadModule: async () => ({
module: {
theme: {
fontFamily: {
sans: 'Potato Sans',
},
},
},
base: '/root',
}),
})
@ -560,12 +595,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
sans: ['Potato Sans', { fontFeatureSettings: '"cv06"' }],
loadModule: async () => ({
module: {
theme: {
fontFamily: {
sans: ['Potato Sans', { fontFeatureSettings: '"cv06"' }],
},
},
},
base: '/root',
}),
})
@ -597,12 +635,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
sans: ['Potato Sans', { fontVariationSettings: '"XHGT" 0.7' }],
loadModule: async () => ({
module: {
theme: {
fontFamily: {
sans: ['Potato Sans', { fontVariationSettings: '"XHGT" 0.7' }],
},
},
},
base: '/root',
}),
})
@ -634,15 +675,18 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
sans: [
'Potato Sans',
{ fontFeatureSettings: '"cv06"', fontVariationSettings: '"XHGT" 0.7' },
],
loadModule: async () => ({
module: {
theme: {
fontFamily: {
sans: [
'Potato Sans',
{ fontFeatureSettings: '"cv06"', fontVariationSettings: '"XHGT" 0.7' },
],
},
},
},
base: '/root',
}),
})
@ -678,12 +722,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
sans: 'Potato Sans',
loadModule: async () => ({
module: {
theme: {
fontFamily: {
sans: 'Potato Sans',
},
},
},
base: '/root',
}),
})
@ -715,12 +762,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
loadModule: async () => ({
module: {
theme: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
base: '/root',
}),
})
@ -751,12 +801,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
sans: { foo: 'bar', banana: 'sandwich' },
loadModule: async () => ({
module: {
theme: {
fontFamily: {
sans: { foo: 'bar', banana: 'sandwich' },
},
},
},
base: '/root',
}),
})
@ -782,12 +835,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
mono: 'Potato Mono',
loadModule: async () => ({
module: {
theme: {
fontFamily: {
mono: 'Potato Mono',
},
},
},
base: '/root',
}),
})
@ -818,12 +874,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
mono: ['Potato Mono', { fontFeatureSettings: '"cv06"' }],
loadModule: async () => ({
module: {
theme: {
fontFamily: {
mono: ['Potato Mono', { fontFeatureSettings: '"cv06"' }],
},
},
},
base: '/root',
}),
})
@ -855,12 +914,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
mono: ['Potato Mono', { fontVariationSettings: '"XHGT" 0.7' }],
loadModule: async () => ({
module: {
theme: {
fontFamily: {
mono: ['Potato Mono', { fontVariationSettings: '"XHGT" 0.7' }],
},
},
},
base: '/root',
}),
})
@ -892,15 +954,18 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
mono: [
'Potato Mono',
{ fontFeatureSettings: '"cv06"', fontVariationSettings: '"XHGT" 0.7' },
],
loadModule: async () => ({
module: {
theme: {
fontFamily: {
mono: [
'Potato Mono',
{ fontFeatureSettings: '"cv06"', fontVariationSettings: '"XHGT" 0.7' },
],
},
},
},
base: '/root',
}),
})
@ -936,12 +1001,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
mono: 'Potato Mono',
loadModule: async () => ({
module: {
theme: {
fontFamily: {
mono: 'Potato Mono',
},
},
},
base: '/root',
}),
})
@ -973,12 +1041,15 @@ describe('default font family compatibility', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
fontFamily: {
mono: { foo: 'bar', banana: 'sandwich' },
loadModule: async () => ({
module: {
theme: {
fontFamily: {
mono: { foo: 'bar', banana: 'sandwich' },
},
},
},
base: '/root',
}),
})
@ -1000,21 +1071,24 @@ test('creates variants for `data`, `supports`, and `aria` theme options at the s
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
aria: {
polite: 'live="polite"',
},
supports: {
'child-combinator': 'selector(h2 > p)',
foo: 'bar',
},
data: {
checked: 'ui~="checked"',
loadModule: async () => ({
module: {
theme: {
extend: {
aria: {
polite: 'live="polite"',
},
supports: {
'child-combinator': 'selector(h2 > p)',
foo: 'bar',
},
data: {
checked: 'ui~="checked"',
},
},
},
},
base: '/root',
}),
})
@ -1096,14 +1170,17 @@ test('merges css breakpoints with js config screens', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
sm: '44rem',
loadModule: async () => ({
module: {
theme: {
extend: {
screens: {
sm: '44rem',
},
},
},
},
base: '/root',
}),
})

View File

@ -19,6 +19,7 @@ test('top level theme keys are replaced', () => {
},
},
},
base: '/root',
},
{
config: {
@ -28,6 +29,7 @@ test('top level theme keys are replaced', () => {
},
},
},
base: '/root',
},
{
config: {
@ -37,6 +39,7 @@ test('top level theme keys are replaced', () => {
},
},
},
base: '/root',
},
])
@ -68,6 +71,7 @@ test('theme can be extended', () => {
},
},
},
base: '/root',
},
{
config: {
@ -79,6 +83,7 @@ test('theme can be extended', () => {
},
},
},
base: '/root',
},
])
@ -112,6 +117,7 @@ test('theme keys can reference other theme keys using the theme function regardl
},
},
},
base: '/root',
},
{
config: {
@ -124,6 +130,7 @@ test('theme keys can reference other theme keys using the theme function regardl
},
},
},
base: '/root',
},
{
config: {
@ -135,6 +142,7 @@ test('theme keys can reference other theme keys using the theme function regardl
},
},
},
base: '/root',
},
])
@ -192,6 +200,7 @@ test('theme keys can read from the CSS theme', () => {
}),
},
},
base: '/root',
},
])

View File

@ -12,6 +12,7 @@ import {
export interface ConfigFile {
path?: string
base: string
config: UserConfig
}
@ -103,7 +104,7 @@ export interface PluginUtils {
theme(keypath: string, defaultValue?: any): any
}
function extractConfigs(ctx: ResolutionContext, { config, path }: ConfigFile): void {
function extractConfigs(ctx: ResolutionContext, { config, base, path }: ConfigFile): void {
let plugins: PluginWithConfig[] = []
// Normalize plugins so they share the same shape
@ -133,7 +134,7 @@ function extractConfigs(ctx: ResolutionContext, { config, path }: ConfigFile): v
}
for (let preset of config.presets ?? []) {
extractConfigs(ctx, { path, config: preset })
extractConfigs(ctx, { path, base, config: preset })
}
// Apply configs from plugins
@ -141,7 +142,7 @@ function extractConfigs(ctx: ResolutionContext, { config, path }: ConfigFile): v
ctx.plugins.push(plugin)
if (plugin.config) {
extractConfigs(ctx, { path, config: plugin.config })
extractConfigs(ctx, { path, base, config: plugin.config })
}
}
@ -150,7 +151,7 @@ function extractConfigs(ctx: ResolutionContext, { config, path }: ConfigFile): v
let files = Array.isArray(content) ? content : content.files
for (let file of files) {
ctx.content.files.push(typeof file === 'object' ? file : { base: path!, pattern: file })
ctx.content.files.push(typeof file === 'object' ? file : { base, pattern: file })
}
// Then apply the "user" config

File diff suppressed because it is too large Load Diff

View File

@ -20,14 +20,17 @@ test('CSS `--breakpoint-*` merge with JS config `screens`', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
sm: '44rem',
loadModule: async () => ({
module: {
theme: {
extend: {
screens: {
sm: '44rem',
},
},
},
},
base: '/root',
}),
})
@ -100,17 +103,20 @@ test('JS config `screens` extend CSS `--breakpoint-*`', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
xs: '30rem',
sm: '40rem',
md: '48rem',
lg: '60rem',
loadModule: async () => ({
module: {
theme: {
extend: {
screens: {
xs: '30rem',
sm: '40rem',
md: '48rem',
lg: '60rem',
},
},
},
},
base: '/root',
}),
})
@ -195,14 +201,17 @@ test('JS config `screens` only setup, even if those match the default-theme expo
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
screens: {
sm: '40rem',
md: '48rem',
lg: '64rem',
loadModule: async () => ({
module: {
theme: {
screens: {
sm: '40rem',
md: '48rem',
lg: '64rem',
},
},
},
base: '/root',
}),
})
@ -271,14 +280,17 @@ test('JS config `screens` overwrite CSS `--breakpoint-*`', async () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
screens: {
mini: '40rem',
midi: '48rem',
maxi: '64rem',
loadModule: async () => ({
module: {
theme: {
screens: {
mini: '40rem',
midi: '48rem',
maxi: '64rem',
},
},
},
base: '/root',
}),
})
@ -374,16 +386,19 @@ test('JS config with `theme: { extends }` should not include the `default-config
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
mini: '40rem',
midi: '48rem',
maxi: '64rem',
loadModule: async () => ({
module: {
theme: {
extend: {
screens: {
mini: '40rem',
midi: '48rem',
maxi: '64rem',
},
},
},
},
base: '/root',
}),
})
@ -449,22 +464,25 @@ describe('complex screen configs', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
sm: { max: '639px' },
md: [
//
{ min: '668px', max: '767px' },
'868px',
],
lg: { min: '868px' },
xl: { min: '1024px', max: '1279px' },
tall: { raw: '(min-height: 800px)' },
loadModule: async () => ({
module: {
theme: {
extend: {
screens: {
sm: { max: '639px' },
md: [
//
{ min: '668px', max: '767px' },
'868px',
],
lg: { min: '868px' },
xl: { min: '1024px', max: '1279px' },
tall: { raw: '(min-height: 800px)' },
},
},
},
},
base: '/root',
}),
})
@ -533,15 +551,18 @@ describe('complex screen configs', () => {
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
sm: '40rem',
portrait: { raw: 'screen and (orientation: portrait)' },
loadModule: async () => ({
module: {
theme: {
extend: {
screens: {
sm: '40rem',
portrait: { raw: 'screen and (orientation: portrait)' },
},
},
},
},
base: '/root',
}),
})

View File

@ -336,7 +336,7 @@ describe('theme function', () => {
}
`,
{
loadConfig: async () => ({}),
loadModule: async () => ({ module: {}, base: '/root' }),
},
)
@ -795,23 +795,26 @@ describe('in plugins', () => {
}
`,
{
async loadPlugin() {
return plugin(({ addBase, addUtilities }) => {
addBase({
'.my-base-rule': {
color: 'theme(colors.red)',
'outline-color': 'theme(colors.orange / 15%)',
'background-color': 'theme(--color-blue)',
'border-color': 'theme(--color-pink / 10%)',
},
})
async loadModule() {
return {
module: plugin(({ addBase, addUtilities }) => {
addBase({
'.my-base-rule': {
color: 'theme(colors.red)',
'outline-color': 'theme(colors.orange / 15%)',
'background-color': 'theme(--color-blue)',
'border-color': 'theme(--color-pink / 10%)',
},
})
addUtilities({
'.my-utility': {
color: 'theme(colors.red)',
},
})
})
addUtilities({
'.my-utility': {
color: 'theme(colors.red)',
},
})
}),
base: '/root',
}
},
},
)
@ -850,31 +853,34 @@ describe('in JS config files', () => {
}
`,
{
loadConfig: async () => ({
theme: {
extend: {
colors: {
primary: 'theme(colors.red)',
secondary: 'theme(--color-orange)',
loadModule: async () => ({
module: {
theme: {
extend: {
colors: {
primary: 'theme(colors.red)',
secondary: 'theme(--color-orange)',
},
},
},
},
plugins: [
plugin(({ addBase, addUtilities }) => {
addBase({
'.my-base-rule': {
background: 'theme(colors.primary)',
color: 'theme(colors.secondary)',
},
})
plugins: [
plugin(({ addBase, addUtilities }) => {
addBase({
'.my-base-rule': {
background: 'theme(colors.primary)',
color: 'theme(colors.secondary)',
},
})
addUtilities({
'.my-utility': {
color: 'theme(colors.red)',
},
})
}),
],
addUtilities({
'.my-utility': {
color: 'theme(colors.red)',
},
})
}),
],
},
base: '/root',
}),
},
)

View File

@ -1429,17 +1429,20 @@ describe('Parsing themes values from CSS', () => {
@tailwind utilities;
`,
{
loadPlugin: async () => {
return plugin(({}) => {}, {
theme: {
extend: {
colors: {
red: 'tomato',
orange: '#f28500',
loadModule: async () => {
return {
module: plugin(({}) => {}, {
theme: {
extend: {
colors: {
red: 'tomato',
orange: '#f28500',
},
},
},
},
})
}),
base: '/root',
}
},
},
)
@ -1472,16 +1475,19 @@ describe('Parsing themes values from CSS', () => {
@tailwind utilities;
`,
{
loadConfig: async () => {
loadModule: async () => {
return {
theme: {
extend: {
colors: {
red: 'tomato',
orange: '#f28500',
module: {
theme: {
extend: {
colors: {
red: 'tomato',
orange: '#f28500',
},
},
},
},
base: '/root',
}
},
},
@ -1511,11 +1517,12 @@ describe('plugins', () => {
@plugin;
`,
{
loadPlugin: async () => {
return ({ addVariant }: PluginAPI) => {
loadModule: async () => ({
module: ({ addVariant }: PluginAPI) => {
addVariant('hocus', '&:hover, &:focus')
}
},
},
base: '/root',
}),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`))
@ -1527,11 +1534,12 @@ describe('plugins', () => {
@plugin '';
`,
{
loadPlugin: async () => {
return ({ addVariant }: PluginAPI) => {
loadModule: async () => ({
module: ({ addVariant }: PluginAPI) => {
addVariant('hocus', '&:hover, &:focus')
}
},
},
base: '/root',
}),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`))
@ -1545,11 +1553,12 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => {
return ({ addVariant }: PluginAPI) => {
loadModule: async () => ({
module: ({ addVariant }: PluginAPI) => {
addVariant('hocus', '&:hover, &:focus')
}
},
},
base: '/root',
}),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot be nested.]`))
@ -1565,8 +1574,8 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => {
return plugin.withOptions((options) => {
loadModule: async () => ({
module: plugin.withOptions((options) => {
expect(options).toEqual({
color: 'red',
})
@ -1578,8 +1587,9 @@ describe('plugins', () => {
},
})
}
})
},
}),
base: '/root',
}),
},
)
@ -1616,8 +1626,8 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => {
return plugin.withOptions((options) => {
loadModule: async () => ({
module: plugin.withOptions((options) => {
expect(options).toEqual({
'is-null': null,
'is-true': true,
@ -1636,8 +1646,9 @@ describe('plugins', () => {
})
return () => {}
})
},
}),
base: '/root',
}),
},
)
})
@ -1655,8 +1666,8 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => {
return plugin.withOptions((options) => {
loadModule: async () => ({
module: plugin.withOptions((options) => {
return ({ addUtilities }) => {
addUtilities({
'.text-primary': {
@ -1664,8 +1675,9 @@ describe('plugins', () => {
},
})
}
})
},
}),
base: '/root',
}),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(
@ -1692,15 +1704,16 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => {
return plugin(({ addUtilities }) => {
loadModule: async () => ({
module: plugin(({ addUtilities }) => {
addUtilities({
'.text-primary': {
color: 'red',
},
})
})
},
}),
base: '/root',
}),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(
@ -1717,7 +1730,7 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => plugin(() => {}),
loadModule: async () => ({ module: plugin(() => {}), base: '/root' }),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(
@ -1738,7 +1751,7 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => plugin(() => {}),
loadModule: async () => ({ module: plugin(() => {}), base: '/root' }),
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(
@ -1763,11 +1776,12 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => {
return ({ addVariant }: PluginAPI) => {
loadModule: async () => ({
module: ({ addVariant }: PluginAPI) => {
addVariant('hocus', '&:hover, &:focus')
}
},
},
base: '/root',
}),
},
)
let compiled = build(['hocus:underline', 'group-hocus:flex'])
@ -1794,11 +1808,12 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => {
return ({ addVariant }: PluginAPI) => {
loadModule: async () => ({
module: ({ addVariant }: PluginAPI) => {
addVariant('hocus', ['&:hover', '&:focus'])
}
},
},
base: '/root',
}),
},
)
@ -1826,14 +1841,15 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => {
return ({ addVariant }: PluginAPI) => {
loadModule: async () => ({
module: ({ addVariant }: PluginAPI) => {
addVariant('hocus', {
'&:hover': '@slot',
'&:focus': '@slot',
})
}
},
},
base: '/root',
}),
},
)
let compiled = build(['hocus:underline', 'group-hocus:flex'])
@ -1860,16 +1876,17 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => {
return ({ addVariant }: PluginAPI) => {
loadModule: async () => ({
module: ({ addVariant }: PluginAPI) => {
addVariant('hocus', {
'@media (hover: hover)': {
'&:hover': '@slot',
},
'&:focus': '@slot',
})
}
},
},
base: '/root',
}),
},
)
let compiled = build(['hocus:underline', 'group-hocus:flex'])
@ -1908,8 +1925,8 @@ describe('plugins', () => {
}
`,
{
loadPlugin: async () => {
return ({ addVariant }: PluginAPI) => {
loadModule: async () => ({
module: ({ addVariant }: PluginAPI) => {
addVariant('hocus', {
'&': {
'--custom-property': '@slot',
@ -1917,8 +1934,9 @@ describe('plugins', () => {
'&:focus': '@slot',
},
})
}
},
},
base: '/root',
}),
},
)
let compiled = build(['hocus:underline'])
@ -1944,13 +1962,13 @@ describe('plugins', () => {
@tailwind utilities;
}
`,
{
loadPlugin: async () => {
return ({ addVariant }: PluginAPI) => {
loadModule: async () => ({
module: ({ addVariant }: PluginAPI) => {
addVariant('dark', '&:is([data-theme=dark] *)')
}
},
},
base: '/root',
}),
},
)
let compiled = build(
@ -1981,20 +1999,29 @@ describe('plugins', () => {
describe('@source', () => {
test('emits @source files', async () => {
let { globs } = await compile(css`
@source "./foo/bar/*.ts";
`)
let { globs } = await compile(
css`
@source "./foo/bar/*.ts";
`,
{ base: '/root' },
)
expect(globs).toEqual([{ pattern: './foo/bar/*.ts' }])
expect(globs).toEqual([{ pattern: './foo/bar/*.ts', base: '/root' }])
})
test('emits multiple @source files', async () => {
let { globs } = await compile(css`
@source "./foo/**/*.ts";
@source "./php/secr3t/smarty.php";
`)
let { globs } = await compile(
css`
@source "./foo/**/*.ts";
@source "./php/secr3t/smarty.php";
`,
{ base: '/root' },
)
expect(globs).toEqual([{ pattern: './foo/**/*.ts' }, { pattern: './php/secr3t/smarty.php' }])
expect(globs).toEqual([
{ pattern: './foo/**/*.ts', base: '/root' },
{ pattern: './php/secr3t/smarty.php', base: '/root' },
])
})
})
@ -2513,17 +2540,17 @@ test('addBase', async () => {
@tailwind utilities;
}
`,
{
loadPlugin: async () => {
return ({ addBase }: PluginAPI) => {
loadModule: async () => ({
module: ({ addBase }: PluginAPI) => {
addBase({
body: {
'font-feature-settings': '"tnum"',
},
})
}
},
},
base: '/root',
}),
},
)

View File

@ -1,6 +1,17 @@
import { version } from '../package.json'
import { substituteAtApply } from './apply'
import { comment, decl, rule, toCss, walk, WalkAction, type Rule } from './ast'
import {
comment,
context,
decl,
rule,
toCss,
walk,
WalkAction,
type AstNode,
type Rule,
} from './ast'
import { substituteAtImports } from './at-import'
import { applyCompatibilityHooks } from './compat/apply-compat-hooks'
import type { UserConfig } from './compat/config/types'
import { type Plugin } from './compat/plugin-api'
@ -15,16 +26,21 @@ export type Config = UserConfig
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
type CompileOptions = {
loadPlugin?: (path: string) => Promise<Plugin>
loadConfig?: (path: string) => Promise<UserConfig>
base?: string
loadModule?: (
id: string,
base: string,
resourceHint: 'plugin' | 'config',
) => Promise<{ module: Plugin | Config; base: string }>
loadStylesheet?: (id: string, base: string) => Promise<{ content: string; base: string }>
}
function throwOnPlugin(): never {
throw new Error('No `loadPlugin` function provided to `compile`')
function throwOnLoadModule(): never {
throw new Error('No `loadModule` function provided to `compile`')
}
function throwOnConfig(): never {
throw new Error('No `loadConfig` function provided to `compile`')
function throwOnLoadStylesheet(): never {
throw new Error('No `loadStylesheet` function provided to `compile`')
}
function parseThemeOptions(selector: string) {
@ -45,9 +61,15 @@ function parseThemeOptions(selector: string) {
async function parseCss(
css: string,
{ loadPlugin = throwOnPlugin, loadConfig = throwOnConfig }: CompileOptions = {},
{
base = '',
loadModule = throwOnLoadModule,
loadStylesheet = throwOnLoadStylesheet,
}: CompileOptions = {},
) {
let ast = CSS.parse(css)
let ast = [context({ base }, CSS.parse(css))] as AstNode[]
await substituteAtImports(ast, base, loadStylesheet)
// Find all `@theme` declarations
let theme = new Theme()
@ -55,9 +77,9 @@ async function parseCss(
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
let firstThemeRule: Rule | null = null
let keyframesRules: Rule[] = []
let globs: { origin?: string; pattern: string }[] = []
let globs: { base: string; pattern: string }[] = []
walk(ast, (node, { parent, replaceWith }) => {
walk(ast, (node, { parent, replaceWith, context }) => {
if (node.kind !== 'rule') return
// Collect custom `@utility` at-rules
@ -104,7 +126,7 @@ async function parseCss(
) {
throw new Error('`@source` paths must be quoted.')
}
globs.push({ pattern: path.slice(1, -1) })
globs.push({ base: context.base, pattern: path.slice(1, -1) })
replaceWith([])
return
}
@ -234,7 +256,7 @@ async function parseCss(
// of random arguments because it really just needs access to "the world" to
// do whatever ungodly things it needs to do to make things backwards
// compatible without polluting core.
await applyCompatibilityHooks({ designSystem, ast, loadPlugin, loadConfig, globs })
await applyCompatibilityHooks({ designSystem, base, ast, loadModule, globs })
for (let customVariant of customVariants) {
customVariant(designSystem)
@ -316,7 +338,7 @@ export async function compile(
css: string,
opts: CompileOptions = {},
): Promise<{
globs: { origin?: string; pattern: string }[]
globs: { base: string; pattern: string }[]
build(candidates: string[]): string
}> {
let { designSystem, ast, globs } = await parseCss(css, opts)

View File

@ -10,15 +10,16 @@ test('plugin', async () => {
`
let compiler = await compile(input, {
loadPlugin: async () => {
return plugin(function ({ addBase }) {
loadModule: async () => ({
module: plugin(function ({ addBase }) {
addBase({
body: {
margin: '0',
},
})
})
},
}),
base: '/root',
}),
})
expect(compiler.build([])).toMatchInlineSnapshot(`
@ -37,8 +38,8 @@ test('plugin.withOptions', async () => {
`
let compiler = await compile(input, {
loadPlugin: async () => {
return plugin.withOptions(function (opts = { foo: '1px' }) {
loadModule: async () => ({
module: plugin.withOptions(function (opts = { foo: '1px' }) {
return function ({ addBase }) {
addBase({
body: {
@ -46,8 +47,9 @@ test('plugin.withOptions', async () => {
},
})
}
})
},
}),
base: '/root',
}),
})
expect(compiler.build([])).toMatchInlineSnapshot(`

79
pnpm-lock.yaml generated
View File

@ -156,25 +156,15 @@ importers:
picocolors:
specifier: ^1.0.1
version: 1.0.1
postcss:
specifier: ^8.4.41
version: 8.4.41
postcss-import:
specifier: ^16.1.0
version: 16.1.0(postcss@8.4.41)
tailwindcss:
specifier: workspace:^
version: link:../tailwindcss
devDependencies:
'@types/postcss-import':
specifier: ^14.0.3
version: 14.0.3
internal-postcss-fix-relative-paths:
specifier: workspace:^
version: link:../internal-postcss-fix-relative-paths
packages/@tailwindcss-node:
dependencies:
enhanced-resolve:
specifier: ^5.17.1
version: 5.17.1
jiti:
specifier: ^2.0.0-beta.3
version: 2.0.0-beta.3
@ -194,9 +184,6 @@ importers:
lightningcss:
specifier: 'catalog:'
version: 1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4)
postcss-import:
specifier: ^16.1.0
version: 16.1.0(postcss@8.4.41)
tailwindcss:
specifier: workspace:^
version: link:../tailwindcss
@ -205,17 +192,17 @@ importers:
specifier: 'catalog:'
version: 20.14.13
'@types/postcss-import':
specifier: ^14.0.3
specifier: 14.0.3
version: 14.0.3
internal-example-plugin:
specifier: workspace:*
version: link:../internal-example-plugin
internal-postcss-fix-relative-paths:
specifier: workspace:^
version: link:../internal-postcss-fix-relative-paths
postcss:
specifier: ^8.4.41
version: 8.4.41
postcss-import:
specifier: ^16.1.0
version: 16.1.0(postcss@8.4.41)
packages/@tailwindcss-standalone:
dependencies:
@ -326,12 +313,6 @@ importers:
lightningcss:
specifier: 'catalog:'
version: 1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4)
postcss:
specifier: ^8.4.41
version: 8.4.41
postcss-import:
specifier: ^16.1.0
version: 16.1.0(postcss@8.4.41)
tailwindcss:
specifier: workspace:^
version: link:../tailwindcss
@ -339,33 +320,12 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 20.14.13
'@types/postcss-import':
specifier: ^14.0.3
version: 14.0.3
internal-postcss-fix-relative-paths:
specifier: workspace:^
version: link:../internal-postcss-fix-relative-paths
vite:
specifier: 'catalog:'
version: 5.4.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6)
packages/internal-example-plugin: {}
packages/internal-postcss-fix-relative-paths:
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 20.14.13
'@types/postcss-import':
specifier: ^14.0.3
version: 14.0.3
postcss:
specifier: 8.4.41
version: 8.4.41
postcss-import:
specifier: ^16.1.0
version: 16.1.0(postcss@8.4.41)
packages/tailwindcss:
devDependencies:
'@tailwindcss/oxide':
@ -374,6 +334,9 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 20.14.13
dedent:
specifier: 1.5.3
version: 1.5.3
lightningcss:
specifier: 'catalog:'
version: 1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4)
@ -1055,7 +1018,6 @@ packages:
'@parcel/watcher-darwin-arm64@2.4.2-alpha.0':
resolution: {integrity: sha512-2xH4Ve7OKjIh+4YRfTN3HGJa2W8KTPLOALHZj5fxcbTPwaVxdpIRItDrcikUx2u3AzGAFme7F+AZZXHnf0F15Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.4.1':
@ -1067,7 +1029,6 @@ packages:
'@parcel/watcher-darwin-x64@2.4.2-alpha.0':
resolution: {integrity: sha512-xtjmXUH4YZVah5+7Q0nb+fpRP5qZn9cFfuPuZ4k77UfUGVwhacgZyIRQgIOwMP3GkgW4TsrKQaw1KIe7L1ZqcQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.4.1':
@ -1091,7 +1052,6 @@ packages:
'@parcel/watcher-linux-arm64-glibc@2.4.2-alpha.0':
resolution: {integrity: sha512-vIIOcZf+fgsRReIK3Fw0WINvGo9UwiXfisnqYRzfpNByRZvkEPkGTIVe8iiDp72NhPTVmwIvBqM6yKDzIaw8GQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.4.1':
@ -1103,7 +1063,6 @@ packages:
'@parcel/watcher-linux-arm64-musl@2.4.2-alpha.0':
resolution: {integrity: sha512-gXqEAoLG9bBCbQNUgqjSOxHcjpmCZmYT9M8UvrdTMgMYgXgiWcR8igKlPRd40mCIRZSkMpN2ScSy2WjQ0bQZnQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.4.1':
@ -1115,7 +1074,6 @@ packages:
'@parcel/watcher-linux-x64-glibc@2.4.2-alpha.0':
resolution: {integrity: sha512-/WJJ3Y46ubwQW+Z+mzpzK3pvqn/AT7MA63NB0+k9GTLNxJQZNREensMtpJ/FJ+LVIiraEHTY22KQrsx9+DeNbw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.4.1':
@ -1127,7 +1085,6 @@ packages:
'@parcel/watcher-linux-x64-musl@2.4.2-alpha.0':
resolution: {integrity: sha512-1dz4fTM5HaANk3RSRmdhALT+bNqTHawVDL1D77HwV/FuF/kSjlM3rGrJuFaCKwQ5E8CInHCcobqMN8Jh8LYaRg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.4.1':
@ -1151,7 +1108,6 @@ packages:
'@parcel/watcher-win32-x64@2.4.2-alpha.0':
resolution: {integrity: sha512-U2abMKF7JUiIxQkos19AvTLFcnl2Xn8yIW1kzu+7B0Lux4Gkuu/BUDBroaM1s6+hwgK63NOLq9itX2Y3GwUThg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.4.1':
@ -1487,13 +1443,11 @@ packages:
bun@1.1.22:
resolution: {integrity: sha512-G2HCPhzhjDc2jEDkZsO9vwPlpHrTm7a8UVwx9oNS5bZqo5OcSK5GPuWYDWjj7+37bRk5OVLfeIvUMtSrbKeIjQ==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
bun@1.1.26:
resolution: {integrity: sha512-dWSewAqE7sVbYmflJxgG47dW4vmsbar7VAnQ4ao45y3ulr3n7CwdsMLFnzd28jhPRtF+rsaVK2y4OLIkP3OD4A==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
@ -2260,13 +2214,11 @@ packages:
lightningcss-darwin-arm64@1.26.0:
resolution: {integrity: sha512-n4TIvHO1NY1ondKFYpL2ZX0bcC2y6yjXMD6JfyizgR8BCFNEeArINDzEaeqlfX9bXz73Bpz/Ow0nu+1qiDrBKg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.26.0:
resolution: {integrity: sha512-Rf9HuHIDi1R6/zgBkJh25SiJHF+dm9axUZW/0UoYCW1/8HV0gMI0blARhH4z+REmWiU1yYT/KyNF3h7tHyRXUg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.26.0:
@ -2284,25 +2236,21 @@ packages:
lightningcss-linux-arm64-gnu@1.26.0:
resolution: {integrity: sha512-iJmZM7fUyVjH+POtdiCtExG+67TtPUTer7K/5A8DIfmPfrmeGvzfRyBltGhQz13Wi15K1lf2cPYoRaRh6vcwNA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-arm64-musl@1.26.0:
resolution: {integrity: sha512-XxoEL++tTkyuvu+wq/QS8bwyTXZv2y5XYCMcWL45b8XwkiS8eEEEej9BkMGSRwxa5J4K+LDeIhLrS23CpQyfig==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-x64-gnu@1.26.0:
resolution: {integrity: sha512-1dkTfZQAYLj8MUSkd6L/+TWTG8V6Kfrzfa0T1fSlXCXQHrt1HC1/UepXHtKHDt/9yFwyoeayivxXAsApVxn6zA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-linux-x64-musl@1.26.0:
resolution: {integrity: sha512-yX3Rk9m00JGCUzuUhFEojY+jf/6zHs3XU8S8Vk+FRbnr4St7cjyMXdNjuA2LjiT8e7j8xHRCH8hyZ4H/btRE4A==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-win32-arm64-msvc@1.26.0:
@ -2314,7 +2262,6 @@ packages:
lightningcss-win32-x64-msvc@1.26.0:
resolution: {integrity: sha512-pYS3EyGP3JRhfqEFYmfFDiZ9/pVNfy8jVIYtrx9TVNusVyDK3gpW1w/rbvroQ4bDJi7grdUtyrYU6V2xkY/bBw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.26.0:
@ -4442,7 +4389,7 @@ snapshots:
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0)
eslint-plugin-react: 7.35.0(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0)
@ -4466,7 +4413,7 @@ snapshots:
enhanced-resolve: 5.17.1
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.6
is-core-module: 2.15.0
@ -4488,7 +4435,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5