Compare commits

...

60 commits
v4.3.2 ... main

Author SHA1 Message Date
Robin Malfait
fa81d697fe
Improve style invalidation performance of group-* and peer-* variants (#20513)
# For humans, by @RobinMalfait

## TL;DR

Some people ran into performance issues with the current `group-*`
variant because of the `*` inside of the selector. Take the
`group-focus-visible:flex` class for example, this generates:
```css
.group-focus-visible\:flex:is(:where(.group):focus-visible *) {
  display: flex;
}
```

This slightly rewrites the selector by maintaining the same
functionality, but increasing performance because the browser has to do
fewer style recalculations.

That same class now produces:
```css
:is(:where(.group):focus-visible .group-focus-visible\\:flex) {
  display: flex;
}
```

# For AI, generated by AI

This PR changes the selectors generated for `group-*` and `peer-*`
variants so browsers do less style recalculation when a group or peer
changes state (e.g. on focus or hover). Which elements match, and with
what specificity, stays the same, except for one deliberately accepted
edge case involving `@namespace` (see below).

```css
/* Before */
.group-focus\:flex:is(:where(.group):focus *) { display: flex; }
.peer-focus\:flex:is(:where(.peer):focus ~ *) { display: flex; }

/* After */
:is(:where(.group):focus .group-focus\:flex) { display: flex; }
:is(:where(.peer):focus ~ .peer-focus\:flex) { display: flex; }
```

## Why

In the old form, the subject inside `:is(…)` is `*`. When `.group`
changes state, Chromium has to recalculate styles for **every**
descendant of the group (or every following sibling of the peer), not
just the elements that use the utility. With the target itself in that
position, the browser can narrow invalidation down to elements matching
the target.

## Performance

Synthetic benchmark: toggle focus on a group/peer, flush style after
each change, and measure only a non-layout property (`outline-color`) to
isolate invalidation. Apple M5 Pro, macOS arm64.

**Elements recalculated per focus/blur** (Chromium 151,
`UpdateLayoutTree` `elementCount`, including the focused element):

| Scenario                                    | Before |   After |
| ------------------------------------------- | -----: | ------: |
| Group: 100 targets among 10,000 descendants | 10,001 | **101** |
| Peer: 20 targets among 2,000 siblings       |  2,001 |  **21** |

**Median time per focus/blur** (sparse: 1% of elements carry the
utility):

| Engine       | Group: before → after     | Peer: before → after      |
| ------------ | ------------------------- | ------------------------- |
| Chromium 151 | 2.169 → **0.130 ms** (~17×) | 0.635 → **0.192 ms**
(~3.3×) |
| Firefox 153  | 0.500 → 0.350 ms          | 0.650 → 0.600 ms          |
| WebKit 26.5  | 0.750 → 0.750 ms          | 0.450 → 0.450 ms          |

**Dense case** (every element carries the utility): no meaningful
difference in any engine, because every element needs recalculation
anyway. Firefox's dense group case was ~5% slower (2.775 → 2.925 ms).
Everything else was within noise.

| Engine | Group dense: before → after | Peer dense: before → after |
| ------------ | --------------------------- |
-------------------------- |
| Chromium 151 | 4.027 → 3.971 ms | 18.095 → 18.121 ms |
| Firefox 153 | 2.775 → 2.925 ms | 43.900 → 44.025 ms |
| WebKit 26.5 | 7.550 → 7.425 ms | 35.325 → 35.100 ms |

These are micro-benchmarks of style updates, not page-load or frame-rate
numbers. The real-world gain depends on DOM size and how many elements
inside a group/peer use the variant. The biggest win is the common case:
a large group containing only a handful of `group-*` targets.

## Do the selectors behave the same?

Yes. For a group condition `G` and a target `&`:

- **Before:** matches `&` and has an ancestor matching `G`
- **After:** has an ancestor matching `G` and matches `&`

`peer-*` follows the same reasoning with `~`. Details:

- **Specificity is unchanged.** `:is()` takes the specificity of its
argument, so before was `spec(&) + spec(G)` and after is `spec(G) +
spec(&)`.
- **`&` is used, not the utility class**, so `@apply`, `@variant`,
`*:group-*`, `[&_p]:group-*`, and other variants that change the target
keep working. Complex parents such as `.foo .bar { @apply
peer-focus:flex }` keep `:is(…)` semantics during nesting: `:is(P ~
:is(.foo .bar))`.
- **`&` appears only once**, so stacked variants grow the selector
linearly. A unit test with 12 stacked variants guards against
exponential growth.
- **The selectors are also shorter:** 6 bytes of wrapping instead of 7.
- **The outer `:is(…)`** keeps compound variants such as `has-group-*`,
`not-group-*`, and `in-group-*` equivalent. For example, `has-group-*`
can still match when the group sits outside the element carrying the
utility.

**Accepted edge case:** if a stylesheet declares a default `@namespace`,
the old trailing `*` limited matches to elements in that namespace.
Inside compound variants such as `group-group-*` or `has-group-*`, the
new selector no longer does, so an SVG element (e.g. inside
`foreignObject`) can now count as the inner group. Appending `:is(*)` to
the target would restore the old behavior with no performance cost, but
it makes every selector longer for a combination (`@namespace` + mixed
namespaces + compound group variants) that is very unlikely in practice.
We can add it back if anyone runs into this.

There's one known browser quirk this PR doesn't change: Chromium doesn't
invalidate `has-group-*` when the focused group is an ancestor *outside*
the element. That happens with both the old and new selectors.

## Test plan

- [x] Updated unit test snapshots for the new selector shape
- [x] New unit test that bounds selector size with 12 stacked variants
- [x] New browser tests in `packages/tailwindcss/tests/ui.spec.ts`, run
in Chromium, Firefox, and WebKit. They cover `group-*`/`peer-*` focus
and blur, `@apply` inside a complex selector, specificity, stacked
groups in either order, and compound `group-peer-*`/`peer-group-*`. The
pre-PR selectors pass all of them too, so behavior is unchanged
- [x] `pnpm run test` and `pnpm run test:ui` pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 21:32:41 +02:00
Dex
9798a8ab5b
fix: correct two comment typos (#20507)
Fixes two typos in code comments (no behavior affected):

- `packages/@tailwindcss-cli/src/commands/build/index.ts`: `Succesfully`
-> `Successfully`
- `packages/tailwindcss/src/candidate.ts`: `preceeded` -> `preceded`

Co-authored-by: Dextheking1 <4175097+Dextheking1@users.noreply.github.com>
2026-09-25 11:28:27 +00:00
한국
722acdfebf
Don't treat \ as an escape inside CSS comments (#20508)
<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new
features.**

It's never a fun experience to have your pull request declined after
investing a lot of time and effort into a new feature. To avoid this
from happening, we request that contributors create a discussion to
first discuss any significant new features.

For more info, check out the contributing guide:


https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md

-->

## Summary

<!--

Provide a summary of the issue and the changes you're making. How does
your change solve the problem?

-->

The CSS parser treated `\` inside comments as an escape and skipped the
character after it. Because of that, a comment ending in `\*/` was never
closed, and the CSS that followed was swallowed or turned into a broken
rule:

```css
/* C:\temp\*/
.a { color: red }
```

Before this change the `.a` rule disappeared entirely, and `/* \*/ .a {
color: red }` produced the selector `* \*/ .a`. The same thing happened
to comments inside declaration values, where the comment ran on until
the next `*/` it could find, pulling following declarations into the
value.

Per [CSS Syntax Level 3
§4.3.2](https://www.w3.org/TR/css-syntax-3/#consume-comment), a comment
ends at the first `*/` and escapes aren't processed inside comments.
This removes the backslash handling from both comment-scanning loops in
`css-parser.ts` (top-level and inside declaration values).

The existing test `/*Hello, \*\/ world!*/` keeps passing, since that
input contains no `*/` before the final one.

## Test plan

<!--

Explain how you tested your changes. Include the exact commands that you
used to verify the change works and include screenshots/screen
recordings of the update behavior in the browser if applicable.

-->

Added two tests to `packages/tailwindcss/src/css-parser.test.ts` (both
run with Unix and Windows line endings):

- a top-level comment ending in `\*/` followed by a rule
- a comment ending in `\*/` inside a custom property value, followed by
another declaration

Both fail without the change to `css-parser.ts` and pass with it.

```sh
pnpm vitest run packages/tailwindcss/src/css-parser.test.ts
pnpm vitest run --project tailwindcss
pnpm run lint
```

---------

Co-authored-by: Robin Malfait <robin.malfait@shopify.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-09-25 11:16:58 +00:00
Alexandre Kohler
86f3daf912
Sort breakpoint variants with decimal values numerically (#20512)
## Summary

`compareBreakpoints` compared values in the same unit with `parseInt`,
which drops the fractional part, so `40.25rem` and `40.5rem` were
treated as equal. As a result `min-[40.5rem]:*` could be emitted before
`min-[40.25rem]:*` (the same happened for `@min-*` container queries),
which means the smaller breakpoint wins in the cascade. This switches
the comparison to `parseFloat` so decimal values are ordered
numerically; values that were already sorted correctly are unaffected.

## Test plan

- Added a test to `packages/tailwindcss/src/variants.test.ts` that sorts
`min-[40.25rem]`, `min-[40.5rem]`, `max-[40.25rem]` and `max-[40.5rem]`.
It fails without the change (the `min-[40.5rem]` rule is emitted before
`min-[40.25rem]`) and passes with it.
- `vitest run src/variants.test.ts -t "decimal values"` (in
`packages/tailwindcss`)
- `vitest run` (in `packages/tailwindcss`): 42 files, 5009 tests passed
- `prettier --check` on the changed files
2026-09-25 11:07:37 +00:00
cui fliter
41d9cae8e5
Reject candidates with multiple modifiers (#20466)
<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new
features.**

It's never a fun experience to have your pull request declined after
investing a lot of time and effort into a new feature. To avoid this
from happening, we request that contributors create a discussion to
first discuss any significant new features.

For more info, check out the contributing guide:


https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md

-->

## Summary

<!--

Provide a summary of the issue and the changes you're making. How does
your change solve the problem?

-->

`segment()` preserves empty top-level segments, but the candidate and
variant parsers currently use the truthiness of the third segment to
detect additional modifiers.

As a result, inputs such as `bg-red-500/50/`, `bg-red-500/50//foo`,
`group-hover/foo/:flex`, and `group-hover/foo//bar:flex` can be parsed
as valid candidates even though they contain multiple slash modifier
segments.

This change checks the number of segments instead of the value of the
third segment. Single modifiers such as `bg-red-500/50` and
`group-hover/foo:flex` continue to parse normally, while all additional
top-level `/` segments are rejected.


## Test plan

<!--

Explain how you tested your changes. Include the exact commands that you
used to verify the change works and include screenshots/screen
recordings of the update behavior in the browser if applicable.

-->

- `pnpm exec vitest run packages/tailwindcss/src/candidate.test.ts
--hideSkippedTests`
- `pnpm exec vitest run packages/tailwindcss/src --hideSkippedTests`
- `pnpm exec prettier --check packages/tailwindcss/src/candidate.ts
packages/tailwindcss/src/candidate.test.ts`

`pnpm --filter=tailwindcss lint` was also attempted, but currently fails
on existing cross-package dependency and Bun type errors unrelated to
this change.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-09-08 13:46:59 +00:00
Wei
f723e834ad
Don't warn about Angular's ::ng-deep and :host-context() when optimizing CSS (#20434)
Fixes #20433.

The warning filter in `optimize.ts` already ignores `:deep()`,
`:slotted()` and `:global()`. Angular's two deep selectors are the same
kind of thing — non-standard pseudo-selectors that the framework's
compiler resolves before the CSS reaches a browser — but they aren't
covered, so every Angular component stylesheet using them prints a
warning block per occurrence.

```
Found 2 warnings while optimizing generated CSS:

Issue #1:
│ :host ::ng-deep .some-child, :host
┆        ^-- 'ng-deep' is not recognized as a valid pseudo-element. Did you mean ':ng-deep' (pseudo-class) or is this a typo?
```

Angular strips both during view-encapsulation shimming — `::ng-deep` via
`_shadowDeepSelectors = /(?:>>>)|(?:\/deep\/)|(?:::ng-deep)/g`, and
`:host-context()` in the same pass — so neither ever reaches a browser.

Worth noting that `/deep/` and `>>>`, Angular's two other spellings of
the deep selector, already pass silently because
`nonStandard.deepSelectorCombinator` is enabled. `::ng-deep` is the only
one that warns, and it's the spelling the Angular docs use — so in
practice every Angular codebase hits this. On the workspace where I ran
into it (7 Angular apps), a production build printed 425 `ng-deep`
warnings and 2 `host-context` ones.

## Test plan

There's no automated coverage here because the warning path is behind
`process.env.NODE_ENV !== 'test'`, so a spy sees nothing under Vitest
regardless of the filter — the same reason #20277 shipped without one.
Instead I ran the file before and after the change against Lightning CSS
1.33.0 directly, counting emitted warning blocks:

| Input | Before | After |
| --- | --- | --- |
| `:host ::ng-deep .a, :host ::ng-deep .b { … }` | 1 | 0 |
| `:host-context(.dark) .a { … }` | 1 | 0 |
| `:deep(.a) { … }` | 0 | 0 |
| `.a::totally-not-real { … }` — genuine typo | 1 | **1** |

Generated CSS is byte-identical before and after; only the warning is
suppressed. Genuine unknown pseudo-selectors still warn, so the typo
hint the message exists for is preserved.

Happy to restructure this if you'd prefer the predicate extracted so it
can be unit-tested, or to split `ng-deep` and `host-context` into their
own block rather than extending the existing regex.

A minimal reproduction of the original issue is at
https://gist.github.com/weilinzung/ace42ceb95c2f47b6747cc1f34f162bd.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-31 10:14:54 +00:00
Simon Bobrov
9f451eead8
Cold start performance improvements (#20427)
<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new
features.**

It's never a fun experience to have your pull request declined after
investing a lot of time and effort into a new feature. To avoid this
from happening, we request that contributors create a discussion to
first discuss any significant new features.

For more info, check out the contributing guide:


https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md

-->

## Summary

Hi guys, thanks for you great work!

I work on performance improvements on
https://github.com/schoero/eslint-plugin-better-tailwindcss project.
Part of issues can be fixed on the tailwind side only.

This is a first fix, I have a bigger one in mind, it would require a
small additional public API method - out of scope of this PR.

Please let me know what you think.

## Finding

`getVariantOrder()` re-sorts all parsed variants with the (expensive)
variant comparator on every call, and it is called by every
compileCandidates() invocation — per build pass, per @apply
substitution, and hundreds of times during candidate canonicalization
via the variant signature caches. Since parsedVariants is append-only,
the computed order only changes when a new variant is parsed, so we can
cache the result and invalidate on parsedVariants.size. This makes cold
canonicalization (IntelliSense, lint plugins) faster and removes
repeated sorting from @apply substitution and incremental rebuilds.

## Test plan

Tested at https://github.com/smnbbrv/better-tailwindcss-bench . The
relevant part is the `epbt-now / tw-patched`

**plugin cost** (ms, cold run net of parse baseline)

| codebase | epbt-now / tw-now | epbt-patched / tw-now | epbt-now /
tw-patched | epbt-patched / tw-patched |
| ---------- | ----------------: | --------------------: |
--------------------: | ------------------------: |
| mixed | 6292 | 3965 (-37%) | 5395 (-14%) | 3595 (-43%) |
| repetitive | 5279 | 2425 (-54%) | 4819 (-9%) | 2124 (-60%) |
| unique | 6096 | 5458 (-10%) | 5507 (-10%) | 5295 (-13%) |
2026-08-31 10:05:03 +00:00
Srinu desetti
90f8ff41c8
Don't space out and/or/not inside function calls in supports-[…] variants (#20420)
## Summary

The `supports-[…]` variant works around a Chrome bug where `@supports
(a)or(b)`
is invalid by spacing out the `and`, `or`, and `not` keywords. However,
the
replacement is applied to the entire value, including the inside of
function
calls, where these words can be part of a selector.

For example, `supports-[selector(a:not(.foo))]:flex` generates:

```css
@supports selector(a: not (.foo))
```

The selector `a: not (.foo)` is unparsable, so a condition that is true
in
every browser silently becomes false and the utility never applies. The
same
happens to class names like `.and` or `.or` inside `selector(…)`.

This PR only spaces out the keywords at the condition level: parens
preceded by
an identifier (other than the keywords themselves) start a function
call, and
everything inside is left as-is. The Chrome workaround still applies to
the
condition itself, e.g. `supports-[(display:grid)or(display:flex)]` still
becomes `@supports (display: grid) or (display: flex)`.

## Test plan

- Added a test covering `selector(a:not(.foo))`, class names
`.and`/`.or`
inside `selector(…)`, the Chrome `(a)or(b)` workaround, and a top-level
  `not(…)` condition.
- `pnpm vitest run packages/tailwindcss/src/variants.test.ts` — 102
passed.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-14 19:43:33 +00:00
Srinu desetti
7a7f386648
Don't emit utilities that resolve a theme value when an unsupported modifier is used (#20419)
## Summary

Modifiers on functional utilities that resolve a named theme value are
silently
dropped instead of invalidating the candidate. For example, with the
default theme:

- `rounded-sm/[5]` emits the same CSS as `rounded-sm` (the `[5]` is
ignored)
- `shadow-sm/foo`, `inset-shadow-sm/foo`, `text-shadow/foo`, and
`drop-shadow/foo`
  emit the full shadow CSS with the invalid `foo` modifier ignored

This is inconsistent with how every sibling code path behaves:
`rounded/foo`,
`rounded-[4px]/foo`, `rounded-sm/5`, and `drop-shadow-xl/foo` all
correctly
produce no output, because those paths check `candidate.modifier`.

This PR adds the missing guards:

- In the generic `functionalUtility` handler, a candidate whose named
value
resolves from the theme now rejects modifiers (except fractions like
`w-1/2`,
  where the modifier is part of the resolved value).
- The `shadow`, `inset-shadow`, and `text-shadow` utilities now apply
the same
`if (candidate.modifier && !alpha) return` guard in their default-value,
arbitrary-value, and named-size branches that `drop-shadow` already
applies,
  and `drop-shadow` gets it in its default-value branch too.

Existing tests asserting candidates like `drop-shadow/foo` produce no
output were
passing for the wrong reason: they run without a theme, so the theme
lookup fails
before the modifier is ever considered. The new tests provide a theme so
the
invalid modifier is what invalidates the candidate.

## Test plan

- Added assertions to the `rounded`, `filter`, `shadow`, `inset-shadow`,
and
`text-shadow` tests that compile candidates with invalid modifiers
against a
theme that defines the relevant values, and expect no output. All of
them fail
  without the fix.
- `pnpm vitest run packages/tailwindcss/src/utilities.test.ts` — 398
passed.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-14 16:47:34 +00:00
Robin Malfait
021b7fe6f2
Canonicalization: prevent inlining CSS-wide keywords (#20417)
This PR fixes an issue where canonicalization suggestions in
intellisense result in 'weird' suggestions.

```
The class text-foreground/60 can be written as text-default-soft-hover
```

If we look at the CSS provided by the issue, this doesn't immediately
make sense:
```css
@theme {
  --color-foreground: var(--foreground);
  --color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent);
}

:root {
  --foreground: oklch(0.2103 0.0059 285.89); /* near-black */
  --default: oklch(94% 0.001 286.375); /* light gray */
}
```

But it turns out that when you use Uniwind (React Native) with HeroUI,
that the setup looks more like this:
```css
@import 'tailwindcss';

@theme {
  --foreground: unset;
  --default: unset;
}

@theme inline {
  --color-foreground: var(--foreground);
  --color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent);
}
```

During the canonicalization step, we inline all the `@theme` values, the
reason for this is that `text-[#fff]` can be turned into `text-white`
even though they look slightly different:
```css
.text-\[\#fff\] {
  color: #fff;
}
.text-white {
  color: var(--color-white, #fff);
}
```

But when we inline them, it looks like this:
```css
.text-\[\#fff\] {
  color: #fff;
}
.text-white {
  color: #fff;
}
```

Internally, we use signatures to make sure that they are safe to be
subtituted with eachother. In this case, the signatures will look like
this:
```css
.x {
  color: #fff;
}
.x {
  color: #fff;
}
```

If we now look at the signatures of the original issue, you would see:
```css
/* text-foreground/60 */
.x {
  color: color-mix(in_oklab,unset_60%,transparent);
}

/* text-default-soft-hover */
.x {
  color: color-mix(in_oklab,unset_60%,transparent);
}
```

That's because the `@theme` variables were inlined, resulting in the
exact same signature, thus we consider them the same.

This PR makes sure to never inline [CSS-wide
keywords](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/Data_types#css-wide_keywords)
(such as `unset`) and therefore keeping the CSS variable reference:
```css
/* text-foreground/60 */
.x {
  color: color-mix(in_oklab,var(--foreground)_60%,transparent);
}

/* text-default-soft-hover */
.x {
  color: color-mix(in_oklab,var(--default)_60%,transparent);
}
```

Fixes:
https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1610

## Test plan

1. Existing tests pass
2. Added a regression test

Checked manually in the regression repo

Before:
<img width="1358" height="421" alt="image"
src="https://github.com/user-attachments/assets/8ab3de97-17e4-4e11-b335-bb37218ff2ee"
/>

After:
<img width="1177" height="247" alt="image"
src="https://github.com/user-attachments/assets/cbbfcf04-3ed3-4fa7-a3eb-ef607239f4ad"
/>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-14 12:16:00 +02:00
Robin Malfait
f7f58f0806
Move debug logs in .tailwindcss folder (#20416)
Instead of writing debug logs (triggered by using `DEBUG=*`) in the root
of the project as `tailwindcss-<pid>.log`, we will write them to
`.tailwindcss/logs/scanner-<timestamp>-<pid>.log` instead.

The reasoning for this is that we won't pollute the root of the project.
Another reason is that if you don't ignore `.log` files via
`.gitignore`, it could be that you end up in a loop if you use `DEBUG=*
vite` because a new `.log` file might trigger a new build. We noticed
this while looking at
https://github.com/tailwindlabs/tailwindcss/discussions/20382.

The `.tailwindcss` folder will come with a `.gitignore` file that
ignores everything (`*`).

## Test plan

1. Nothing really changed here, all tests should pass


Testing this in a local project, it looks like this:
<img width="483" height="165"
alt="file-35bfc43606c556f2380a161c7595d103"
src="https://github.com/user-attachments/assets/629519c8-8f2b-46ee-8046-9ba3749f319a"
/>
2026-08-13 23:16:30 +02:00
Robin Malfait
de9e71c599
fix referenced link 2026-08-13 21:31:51 +02:00
Robin Malfait
00ef99df3d
Do not force full page reloads when using @tailwindcss/vite (#20414)
This PR removes all of the custom HMR handling we had in the
`@tailwindcss/vite` plugin.

When Vite 7.1 was introduced, Vite stopped performing a full page reload
for unknown files and instead started performing normal `hmr` updates.
This resulted in this issue:
https://github.com/tailwindlabs/tailwindcss/issues/19637

At the time, it felt like something we could easily re-add: if a file is
not covered by Vite, we can perform a `full-reload`. This meant that a
`.php` file would trigger a full page reload as expected.

The reason the `.php` file triggered Vite in the first place is because
those files were scanned by us (`@tailwindcss/vite`) so it made sense.

However, this then resulted in a plethora of issues, and it feels a bit
like a game of whac-a-mole.

- https://github.com/tailwindlabs/tailwindcss/issues/19744
- https://github.com/tailwindlabs/tailwindcss/issues/19903
- https://github.com/tailwindlabs/tailwindcss/issues/20320
- https://github.com/tailwindlabs/tailwindcss/issues/20378
- https://github.com/tailwindlabs/tailwindcss/issues/20411

Fixes: #19744
Fixes: #19903
Fixes: #20320
Fixes: #20378
Fixes: #20411

We kept updating the logic by safelisting certain extensions, checking
different servers and/or environments, handling the fact that `server`
in the callback could be absent in `experimental.bundledDev` mode, etc.
etc.

Now, when investigating the last issue
(https://github.com/tailwindlabs/tailwindcss/issues/20411), I can
trigger full reloads by changing `.json`, `.yaml` or `.svg` files. This
makes sense since they aren't handled by default.

So thinking about this more, I think it's just not Tailwind's
responsibility to tell Vite to reload the browser or not. Yes, we use
the `addWatchFile` API, so files are being watched because of us.
However, our only goal is to update the `.css` file (and HMR that).

This means that we can just drop all the custom HMR handling we have in
`@tailwindcss/vite`.

This also means that
https://github.com/tailwindlabs/tailwindcss/issues/19637 would regress
and won't trigger full page reloads. But this can be easily handled by a
plugin responsible for this behavior:

- https://github.com/ElMassimo/vite-plugin-full-reload

## Test plan

1. All tests pass
2. Manually tested and changing unknown files don't result in a full
page reload
2026-08-13 17:00:08 +02:00
Matt Van Horn
b9286a7346
fix: Drop invalid UTF-8 scanner candidates (#20389)
## Summary

Replace the three unchecked scanner conversions in
`crates/oxide/src/scanner/mod.rs` with checked conversions that omit
only extracted slices that are not valid UTF-8. Apply the check in the
shared `extract` pipeline so initial scans, incremental `scan_content`
calls, and CSS-variable extraction cannot insert invalid strings into
scanner state, and apply the same policy to both branches of
`get_candidates_with_positions` while preserving byte offsets and the
legacy `-[]` restoration. Keep the extractor's byte-oriented CSS
identifier classification unchanged: accepting non-ASCII bytes during
extraction is useful for valid multibyte code points, while the
conversion boundary is the authoritative place to enforce the `String`
contract.

The scanner currently converts extracted byte slices with unchecked
UTF-8 constructors at the shared extraction boundary and both
candidate-with-position branches. A source file containing a stray
continuation byte can therefore produce an invalid `String`, violating
Rust's string invariant and allowing corrupted candidates to persist in
a long-lived scanner. The thread provides a deterministic reproduction
using invalid bytes inside an arbitrary value, so the problem no longer
depends on reproducing the originally reported Turbopack race. Valid
candidates found alongside malformed byte sequences must continue to be
returned normally.

Fixes #20368

## Test plan

Not applicable to this change.

AI was used for assistance.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-13 13:01:13 +02:00
Robin Malfait
84bea42b6c
Visualize scanner related tests (#20412)
This PR improves the scanner related tests a bit. Right now all the
scanner tests (a lot of them) are somewhat hard to reason about and to
figure out whether these are actually correct or not.

With this PR, the existing are extended with a visualization of the file
system tree, but with indicators to know whether or not a file was
scanned or not. If a `.gitignore` is present, it's content is also shown
to make it easier to see why some files are included or not included.

One of the outputs look like this:
```
.
├── ✗ .gitignore
│       src/ignored-by-gitignore.html
└── ✓ src
    ├── ✗ ignore-by-extension.bin
    ├── ✓ ignored-by-gitignore.html
    ├── ✗ ignored-by-source-not.html
    ├── ✓ keep-me.html
    └── ✓ new-file.html
```

I'm sure we can cleanup a lot of these tests, simplify, deduplicate, but
I didn't want to do that as part of this PR. This PR is purely adding
this visualization.

This uses the [insta.rs](https://insta.rs) library for (inline) snapshot
tests. If at some point the snapshots change, then you get a nice UI:
<img width="958" height="670" alt="image"
src="https://github.com/user-attachments/assets/652cf48f-e1b1-4fe5-a42a-29c5a888063a"
/>


## Test plan

1. All tests should pass [ci-all]
2. Nothing in the code itself changed, so nothing should break

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-08-13 12:45:40 +02:00
Robin Malfait
606438c6c3
rebase ignore crate from 0.4.24 to 0.4.33 (#20409)
This PR updates the vendored `ignore/` crate to make use of the upstream
improvements.

We can't fully use the native `ignore` crate yet because of the missing
functionality that we added ourselves. Our own changes are still marked
as `CHANGED:`

## Test plan

1. All tests should still pass [ci-all]

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-08-13 12:45:40 +02:00
Syed Sohail Hussain
8ac18c7e72
test: fix 'with with' typo in css parser tests (#20410)
<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new
features.**

It's never a fun experience to have your pull request declined after
investing a lot of time and effort into a new feature. To avoid this
from happening, we request that contributors create a discussion to
first discuss any significant new features.

For more info, check out the contributing guide:


https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md

-->

## Summary

<!--

Provide a summary of the issue and the changes you're making. How does
your change solve the problem?

-->

## Test plan

<!--

Explain how you tested your changes. Include the exact commands that you
used to verify the change works and include screenshots/screen
recordings of the update behavior in the browser if applicable.

-->
2026-08-13 11:05:30 +02:00
Marick van Tuil
b86a6e0a0d
Fix slow Vite rebuilds in projects with large gitignored directories (#20408)
Preface: For full transparency, I used AI (Claude) to help dig into this
issue we're having and write some of the explanation below.

Our local Laravel application (with Docker) takes between 2-6s seconds
(re)building `app.css` after each Blade file update. After checking with
Claude, there seems to be some potential unnecessary traversing of big
(gitignored) directories, specifically `vendor` and `storage`.

As far as I understand it, two scans happen in tailwindcss:

- a first scan, the content scan. This one reads the project files
(auto-detect). It respects .gitignore, so it correctly skips vendor/ and
storage/.

- a second scan, the glob resolution (resolve_globs() in the oxide
crate). It builds watch patterns that the Vite plugin registers, so a
file save can trigger a CSS rebuild. It walks the project again, but
this walk does not respect .gitignore. It only skips a hardcoded list of
directory names from `ignored-content-dirs.txt` (node_modules, .git,
venv, ...). vendor/ and storage/ are not on that list, so it descends
into them and stats every file. These directories can never produce a
watch pattern, because the content scan never visited them. That work is
perhaps wasted?

Maybe why this went unnoticed: on a native filesystem a stat takes about
1µs, so 57k of them is around 0.06s. A Docker bind mount multiplies that
by roughly 100. And the big directory in a typical JS project is
`node_modules`, which the name list already catches; `vendor/` is the
PHP ecosystem's `node_modules`, and it is not on that list. (#19616 /
#19632 fixed the same problem for `scanner.files`; the CLI never reads
`scanner.globs`, so it was never affected.)

The fix: the second walk now skips any directory the scan never visited,
the same pruning the first walk (line 73) already does.

Measured on our local project: `get_globs()` after `scan()` went from
9.9s to somewhere between 0.2 and 1.0s across runs, with identical
output (35 globs). A template save now reloads in about 0.3s instead of
4.

## Test plan

- `cargo test` in `crates/oxide`: 66 tests pass, including the glob
assertions for nested and gitignored directories in `tests/scanner.rs`.
- Compared the sorted `get_globs()` output before and after on the
project above: identical.

I've also set up a repo that reproduces the issue in a fresh Laravel
app:

https://github.com/marickvantuil/tailwind-scan-reproduce

The results:

| Environment | vendor/ | scan | get_globs | syscalls under vendor/ |
| --- | --- | --- | --- | --- |
| GitHub Actions (native FS) | present | 5.0 ms | 48.8 ms | 11,410 |
| GitHub Actions (native FS) | absent | 4.5 ms | 0.9 ms | n/a |
| macOS, Docker bind mount | present | 15.8 ms | 599.4 ms | 11,410 |
| macOS, Docker bind mount | absent | 16.5 ms | 6.5 ms | n/a |

---------

Co-authored-by: Marick van Tuil <marick@dolphiq.nl>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-12 14:31:47 +00:00
Ridwan Olanrewaju
16e94cbf7f
Make createCompatConfig theme mappings safe to spread when a namespace returns a string (#20399)
When building the legacy config compat layer, namespace mappings like
`fontSize` → `text`, `boxShadow` → `shadow`, `animation` → `animate`,
etc. spread the result of `theme(namespace, {})` directly:

```ts
...(theme('text', {}) ?? {})
```

Some of these namespaces can return a **string** (e.g. `theme('text',
{})` → `'1rem'`). Spreading a string produces char-indexed keys (`{ '0':
'1', '1': 'r', ... }`) instead of a `{ DEFAULT: ... }` entry, silently
corrupting the compat config output.

This PR adds a small `spreadTheme` helper that normalizes the return
value before spreading:

- `string` → `{ DEFAULT: value }`
- plain object → `{ ...value }`
- anything else → `{}`

and updates all namespace mappings to use it. Includes unit tests for
the helper plus integration tests for the affected namespaces and the
string-return regression cases.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-10 15:22:33 +00:00
Robin Malfait
46df7ee2fc
Don't scan ignored folders using .gitignore safelist setup (#20397)
This PR fixes an issue where a folder containing a `.gitignore` that
uses a whitelist setup results in too many folders being scanned:

```gitignore
# .gitignore
/*
!/app
```

We want to make sure that we respect the `.gitignore` file, but we also
need to make sure that `@source` directives 'win' from `.gitignore`
files. For example:

```css
@import 'tailwindcss';
@source "./node_modules/my-package/*.js";
```

In this case, `node_modules` is likely ignored via `.gitignore`, but you
_do_ want to include the files from the `my-package` folder, essentially
overriding the `.gitignore` rules.

To do this, we internally mark this folder as an `External` folder such
that it gets read, because that's what we want.

The current bug has a pattern like `/*` and then safelist/whitelist
patterns to include certain folders. The problem with the `/*` pattern
is that this convert the _current_ folder to an `External` folder. This
is wrong. The current folder cannot be ignored by the current
`.gitignore` file itself. You can ignore files and folders _inside_ the
current folder, but not the folder itself. The current folder can only
be ignored by parent `.gitignore` rules.

Originally discussed in:
https://github.com/tailwindlabs/tailwindcss/discussions/20382
Fixes: #20385

## Test plan

1. Added regression tests
2. Added integration tests

Manually tested, and logging the scanned files does result in some
interesting results:


<details>

<summary>Before</summary>

```
Reading ".git/COMMIT_EDITMSG"
Reading ".git/HEAD"
Reading ".git/MERGE_RR"
Reading ".git/config"
Reading ".git/description"
Reading ".git/hooks/applypatch-msg.sample"
Reading ".git/hooks/commit-msg.sample"
Reading ".git/hooks/fsmonitor-watchman.sample"
Reading ".git/hooks/post-update.sample"
Reading ".git/hooks/pre-applypatch.sample"
Reading ".git/hooks/pre-commit.sample"
Reading ".git/hooks/pre-merge-commit.sample"
Reading ".git/hooks/pre-push.sample"
Reading ".git/hooks/pre-rebase.sample"
Reading ".git/hooks/pre-receive.sample"
Reading ".git/hooks/prepare-commit-msg.sample"
Reading ".git/hooks/push-to-checkout.sample"
Reading ".git/hooks/sendemail-validate.sample"
Reading ".git/hooks/update.sample"
Reading ".git/index"
Reading ".git/info/exclude"
Reading ".git/logs/HEAD"
Reading ".git/logs/refs/heads/main"
Reading ".git/objects/02/2fe3aa1b28300c0146acdff4eb3240cd2a861b"
Reading ".git/objects/17/45509b3ec34de9d8ddd6708c9d73f191c63c48"
Reading ".git/objects/1e/d83f044a80cfec0843a0d68fa859c101e4e02f"
Reading ".git/objects/60/76888f3ae7ecd2712c236933314e1c420e52f2"
Reading ".git/objects/76/e5d55dc7d51e47bff78b27e39a13110e17c072"
Reading ".git/objects/89/7b4529b9a3db489cc00e2cff4bbd1a2f09fb50"
Reading ".git/objects/8c/781ce3374b53455c284bef51bffbee5ca88969"
Reading ".git/objects/b2/ceaa157eef0b2a8d0231a1396b17684da71de7"
Reading ".git/objects/da/7ef6439d0779bae07668b42d77dcbce2f84111"
Reading ".git/refs/heads/main"
Reading ".gitattributes"
Reading ".gitignore"
Reading "app/index.html"
Reading "build/index.html"
Reading "node_modules/.modules.yaml"
Reading "node_modules/.package-map.json"
Reading "node_modules/.pnpm-workspace-state-v1.json"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/LICENSE"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/README.md"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/package.json"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/src/scopes.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/src/sourcemap-codec.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/src/strings.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/src/vlq.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/LICENSE"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/README.md"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.umd.js"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.umd.js.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/package.json"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/src/binary-search.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/src/by-source.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/src/flatten-map.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/src/resolve.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/src/sort.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/src/sourcemap-segment.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/src/strip-filename.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/src/trace-mapping.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/src/types.ts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/binary-search.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/binary-search.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/binary-search.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/binary-search.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/by-source.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/by-source.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/by-source.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/by-source.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/resolve.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/resolve.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/resolve.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/resolve.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/sort.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/sort.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/sort.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/sort.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/types.d.cts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/types.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/types.d.mts"
Reading "node_modules/.pnpm/@jridgewell+gen-mapping@0.3.13/node_modules/@jridgewell/trace-mapping/types/types.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/LICENSE"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/README.md"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.mjs"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.mjs.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.umd.js"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.umd.js.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/dist/types/gen-mapping.d.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/dist/types/set-array.d.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/dist/types/sourcemap-segment.d.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/dist/types/types.d.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/package.json"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/src/gen-mapping.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/src/set-array.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/src/sourcemap-segment.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/src/types.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/set-array.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/set-array.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/set-array.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/set-array.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/types.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/types.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/types.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/gen-mapping/types/types.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/LICENSE"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/README.md"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/dist/remapping.mjs"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/dist/remapping.mjs.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/dist/remapping.umd.js"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/dist/remapping.umd.js.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/package.json"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/src/build-source-map-tree.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/src/remapping.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/src/source-map-tree.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/src/source-map.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/src/types.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/build-source-map-tree.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/build-source-map-tree.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/build-source-map-tree.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/build-source-map-tree.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/remapping.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/remapping.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/remapping.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/remapping.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/source-map-tree.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/source-map-tree.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/source-map-tree.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/source-map-tree.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/source-map.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/source-map.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/source-map.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/source-map.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/types.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/types.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/types.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/remapping/types/types.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/LICENSE"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/README.md"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.umd.js"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.umd.js.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/package.json"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/src/binary-search.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/src/by-source.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/src/flatten-map.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/src/resolve.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/src/sort.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/src/sourcemap-segment.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/src/strip-filename.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/src/trace-mapping.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/src/types.ts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/binary-search.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/binary-search.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/binary-search.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/binary-search.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/by-source.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/by-source.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/by-source.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/by-source.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/resolve.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/resolve.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/resolve.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/resolve.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/sort.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/sort.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/sort.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/sort.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/types.d.cts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/types.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/types.d.mts"
Reading "node_modules/.pnpm/@jridgewell+remapping@2.3.5/node_modules/@jridgewell/trace-mapping/types/types.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+resolve-uri@3.1.2/node_modules/@jridgewell/resolve-uri/LICENSE"
Reading "node_modules/.pnpm/@jridgewell+resolve-uri@3.1.2/node_modules/@jridgewell/resolve-uri/README.md"
Reading "node_modules/.pnpm/@jridgewell+resolve-uri@3.1.2/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.mjs"
Reading "node_modules/.pnpm/@jridgewell+resolve-uri@3.1.2/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.mjs.map"
Reading "node_modules/.pnpm/@jridgewell+resolve-uri@3.1.2/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.umd.js"
Reading "node_modules/.pnpm/@jridgewell+resolve-uri@3.1.2/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.umd.js.map"
Reading "node_modules/.pnpm/@jridgewell+resolve-uri@3.1.2/node_modules/@jridgewell/resolve-uri/dist/types/resolve-uri.d.ts"
Reading "node_modules/.pnpm/@jridgewell+resolve-uri@3.1.2/node_modules/@jridgewell/resolve-uri/package.json"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/resolve-uri/LICENSE"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/resolve-uri/README.md"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.mjs"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.mjs.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.umd.js"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.umd.js.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/resolve-uri/dist/types/resolve-uri.d.ts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/resolve-uri/package.json"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/LICENSE"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/README.md"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/package.json"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/src/scopes.ts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/src/sourcemap-codec.ts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/src/strings.ts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/src/vlq.ts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts.map"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts"
Reading "node_modules/.pnpm/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts.map"
Reading "node_modules/.pnpm/@parcel+watcher-darwin-arm64@2.6.0/node_modules/@parcel/watcher-darwin-arm64/LICENSE"
Reading "node_modules/.pnpm/@parcel+watcher-darwin-arm64@2.6.0/node_modules/@parcel/watcher-darwin-arm64/README.md"
Reading "node_modules/.pnpm/@parcel+watcher-darwin-arm64@2.6.0/node_modules/@parcel/watcher-darwin-arm64/package.json"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher-darwin-arm64/LICENSE"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher-darwin-arm64/README.md"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher-darwin-arm64/package.json"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/LICENSE"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/README.md"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/binding.gyp"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/index.d.ts"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/index.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/index.js.flow"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/package.json"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/scripts/build-from-source.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/Backend.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/Backend.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/Debounce.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/Debounce.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/DirTree.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/DirTree.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/Event.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/Glob.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/Glob.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/PromiseRunner.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/Signal.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/Watcher.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/Watcher.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/binding.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/kqueue/KqueueBackend.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/kqueue/KqueueBackend.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/linux/InotifyBackend.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/linux/InotifyBackend.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/macos/FSEventsBackend.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/macos/FSEventsBackend.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/shared/BruteForceBackend.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/shared/BruteForceBackend.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/unix/fts.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/unix/legacy.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/wasm/WasmBackend.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/wasm/WasmBackend.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/wasm/include.h"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/watchman/BSER.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/watchman/BSER.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/watchman/IPC.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/watchman/WatchmanBackend.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/watchman/WatchmanBackend.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/windows/WindowsBackend.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/windows/WindowsBackend.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/windows/win_utils.cc"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/src/windows/win_utils.hh"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/@parcel/watcher/wrapper.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/detect-libc/LICENSE"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/detect-libc/README.md"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/detect-libc/index.d.ts"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/detect-libc/lib/detect-libc.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/detect-libc/lib/elf.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/detect-libc/lib/filesystem.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/detect-libc/lib/process.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/detect-libc/package.json"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/is-glob/LICENSE"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/is-glob/README.md"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/is-glob/index.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/is-glob/package.json"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/LICENSE.md"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/README.md"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/common.gypi"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/except.gypi"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/index.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/napi-inl.deprecated.h"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/napi-inl.h"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/napi.h"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/node_addon_api.gyp"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/node_api.gyp"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/noexcept.gypi"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/nothing.c"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/package-support.json"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/package.json"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/tools/README.md"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/tools/check-napi.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/tools/clang-format.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/tools/conversion.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/node-addon-api/tools/eslint-format.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/picomatch/LICENSE"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/picomatch/README.md"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/picomatch/index.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/picomatch/lib/constants.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/picomatch/lib/parse.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/picomatch/lib/picomatch.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/picomatch/lib/scan.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/picomatch/lib/utils.js"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/picomatch/package.json"
Reading "node_modules/.pnpm/@parcel+watcher@2.6.0/node_modules/picomatch/posix.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/graceful-fs/LICENSE"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/graceful-fs/README.md"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/graceful-fs/clone.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/graceful-fs/graceful-fs.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/graceful-fs/legacy-streams.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/graceful-fs/package.json"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/graceful-fs/polyfills.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/LICENSE"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/README.md"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/AsyncParallelBailHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/AsyncParallelHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/AsyncSeriesBailHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/AsyncSeriesHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/AsyncSeriesLoopHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/AsyncSeriesWaterfallHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/Hook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/HookCodeFactory.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/HookMap.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/MultiHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/SyncBailHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/SyncHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/SyncLoopHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/SyncWaterfallHook.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/index.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/lib/util-browser.js"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/package.json"
Reading "node_modules/.pnpm/enhanced-resolve@5.24.5/node_modules/tapable/tapable.d.ts"
Reading "node_modules/.pnpm/graceful-fs@4.2.11/node_modules/graceful-fs/LICENSE"
Reading "node_modules/.pnpm/graceful-fs@4.2.11/node_modules/graceful-fs/README.md"
Reading "node_modules/.pnpm/graceful-fs@4.2.11/node_modules/graceful-fs/clone.js"
Reading "node_modules/.pnpm/graceful-fs@4.2.11/node_modules/graceful-fs/graceful-fs.js"
Reading "node_modules/.pnpm/graceful-fs@4.2.11/node_modules/graceful-fs/legacy-streams.js"
Reading "node_modules/.pnpm/graceful-fs@4.2.11/node_modules/graceful-fs/package.json"
Reading "node_modules/.pnpm/graceful-fs@4.2.11/node_modules/graceful-fs/polyfills.js"
Reading "node_modules/.pnpm/is-extglob@2.1.1/node_modules/is-extglob/LICENSE"
Reading "node_modules/.pnpm/is-extglob@2.1.1/node_modules/is-extglob/README.md"
Reading "node_modules/.pnpm/is-extglob@2.1.1/node_modules/is-extglob/index.js"
Reading "node_modules/.pnpm/is-extglob@2.1.1/node_modules/is-extglob/package.json"
Reading "node_modules/.pnpm/is-glob@4.0.3/node_modules/is-extglob/LICENSE"
Reading "node_modules/.pnpm/is-glob@4.0.3/node_modules/is-extglob/README.md"
Reading "node_modules/.pnpm/is-glob@4.0.3/node_modules/is-extglob/index.js"
Reading "node_modules/.pnpm/is-glob@4.0.3/node_modules/is-extglob/package.json"
Reading "node_modules/.pnpm/is-glob@4.0.3/node_modules/is-glob/LICENSE"
Reading "node_modules/.pnpm/is-glob@4.0.3/node_modules/is-glob/README.md"
Reading "node_modules/.pnpm/is-glob@4.0.3/node_modules/is-glob/index.js"
Reading "node_modules/.pnpm/is-glob@4.0.3/node_modules/is-glob/package.json"
Reading "node_modules/.pnpm/lightningcss-darwin-arm64@1.33.0/node_modules/lightningcss-darwin-arm64/LICENSE"
Reading "node_modules/.pnpm/lightningcss-darwin-arm64@1.33.0/node_modules/lightningcss-darwin-arm64/README.md"
Reading "node_modules/.pnpm/lightningcss-darwin-arm64@1.33.0/node_modules/lightningcss-darwin-arm64/package.json"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/detect-libc/LICENSE"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/detect-libc/README.md"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/detect-libc/index.d.ts"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/detect-libc/lib/detect-libc.js"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/detect-libc/lib/elf.js"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/detect-libc/lib/filesystem.js"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/detect-libc/lib/process.js"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/detect-libc/package.json"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss-darwin-arm64/LICENSE"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss-darwin-arm64/README.md"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss-darwin-arm64/package.json"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/LICENSE"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/README.md"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/ast.d.ts"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/ast.js.flow"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/browserslistToTargets.js"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/composeVisitors.js"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/flags.js"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/index.d.ts"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/index.js"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/index.js.flow"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/index.mjs"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/targets.d.ts"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/node/targets.js.flow"
Reading "node_modules/.pnpm/lightningcss@1.33.0/node_modules/lightningcss/package.json"
Reading "node_modules/.pnpm/lock.yaml"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/LICENSE"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/README.md"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs.map"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js.map"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/package.json"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/src/scopes.ts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/src/sourcemap-codec.ts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/src/strings.ts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/src/vlq.ts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts.map"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts.map"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts.map"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts.map"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts.map"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts.map"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts.map"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts.map"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/magic-string/LICENSE"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/magic-string/README.md"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/magic-string/dist/index.d.mts"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/magic-string/dist/index.mjs"
Reading "node_modules/.pnpm/magic-string@1.1.0/node_modules/magic-string/package.json"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/LICENSE"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/README.md"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.mjs"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.mjs.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.umd.js"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.umd.js.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/dist/types/gen-mapping.d.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/dist/types/set-array.d.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/dist/types/sourcemap-segment.d.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/dist/types/types.d.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/package.json"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/src/gen-mapping.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/src/set-array.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/src/sourcemap-segment.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/src/types.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/set-array.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/set-array.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/set-array.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/set-array.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/types.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/types.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/types.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/gen-mapping/types/types.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/LICENSE"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/README.md"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/dist/remapping.mjs"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/dist/remapping.mjs.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/dist/remapping.umd.js"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/dist/remapping.umd.js.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/package.json"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/src/build-source-map-tree.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/src/remapping.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/src/source-map-tree.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/src/source-map.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/src/types.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/build-source-map-tree.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/build-source-map-tree.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/build-source-map-tree.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/build-source-map-tree.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/remapping.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/remapping.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/remapping.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/remapping.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/source-map-tree.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/source-map-tree.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/source-map-tree.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/source-map-tree.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/source-map.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/source-map.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/source-map.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/source-map.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/types.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/types.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/types.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/remapping/types/types.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/resolve-uri/LICENSE"
Reading "node_modules/.pnpm/node_modules/@jridgewell/resolve-uri/README.md"
Reading "node_modules/.pnpm/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.mjs"
Reading "node_modules/.pnpm/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.mjs.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.umd.js"
Reading "node_modules/.pnpm/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.umd.js.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/resolve-uri/dist/types/resolve-uri.d.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/resolve-uri/package.json"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/LICENSE"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/README.md"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/package.json"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/src/scopes.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/src/sourcemap-codec.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/src/strings.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/src/vlq.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/LICENSE"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/README.md"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.umd.js"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.umd.js.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/package.json"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/src/binary-search.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/src/by-source.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/src/flatten-map.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/src/resolve.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/src/sort.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/src/sourcemap-segment.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/src/strip-filename.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/src/trace-mapping.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/src/types.ts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/binary-search.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/binary-search.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/binary-search.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/binary-search.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/by-source.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/by-source.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/by-source.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/by-source.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/resolve.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/resolve.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/resolve.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/resolve.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/sort.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/sort.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/sort.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/sort.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/types.d.cts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/types.d.cts.map"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/types.d.mts"
Reading "node_modules/.pnpm/node_modules/@jridgewell/trace-mapping/types/types.d.mts.map"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher-darwin-arm64/LICENSE"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher-darwin-arm64/README.md"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher-darwin-arm64/package.json"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/LICENSE"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/README.md"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/binding.gyp"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/index.d.ts"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/index.js"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/index.js.flow"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/package.json"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/scripts/build-from-source.js"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/Backend.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/Backend.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/Debounce.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/Debounce.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/DirTree.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/DirTree.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/Event.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/Glob.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/Glob.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/PromiseRunner.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/Signal.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/Watcher.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/Watcher.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/binding.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/kqueue/KqueueBackend.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/kqueue/KqueueBackend.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/linux/InotifyBackend.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/linux/InotifyBackend.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/macos/FSEventsBackend.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/macos/FSEventsBackend.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/shared/BruteForceBackend.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/shared/BruteForceBackend.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/unix/fts.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/unix/legacy.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/wasm/WasmBackend.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/wasm/WasmBackend.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/wasm/include.h"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/watchman/BSER.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/watchman/BSER.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/watchman/IPC.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/watchman/WatchmanBackend.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/watchman/WatchmanBackend.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/windows/WindowsBackend.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/windows/WindowsBackend.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/windows/win_utils.cc"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/src/windows/win_utils.hh"
Reading "node_modules/.pnpm/node_modules/@parcel/watcher/wrapper.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/LICENSE"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/README.md"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/dist/esm-cache.loader.d.mts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/dist/esm-cache.loader.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/dist/index.d.mts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/dist/index.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/dist/index.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/dist/index.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/dist/require-cache.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/dist/require-cache.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/node/package.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-darwin-arm64/LICENSE"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-darwin-arm64/README.md"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-darwin-arm64/package.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/README.md"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/LICENSE"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/README.md"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.cjs.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.cjs.min.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.cjs.min.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.d.mts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.esm-bundler.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.min.d.mts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.min.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.min.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/dist/emnapi-core.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/index.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core/package.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/LICENSE"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/README.md"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.cjs.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.cjs.min.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.cjs.min.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.d.mts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.esm-bundler.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.iife.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.iife.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.min.d.mts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.min.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.min.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/dist/emnapi.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/index.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime/package.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/LICENSE"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/README.md"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.cjs.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.cjs.min.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.cjs.min.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.d.mts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.esm-bundler.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.min.d.mts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.min.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.min.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/dist/wasi-threads.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/index.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads/package.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime/dist/emnapi-plugins.cjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime/dist/emnapi-plugins.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime/dist/fs-proxy.cjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime/dist/fs.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime/fs-proxy.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime/package.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime/runtime.cjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime/runtime.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/README.md"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/dist/tsdoc-metadata.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/dist/wasm-util.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/dist/wasm-util.esm-bundler.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/dist/wasm-util.esm.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/dist/wasm-util.esm.min.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/dist/wasm-util.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/dist/wasm-util.min.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/asyncify.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/index.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/jspi.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/load.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/memory.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/wasi/error.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/wasi/fd.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/wasi/fs.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/wasi/index.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/wasi/path.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/wasi/preview1.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/wasi/rights.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/wasi/types.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/wasi/util.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/cjs/webassembly.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/asyncify.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/index.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/jspi.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/load.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/memory.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/wasi/error.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/wasi/fd.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/wasi/fs.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/wasi/index.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/wasi/path.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/wasi/preview1.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/wasi/rights.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/wasi/types.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/wasi/util.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/lib/mjs/webassembly.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util/package.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/CopyrightNotice.txt"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/LICENSE.txt"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/README.md"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/SECURITY.md"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/modules/index.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/modules/index.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/modules/package.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/package.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/tslib.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/tslib.es6.html"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/tslib.es6.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/tslib.es6.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/tslib.html"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib/tslib.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/package.json"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/tailwindcss-oxide.wasi-browser.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/tailwindcss-oxide.wasi.cjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/wasi-worker-browser.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide-wasm32-wasi/wasi-worker.mjs"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide/LICENSE"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide/index.d.ts"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide/index.js"
Reading "node_modules/.pnpm/node_modules/@tailwindcss/oxide/package.json"
Reading "node_modules/.pnpm/node_modules/detect-libc/LICENSE"
Reading "node_modules/.pnpm/node_modules/detect-libc/README.md"
Reading "node_modules/.pnpm/node_modules/detect-libc/index.d.ts"
Reading "node_modules/.pnpm/node_modules/detect-libc/lib/detect-libc.js"
Reading "node_modules/.pnpm/node_modules/detect-libc/lib/elf.js"
Reading "node_modules/.pnpm/node_modules/detect-libc/lib/filesystem.js"
Reading "node_modules/.pnpm/node_modules/detect-libc/lib/process.js"
Reading "node_modules/.pnpm/node_modules/detect-libc/package.json"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/LICENSE"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/README.md"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/AliasFieldPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/AliasPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/AliasUtils.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/AppendPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/CachedInputFileSystem.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/CloneBasenamePlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ConditionalPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/DescriptionFilePlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/DescriptionFileUtils.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/DirectoryExistsPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ExportsFieldPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ExtensionAliasPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/FileExistsPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ImportsFieldPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/JoinRequestPartPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/JoinRequestPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/LogInfoPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/MainFieldPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ModulesInHierachicDirectoriesPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ModulesInHierarchicalDirectoriesPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ModulesInRootPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ModulesUtils.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/NextPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ParsePlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/PnpPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/Resolver.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ResolverFactory.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/RestrictionsPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/ResultPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/RootsPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/SelfReferencePlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/SymlinkPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/SyncAsyncFileSystemDecorator.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/TryNextPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/TsconfigPathsPlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/UnsafeCachePlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/UseFilePlugin.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/createInnerContext.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/forEachBail.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/getInnerRequest.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/getPaths.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/index.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/entrypoints.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/fileURLToPath.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/fs.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/graceful-fs-browser.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/identifier.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/memoize.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/module-browser.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/path-browser.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/path.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/process-browser.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/strip-json-comments.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/lib/util/url-browser.js"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/package.json"
Reading "node_modules/.pnpm/node_modules/enhanced-resolve/types.d.ts"
Reading "node_modules/.pnpm/node_modules/graceful-fs/LICENSE"
Reading "node_modules/.pnpm/node_modules/graceful-fs/README.md"
Reading "node_modules/.pnpm/node_modules/graceful-fs/clone.js"
Reading "node_modules/.pnpm/node_modules/graceful-fs/graceful-fs.js"
Reading "node_modules/.pnpm/node_modules/graceful-fs/legacy-streams.js"
Reading "node_modules/.pnpm/node_modules/graceful-fs/package.json"
Reading "node_modules/.pnpm/node_modules/graceful-fs/polyfills.js"
Reading "node_modules/.pnpm/node_modules/is-extglob/LICENSE"
Reading "node_modules/.pnpm/node_modules/is-extglob/README.md"
Reading "node_modules/.pnpm/node_modules/is-extglob/index.js"
Reading "node_modules/.pnpm/node_modules/is-extglob/package.json"
Reading "node_modules/.pnpm/node_modules/is-glob/LICENSE"
Reading "node_modules/.pnpm/node_modules/is-glob/README.md"
Reading "node_modules/.pnpm/node_modules/is-glob/index.js"
Reading "node_modules/.pnpm/node_modules/is-glob/package.json"
Reading "node_modules/.pnpm/node_modules/jiti/LICENSE"
Reading "node_modules/.pnpm/node_modules/jiti/README.md"
Reading "node_modules/.pnpm/node_modules/jiti/dist/babel.cjs"
Reading "node_modules/.pnpm/node_modules/jiti/dist/jiti.cjs"
Reading "node_modules/.pnpm/node_modules/jiti/lib/jiti-cli.mjs"
Reading "node_modules/.pnpm/node_modules/jiti/lib/jiti-hooks.mjs"
Reading "node_modules/.pnpm/node_modules/jiti/lib/jiti-native.mjs"
Reading "node_modules/.pnpm/node_modules/jiti/lib/jiti-register.d.mts"
Reading "node_modules/.pnpm/node_modules/jiti/lib/jiti-register.mjs"
Reading "node_modules/.pnpm/node_modules/jiti/lib/jiti-static.mjs"
Reading "node_modules/.pnpm/node_modules/jiti/lib/jiti.cjs"
Reading "node_modules/.pnpm/node_modules/jiti/lib/jiti.d.cts"
Reading "node_modules/.pnpm/node_modules/jiti/lib/jiti.d.mts"
Reading "node_modules/.pnpm/node_modules/jiti/lib/jiti.mjs"
Reading "node_modules/.pnpm/node_modules/jiti/lib/types.d.ts"
Reading "node_modules/.pnpm/node_modules/jiti/package.json"
Reading "node_modules/.pnpm/node_modules/lightningcss-darwin-arm64/LICENSE"
Reading "node_modules/.pnpm/node_modules/lightningcss-darwin-arm64/README.md"
Reading "node_modules/.pnpm/node_modules/lightningcss-darwin-arm64/package.json"
Reading "node_modules/.pnpm/node_modules/lightningcss/LICENSE"
Reading "node_modules/.pnpm/node_modules/lightningcss/README.md"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/ast.d.ts"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/ast.js.flow"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/browserslistToTargets.js"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/composeVisitors.js"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/flags.js"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/index.d.ts"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/index.js"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/index.js.flow"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/index.mjs"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/targets.d.ts"
Reading "node_modules/.pnpm/node_modules/lightningcss/node/targets.js.flow"
Reading "node_modules/.pnpm/node_modules/lightningcss/package.json"
Reading "node_modules/.pnpm/node_modules/magic-string/LICENSE"
Reading "node_modules/.pnpm/node_modules/magic-string/README.md"
Reading "node_modules/.pnpm/node_modules/magic-string/dist/index.d.mts"
Reading "node_modules/.pnpm/node_modules/magic-string/dist/index.mjs"
Reading "node_modules/.pnpm/node_modules/magic-string/package.json"
Reading "node_modules/.pnpm/node_modules/mri/index.d.ts"
Reading "node_modules/.pnpm/node_modules/mri/lib/index.js"
Reading "node_modules/.pnpm/node_modules/mri/lib/index.mjs"
Reading "node_modules/.pnpm/node_modules/mri/license.md"
Reading "node_modules/.pnpm/node_modules/mri/package.json"
Reading "node_modules/.pnpm/node_modules/mri/readme.md"
Reading "node_modules/.pnpm/node_modules/node-addon-api/LICENSE.md"
Reading "node_modules/.pnpm/node_modules/node-addon-api/README.md"
Reading "node_modules/.pnpm/node_modules/node-addon-api/common.gypi"
Reading "node_modules/.pnpm/node_modules/node-addon-api/except.gypi"
Reading "node_modules/.pnpm/node_modules/node-addon-api/index.js"
Reading "node_modules/.pnpm/node_modules/node-addon-api/napi-inl.deprecated.h"
Reading "node_modules/.pnpm/node_modules/node-addon-api/napi-inl.h"
Reading "node_modules/.pnpm/node_modules/node-addon-api/napi.h"
Reading "node_modules/.pnpm/node_modules/node-addon-api/node_addon_api.gyp"
Reading "node_modules/.pnpm/node_modules/node-addon-api/node_api.gyp"
Reading "node_modules/.pnpm/node_modules/node-addon-api/noexcept.gypi"
Reading "node_modules/.pnpm/node_modules/node-addon-api/nothing.c"
Reading "node_modules/.pnpm/node_modules/node-addon-api/package-support.json"
Reading "node_modules/.pnpm/node_modules/node-addon-api/package.json"
Reading "node_modules/.pnpm/node_modules/node-addon-api/tools/README.md"
Reading "node_modules/.pnpm/node_modules/node-addon-api/tools/check-napi.js"
Reading "node_modules/.pnpm/node_modules/node-addon-api/tools/clang-format.js"
Reading "node_modules/.pnpm/node_modules/node-addon-api/tools/conversion.js"
Reading "node_modules/.pnpm/node_modules/node-addon-api/tools/eslint-format.js"
Reading "node_modules/.pnpm/node_modules/picocolors/LICENSE"
Reading "node_modules/.pnpm/node_modules/picocolors/README.md"
Reading "node_modules/.pnpm/node_modules/picocolors/package.json"
Reading "node_modules/.pnpm/node_modules/picocolors/picocolors.browser.js"
Reading "node_modules/.pnpm/node_modules/picocolors/picocolors.d.ts"
Reading "node_modules/.pnpm/node_modules/picocolors/picocolors.js"
Reading "node_modules/.pnpm/node_modules/picocolors/types.d.ts"
Reading "node_modules/.pnpm/node_modules/picomatch/LICENSE"
Reading "node_modules/.pnpm/node_modules/picomatch/README.md"
Reading "node_modules/.pnpm/node_modules/picomatch/index.js"
Reading "node_modules/.pnpm/node_modules/picomatch/lib/constants.js"
Reading "node_modules/.pnpm/node_modules/picomatch/lib/parse.js"
Reading "node_modules/.pnpm/node_modules/picomatch/lib/picomatch.js"
Reading "node_modules/.pnpm/node_modules/picomatch/lib/scan.js"
Reading "node_modules/.pnpm/node_modules/picomatch/lib/utils.js"
Reading "node_modules/.pnpm/node_modules/picomatch/package.json"
Reading "node_modules/.pnpm/node_modules/picomatch/posix.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/LICENSE"
Reading "node_modules/.pnpm/node_modules/source-map-js/README.md"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/array-set.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/base64-vlq.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/base64.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/binary-search.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/mapping-list.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/quick-sort.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/source-map-consumer.d.ts"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/source-map-consumer.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/source-map-generator.d.ts"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/source-map-generator.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/source-node.d.ts"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/source-node.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/lib/util.js"
Reading "node_modules/.pnpm/node_modules/source-map-js/package.json"
Reading "node_modules/.pnpm/node_modules/source-map-js/source-map.d.ts"
Reading "node_modules/.pnpm/node_modules/source-map-js/source-map.js"
Reading "node_modules/.pnpm/node_modules/tapable/LICENSE"
Reading "node_modules/.pnpm/node_modules/tapable/README.md"
Reading "node_modules/.pnpm/node_modules/tapable/lib/AsyncParallelBailHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/AsyncParallelHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/AsyncSeriesBailHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/AsyncSeriesHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/AsyncSeriesLoopHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/AsyncSeriesWaterfallHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/Hook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/HookCodeFactory.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/HookMap.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/MultiHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/SyncBailHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/SyncHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/SyncLoopHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/SyncWaterfallHook.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/index.js"
Reading "node_modules/.pnpm/node_modules/tapable/lib/util-browser.js"
Reading "node_modules/.pnpm/node_modules/tapable/package.json"
Reading "node_modules/.pnpm/node_modules/tapable/tapable.d.ts"
Reading "node_modules/@tailwindcss/cli/LICENSE"
Reading "node_modules/@tailwindcss/cli/README.md"
Reading "node_modules/@tailwindcss/cli/dist/index.mjs"
Reading "node_modules/@tailwindcss/cli/package.json"
Reading "node_modules/my-ui-lib/index.html"
Reading "node_modules/tailwindcss/LICENSE"
Reading "node_modules/tailwindcss/README.md"
Reading "node_modules/tailwindcss/dist/chunk-5JIJA4QV.mjs"
Reading "node_modules/tailwindcss/dist/chunk-C2OYBFIH.mjs"
Reading "node_modules/tailwindcss/dist/chunk-DCG7AFIE.mjs"
Reading "node_modules/tailwindcss/dist/colors-C__qRT83.d.ts"
Reading "node_modules/tailwindcss/dist/colors.d.mts"
Reading "node_modules/tailwindcss/dist/colors.d.ts"
Reading "node_modules/tailwindcss/dist/colors.js"
Reading "node_modules/tailwindcss/dist/colors.mjs"
Reading "node_modules/tailwindcss/dist/default-theme.d.mts"
Reading "node_modules/tailwindcss/dist/default-theme.d.ts"
Reading "node_modules/tailwindcss/dist/default-theme.js"
Reading "node_modules/tailwindcss/dist/default-theme.mjs"
Reading "node_modules/tailwindcss/dist/flatten-color-palette.d.mts"
Reading "node_modules/tailwindcss/dist/flatten-color-palette.d.ts"
Reading "node_modules/tailwindcss/dist/flatten-color-palette.js"
Reading "node_modules/tailwindcss/dist/flatten-color-palette.mjs"
Reading "node_modules/tailwindcss/dist/lib.d.mts"
Reading "node_modules/tailwindcss/dist/lib.d.ts"
Reading "node_modules/tailwindcss/dist/lib.js"
Reading "node_modules/tailwindcss/dist/lib.mjs"
Reading "node_modules/tailwindcss/dist/plugin.d.mts"
Reading "node_modules/tailwindcss/dist/plugin.d.ts"
Reading "node_modules/tailwindcss/dist/plugin.js"
Reading "node_modules/tailwindcss/dist/plugin.mjs"
Reading "node_modules/tailwindcss/dist/resolve-config-B4yBzhca.d.ts"
Reading "node_modules/tailwindcss/dist/resolve-config-QUZ9b-Gn.d.mts"
Reading "node_modules/tailwindcss/dist/types-DWdTiksJ.d.mts"
Reading "node_modules/tailwindcss/package.json"
Reading "package.json"
Reading "pnpm-lock.yaml"
Reading "pnpm-workspace.yaml"
Reading "public/index.html"
Reading "tailwindcss-10742.log"
```

</details>

<details>

<summary>After</summary>

```
Reading "./app/index.html"
Reading "./package.json"
Reading "./public/index.html"
```

</details>
2026-08-07 17:44:33 +02:00
Robin Malfait
e566a926e7
--default(…) should emit values as-is (#20392)
This PR fixes an issue where the `--default(…)` function that can be
used as part of `@utility` definitions goes through the same
pre-processing treatment as `--value(…)` and `--modifier(…)`.

Instead the `--default(…)` value should be emitted as-is.

The pre-processing treatment that's in place today is just to normalize
the value(s) that could be formatted in a strange way if you are dealing
with older Prettier / Biome versions.

Some of the transformations we do ahead of time:

- `--value(--spacing)`                 -> `--value(--spacing-*)`
- `--value(--spacing- *)`              -> `--value(--spacing-*)`
- `--value(--text- * --line-height)` -> `--value(--text-*--line-height)`
- `--value(--text --line-height)` -> `--value(--text-*--line-height)`
- `--value(--text-\\* --line-height)` ->
`--value(--text-*--line-height)`
- `--value([ *])`                      -> `--value([*])`


One of these steps is getting rid of the spaces, that means that if your
`--default(…)` uses spaces, then we will get rid of it.

```css
@theme inline {
  --text-box-trim-start: trim-start;
  --text-box-trim-end: trim-end;
  --text-box-trim-both: trim-both;

  --text-box-edge-cap: cap alphabetic;
  --text-box-edge-cap-text: cap text;
  --text-box-edge-ex: ex alphabetic;
  --text-box-edge-ex-text: ex text;
  --text-box-edge-text: text alphabetic;
  --text-box-edge-text-text: text text;
}
@utility trim-* {
  text-box: --value('normal', 'inherit', 'initial', 'revert', 'revert-layer', 'unset');
  text-box: --value(--text-box-trim-*) --modifier(--text-box-edge-*, --default(box alphabetic));
}
```

When you try to use this:

```html
<div className="trim-both" />
```

Then we get the following CSS out:
```css
.trim-both {
 text-box: trim-both boxalphabetic;
}
```

This PR fixes that, and now the proper CSS will be emitted:
```css
.trim-both {
 text-box: trim-both box alphabetic;
}
```

Fixes: #20391

## Test plan

1. Added an regression test to make sure that `--default(…)` values are
emitted as-is.
2026-08-06 15:25:48 +02:00
Lazizbek Ergashev
b8a5f85889
Detect classes in Ruby percent literals using angle brackets or custom delimiters (#20387)
## Summary

Fixes #20386.

Ruby lets you close a `%w`/`%W` literal with any non-alphanumeric
character, but the pre-processor only recognized `[`, `(`, `{`, `#` and
a space. So `%w<bg-green-400>` and `%w|bg-blue-400|` were skipped and
the classes inside them never made it into the output.

The boundary lookup now maps `<` to `>` alongside the other paired
delimiters, and falls back to treating any other non-alphanumeric,
non-whitespace character as its own closing delimiter.

## Test plan

Added pre-processor and extraction cases for `%w<…>`, `%w|…|`, `%w:…:`
and `%w!…!` in `crates/oxide/src/extractor/pre_processors/ruby.rs`. They
fail on `main` and pass with this change.

```
cargo test -p tailwindcss-oxide
```

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-06 14:52:04 +02:00
Robin Malfait
3524b45310
Attempt to fix flaky integration test (#20384)
One of the integration tests is flaky on CI. This is an attempt to "fix"
it.

It has probably something to do with the availability of the resources
provided by GitHub because locally this works flawlessly for 100 runs
straight.

## Test plan

- Once we get 5 consecutive positive runs we van merge it.
- No actual code was changed, only the flaky integration test itself.

<img width="336" height="445" alt="image"
src="https://github.com/user-attachments/assets/bec81687-62f5-4df3-8109-d0aaa4ffbd8a"
/>
2026-08-05 12:39:27 +02:00
Robin Malfait
b5ab20473b
improve flaky integration test 2026-08-04 19:27:26 +02:00
Robin Malfait
9dae5163db
update changelog 2026-08-04 18:42:00 +02:00
Robin Malfait
d190343594
Use wasm as a fallback for @tailwindcss/oxide (#20383)
Right now, we use Rust for `@tailwindcss/oxide` which has 2
responsibilities:

1. Traverse the file system and figure out which files need to be
scanned based on auto source detection and `@source` directives.
2. Given those files, extract possible Tailwind CSS classes which we
call candidates.

Since this is using native code, we use napi-rs to get native `.node`
files on a per platform / arch basis.

So far so good, however, if you are on an OS that doesn't have a
prebuilt binary, you will receive an error that might look like this:

```
Error: Cannot find native binding. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try `npm i` again after removing both package-lock.json and node_modules directory.
    at Object.<anonymous> (/private/var/folders/1k/bdv8blv93xq7qgwjdwc9z88h0000gn/T/tailwind-integrationspYHIVP/node_modules/.pnpm/@tailwindcss+oxide@file+..+..+..+..+..+..+..+Users+robin+github.com+tailwindlabs+tailwi_47ae1688f61c719f66c73e2ff35e430f/node_modules/@tailwindcss/oxide/index.js:573:19)
    at Module._compile (node:internal/modules/cjs/loader:1829:14)
    at Object..js (node:internal/modules/cjs/loader:1969:10)
    at Module.load (node:internal/modules/cjs/loader:1552:32)
    at Module._load (node:internal/modules/cjs/loader:1354:12)
    at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
    at Module.require (node:internal/modules/cjs/loader:1575:12)
    at require (node:internal/modules/helpers:191:16)
    at file:///private/var/folders/1k/bdv8blv93xq7qgwjdwc9z88h0000gn/T/tailwind-integrationspYHIVP/index.mjs:5:19
    at ModuleJob.run (node:internal/modules/esm/module_job:437:25) {
  cause: Error: Cannot find module '@tailwindcss/oxide-darwin-arm64'
```

This means that we have to add support for these platforms, and there
are some open PRs related to this, which could be closed by this PR:

- #20327
- #20276
- #20201

Today we already have support for the big platforms out there:

- Windows arm64
- Windows x64
- macOS arm64
- macOS x64

But then it starts to get a bit out of hand once we start looking at
Linux based versions:

- Android arm eabi
- Android arm64
- Linux arm64 gnu
- Linux arm64 gnueabihf
- Linux arm64 musl
- Linux x64 gnu
- Linux x64 musl
- freebsd x64

... and then we have the pending list from the 3 PRs linked above.
Adding support for all of these is not the end of the world, but it gets
complex if we need to keep supporting more and more. Right now we rely
on a bunch of non-default napi-rs setup in CI just to support these
other platforms.

This PR solves that by using the `wasm32-wasi` build as a universal
fallback. The napi-rs generated loader already knows how to fall back to
`@tailwindcss/oxide-wasm32-wasi`, but that package declared `"cpu":
["wasm32"]`, so npm/pnpm never installed it on real hardware. Removing
that restriction means the package is installed everywhere, and the
loader picks it up whenever no native binding exists.

This PR also fixes a `UVWASI_EACCES` crash on sandboxed platforms
(OpenHarmony, Android): the generated wasm loader preopens `/`, which
those sandboxes deny, so the fallback failed to load on exactly the
platforms that need it (see [this
comment](https://github.com/tailwindlabs/tailwindcss/pull/20276#issuecomment-4950167198)).
We patch `@napi-rs/cli`'s codegen templates via `pnpm patch` to retry
with narrower preopens (`/` → cwd → none). On such platforms, scanning
is limited to files under the current working directory.

## Test plan

Added two integration tests:

1. Trick pnpm (via `supportedArchitectures`) into installing for a
platform we explicitly don't support, and assert `@tailwindcss/oxide`
loads the wasm binding and scans files from disk.
2. Simulate a sandbox that denies preopening `/`, and assert the wasm
binding still loads and scans.

[ci-all]
2026-08-04 18:41:43 +02:00
Robin Malfait
05c3a87627
Bump dependencies (#20381)
This PR bumps dependencies and dev dependencies and applies required
changes due to version bumps.

- Moved `@parcel/watcher` related dependencies to the
`pnpm-workflow.yaml` catalog
- Bumped Lightning CSS + updated tests with improvements
- Bumped napi related dependencies
- Tackled a Vitest warning

## Test plan

All tests should still pass on all platforms [ci-all]
2026-08-04 13:55:03 +02:00
Lazizbek Ergashev
50daebded0
Prevent @tailwindcss/vite crash under Vite's experimental bundledDev (#20379)
## Summary

Under Vite's experimental `bundledDev` mode, the `hotUpdate` hook in
`@tailwindcss/vite` gets called without a `server`. Vite only passes `{
type, file, modules }` here, but the hook loops over
`Object.values(server.environments)`, so editing any file (JS, CSS, or
HTML) throws `TypeError: Cannot read properties of undefined (reading
'environments')` and the dev server build fails.

The fix returns early when `server` is missing. Those environment loops
only look at environments other than the current one, and the
server-level `hot`/`ws` reload channels don't exist in this mode, so
bailing out leaves the classic (non-`bundledDev`) dev path untouched.

Fixes #20378

## Test plan

- Added a unit test that calls `hotUpdate` without a `server` and checks
it doesn't throw. It fails on the current code and passes with the
guard.
- Reproduced with a Vite 8 project using `experimental.bundledDev:
true`: before the change, editing any JS/CSS/HTML file crashed the dev
server; after it, edits work.
- `pnpm run test` and the `@tailwindcss/vite` integration suite both
pass.


[ci-all]

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-03 15:10:06 +02:00
Robin Malfait
6def8205b5 update changelog 2026-08-03 13:11:43 +02:00
Robin Malfait
a4e0f75633 try converting variants with arbitrary values to builtin variants 2026-08-03 13:11:43 +02:00
Robin Malfait
489c22b4be add failing tests 2026-08-03 13:11:43 +02:00
Robin Malfait
4be6110aff update changelog 2026-07-31 15:13:38 +02:00
Tim Neutkens
25fa72c1ec Use default Turbopack mode in integration test 2026-07-31 15:13:38 +02:00
Tim Neutkens
56565b68f9 Add Turbopack loader integration coverage 2026-07-31 15:13:38 +02:00
Tim Neutkens
c9689a44a6 Add @tailwindcss/turbopack loader 2026-07-31 15:13:38 +02:00
Robin Malfait
d494a78532 update changelog 2026-07-31 14:53:47 +02:00
Robin Malfait
78b4864d30 always emit , ) instead of ,) for fallback values in CSS variables
This will bring the development version and production version closer
together. It also means that the development version works in older
supported browsers like  Chrome v112 where the space is required (even
though the spec allows both)

There are no changes in tests just becaues we already run each test
through Lightning CSS which normalizes these values:

Input:
```css
var(--tw-blur,)
var(--tw-blur, )
```

Lightning CSS output:
```css
var(--tw-blur, )
var(--tw-blur, )
```
2026-07-31 14:53:47 +02:00
Robin Malfait
423aee5309
Improve handling of @scope related at-rules (#20369)
Continuation of #20344, original commits of that PR are present in this
PR as well.


Before we talk about the issues, there is some jargon I'll be using that
might be unfamiliar:
```css
@scope (.from) to (.to) {
  /*   ^^^^^^^^^^^^^^^^    This is the prelude */
  /*   ^^^^^^^             This is the `scope-start` */
  /*              ^^^^^    This is the `scope-end` */
}

/* The `scope-start` is optional, if you only want `scope-end`: */
@scope to (.to) {
}

/* The `scope-end` is optional, if you only want `scope-start`: */
@scope (.from) {
}
```

This PR contains multiple fixes, but they are all related to the
`@scope` at-rule. The `@scope` at-rule is a beast, it's an at-rule where
we do have multiple selectors in the prelude, which in turn can contain
`&` rules. If you use `&` inside of an `@scope` rule, its meaning
changes compared to what `&` typically means and it is even different
whether it exists in `scope-start` or `scope-end`.

Let's start with the original issue, if you define a custom variant
like:
```css
@custom-variant blue {
  @scope ([data-theme='blue']) to ([data-theme]) {
    @slot;
  }
}
```

Then using `blue:bg-blue-500` used to generate:

```css
.blue\:bg-blue-500 {
  @scope ([data-theme='blue']) to ([data-theme]) {
    background-color: var(--color-blue-500);
  }
}
```

This is because internally we start with the AST node representing
`bg-blue-500`, then we wrap all the `variant` related nodes around it
(`blue`), and then wrap everything in the final rule with a selector
representing the class (`.blue\:bg-blue-500`).

This is incorrect because you want that sandwich effect where any
`.blue\:bg-blue-500` class between an element with the
`[data-theme="blue"]` attribute and another nested `[data-theme]`
attribute.

The other [PR](#20344) solved this by simply making sure that the
`@scope` rule gets hoisted to the top. For this particular example that
was enough.

However, that would result in a ton more issues. This hoisting or
swapping with the rule is only "safe" in a `custom-variant`.

So similar to the other PR, this PR wraps this properly:

```css
@scope ([data-theme='blue']) to ([data-theme]) {
  .blue\:bg-blue-500 {
    background-color: var(--color-blue-500);
  }
}
```

The tricky part is that the nesting pass runs on _all_ CSS, custom
variants _and_ your own CSS. Hoisting an `@scope` rule out of the rule
it is nested in changes its meaning.

Take a look at this example:

```css
.parent {
  @scope (.from) to (.to) {
    * {
      border: 1px solid black;
    }
  }
}
```

This says that you need a structure roughly like this:
```html
<div class="parent">
  <div class="from">
    <div>This element will get a border</div>

    <div class="to">
      <div>This element won't get a border, it's beyond the scope</div>
    </div>
  </div>
</div>
```

If we had switched it:
```css
@scope (.from) to (.to) {
  .parent {
    * {
      border: 1px solid black;
    }
  }
}
```

Then this requires that an element with a class of `.parent` lives
between the
`.from` scope-start and `.to` scope-end:
```html
<div class="from">
  <div class="parent">
    <div>This element will get a border</div>

    <div class="to">
      <div>This element won't get a border, it's beyond the scope</div>
    </div>
  </div>
</div>
```

Subtle, but it's definitely wrong.

Instead, we will apply our flattening rules, such that the `@scope` gets
hoisted, but we have to bring that `.parent` selector requirement inside
of the `@scope` prelude, more specifically inside the `scope-start`:

```css
@scope (.parent .from) to (.to) {
  * {
    border: 1px solid black;
  }
}
```

We _could_ have left this as-is, but there is a Lightning CSS bug where
the original CSS:
```css
.parent {
  @scope (.from) to (.to) {
    * {
      border: 1px solid black;
    }
  }
}
```

gets turned into:
```css
@scope (.from) to (.to) {
  :scope * {
    border: 1px solid #000;
  }
}
```

Notice how the `.parent` class is not found at all? (I will open some
issues on the Lightning CSS repo (and PRs to fix this). But it's a good
fix to have in Tailwind CSS as well, just because a lot of tooling is
already using Lightning CSS on top of Tailwind CSS, so we need an entire
chain to be updated for this to work.)

Side note: the version we generate is untouched by Lightning CSS.

## Resolving `&` in the prelude

`&` resolves differently depending on which scope selector you're
looking at
([spec](https://drafts.csswg.org/css-nesting-1/#nesting-at-scope)):

- In the `<scope-start>` selector, `&` refers to the elements matched by
the nearest ancestor style rule (the utility, for variants).
- In the `<scope-end>` selector, `&` refers to the scoping root and
behaves like `:where(:scope)`. `:scope` might work, but that has a
specificity of `0,1,0` instead of `0,0,0`.

So this input:

```css
.parent {
  @scope (& > .scope) to (& .limit) {
    .content {
      color: red;
    }
  }
}
```

compiles to:

```css
@scope (.parent > .scope) to (:where(:scope) .limit) {
  .content {
    color: red;
  }
}
```

The substitution uses the exact same logic as `&` in nested style rule
selectors: `:is(…)` semantics by default, dropping the `:is(…)` whenever
the substitution is provably equivalent.

So a complex parent compiles to `@scope (.a .b > .from)` rather than
`@scope (:is(.a .b) > .from)`, while `.card&` inside a `main` rule keeps
it (`@scope (.card:is(main))`) because substituting the type selector
as-is would produce invalid CSS.

For user CSS, every selector in a `<scope-start>` selector list is
relative to the parent rule, whether it uses `&` or not, just like
nested style rule selectors:

```css
.parent {
  @scope (.a, & > .b) {
    .inside {
      color: red;
    }
  }
}
```

compiles to:

```css
@scope (.parent .a, .parent > .b) {
  .inside {
    color: red;
  }
}
```

## Controlling the composition with `&`

Because `&` in the `<scope-start>` selector refers to the utility,
variant authors can control where the utility ends up:

```css
/* Default: the utility applies to elements inside the scope */
@custom-variant in-blue {
  @scope ([data-theme='blue']) to ([data-theme]) {
    @slot;
  }
}

/* Trailing `&`: the utility itself becomes the scoping root */
@custom-variant blue-self {
  @scope ([data-theme='blue'] &) to ([data-theme]) {
    @slot;
  }
}

/* Leading `&`: the scoping roots are found inside the utility */
@custom-variant scoped-panel {
  @scope (& .panel) {
    @slot;
  }
}
```

generates:

```css
@scope ([data-theme='blue']) to ([data-theme]) {
  .in-blue\:flex {
    display: flex;
  }
}

@scope ([data-theme='blue'] .blue-self\:flex) to ([data-theme]) {
  :where(:scope) {
    display: flex;
  }
}

@scope (.scoped-panel\:flex .panel) {
  :where(:scope) {
    display: flex;
  }
}
```

Note that when the utility becomes the scoping root, the `<scope-end>`
limit no longer affects the utility itself (a scoping root is never
beyond its own limits), and the declarations apply to the root with zero
specificity. The default composition is usually what you want to get the
sandwich effect, but now you _can_ get the other behavior if you want.

## Bare declarations

There is another small Lightning CSS bug that we want to fix. If you
have the following CSS:
```css
@scope (.from) to (.to) {
  color: red;
}
```

Then Lightning CSS crashes with an unexpected input. The [spec
says](https://drafts.csswg.org/css-cascade-6/#scoped-declarations) that:

> Declarations may be used directly with the body of a `@scope` rule.
Contiguous runs of declarations are wrapped in nested declarations
rules, which match the scoping root with zero specificity.
>
> ```css
> @scope (.foo) {
>   border: 1px solid black;
> }
> ```
> is equivalent to:
> ```css
> scope (.foo) {
>   :where(:scope) {
>     border: 1px solid black;
>   }
> }
> ```

So this is what we will do as well:
```css
@scope (.from) to (.to) {
  :where(:scope) {
    color: red;
  }
}
```

---

One more small detail about the implementation: we do have to track
which CSS is coming from the custom variant, and what is user defined
CSS. To do this, we insert some internal `context` nodes with a `{
source: 'user' }` or `{ source: 'variant' }` so we know when we can just
swap this `@scope` around, or if we just have to handle the nesting and
`&` substitutions.

Fixes: #18961
Closes: #20344

## Test plan

1. Added loads of new tests related to `@scope` in user-land
1. Added loads of new tests related to `@scope` in custom variants
   - When using the `@custom-variant` shorthand syntax
   - When using the `@custom-variant` syntax with `@slot`
   - When using arbitrary variants like `[@scope_(.from)_to_(.to)]:flex`
   - When using `addVariant()` and `matchVariant()` APIs
1. Added tests for the hoisting logic, prelude resolution for `&`
selectors
1. Added browser based UI tests to make sure that the non-flattened
version and flattened version behave the same.


---

Sorry about all the sandwiches, I'm hungry myself.

---------

Co-authored-by: uditDewan <udit.dewan21@gmail.com>
2026-07-31 12:36:44 +02:00
Robin Malfait
f6b7c5818e
Update integration tests: PostCSS re-emits the UTF-8 BOM character (#20372)
This PR updates the integration tests that started failing because of a
recent PostCSS update.

PostCSS v8.5.24 started re-emitting the BOM character in the output,
see: https://github.com/postcss/postcss/releases/tag/8.5.24 therefore
the tests started failing.

This PR just re-adds that back.

It might not be clear from the diff on GitHub, but this is the change: 
<img width="878" height="84" alt="image"
src="https://github.com/user-attachments/assets/5e2fbb6e-2ee4-46c5-9a2c-9c3a1a806bb0"
/>


## Test plan

1. All tests pass again [ci-all]
2026-07-31 12:19:43 +02:00
Robin Malfait
6de87c62c4
Fix changes to symlinked files outside of the project works (#20356)
This PR fixes an issue where changes to a symlinked file wouldn't result
in hot-reload when using `@tailwincdss/vite`. This issue also exists in
the other packages such as `@tailwincdss/postcss`,
`@tailwincdss/webpack` and `@tailwincdss/cli`.

The issue is that we watch the symlinked file, but not the "real" file
for changes. If the source of the symlinked file lives in another folder
that is not covered by auto-source detection or by any of the `@source`
directives, then changes to that file won't trigger a change.

To solve this, if a file is symlinked or lives in a symlinked folder,
then we will make sure that the `scanner.files` contains the real path /
canonicalized path to the real file as well just so we can detect
changes in that file.

Fixes: #20346
Closes: #20347

## Test plan

1. Added a regression test for `@tailwindcss/vite` 
2. Added tests in the scanner code itself
3. Manually tested on the reproduction:

| &nbsp; | Initial state | After change |
| ---: | --- | --- |
| **Before** | <img width="3200" height="1800"
alt="file-f8616573eec3150ae484fd279204c6d7"
src="https://github.com/user-attachments/assets/2734ecc3-b5b6-420e-820d-8a4a8fcdd7f5"
/> | <img width="3200" height="1800"
alt="file-0e93fd3c2c9694c844b098616a3208d2"
src="https://github.com/user-attachments/assets/fd86859b-420b-476b-80f6-e94d02e8b07b"
/> |
| **After** | <img width="3200" height="1800"
alt="file-f8616573eec3150ae484fd279204c6d7"
src="https://github.com/user-attachments/assets/2734ecc3-b5b6-420e-820d-8a4a8fcdd7f5"
/> | <img width="3200" height="1800"
alt="file-eb7b37430ea1363dddeac7226007afe4"
src="https://github.com/user-attachments/assets/0a95b1a4-a357-491d-b57f-78460c0fc9db"
/> |


[ci-all]

---------

Co-authored-by: Nic <162764842+Nic-Polumeyv@users.noreply.github.com>
2026-07-28 12:40:02 +02:00
Robin Malfait
4c128662c2
Fix failing upgrade test in CI (#20357)
The last CI run on `main` was on July 16, and the last `globby` release
(16.2.2) was released on July 15 (~21 hours earlier). Due to the default
`minimumReleaseAge` in pnpm of 24 hours, it meant that we were receiving
the previous globby version.

In the new version, they slightly changed some of the `.gitignore`
parsing, but in one of our upgrade tests we wrote an invalid
`.gitignore` file (which had indented lines).

This PR fixes that by using the `txt` tagged template literal, which
behind the scenes uses `dedent` which in turn makes sure that we ship a
proper `.gitignore` file.


## Test plan

All tests should pass [ci-all]
2026-07-28 00:21:06 +02:00
Robin Malfait
094bf62605
update release date 2026-07-16 19:15:10 +02:00
Robin Malfait
c2b24dd15f
4.3.3 (#20334)
Some checks failed
Release / Build aarch64-apple-darwin (oxide) (release) Has been cancelled
Release / Build x86_64-apple-darwin (oxide) (release) Has been cancelled
Release / Build x86_64-unknown-freebsd (OXIDE) (release) Has been cancelled
Release / Build aarch64-unknown-linux-gnu (oxide) (release) Has been cancelled
Release / Build armv7-unknown-linux-gnueabihf (oxide) (release) Has been cancelled
Release / Build x86_64-unknown-linux-gnu (oxide) (release) Has been cancelled
Release / Build x86_64-unknown-linux-musl (oxide) (release) Has been cancelled
Release / Build aarch64-unknown-linux-musl (oxide) (release) Has been cancelled
Release / Build aarch64-linux-android (oxide) (release) Has been cancelled
Release / Build armv7-linux-androideabi (oxide) (release) Has been cancelled
Release / Build aarch64-pc-windows-msvc (oxide) (release) Has been cancelled
Release / Build x86_64-pc-windows-msvc (oxide) (release) Has been cancelled
Release / Build and publish Tailwind CSS (release) Has been cancelled
Release / notify (release) Has been cancelled
2026-07-16 07:21:50 -04:00
Robin Malfait
bdcd7087b3
Don't trigger a full page reload for scanned files that Vite processes as modules (#20336)
This PR fixes an issue where editing a scanned file that Vite (or one of
its plugins) can process as a module, but that isn't currently loaded,
caused `@tailwindcss/vite` to force a full page reload, throwing away
all client state.

The `hotUpdate` hook has a fallback that sends a `full-reload` for files
that Tailwind scans but that Vite knows nothing about (e.g. `.php` or
`.blade.php` templates rendered by a backend). Without it, edits to
those files wouldn't refresh the page at all. To detect those files we
check whether every module for the changed file is an `asset` and/or has
no id, because the scanner's `addWatchFile` calls create exactly such
placeholder nodes for every scanned file.

The problem is that a source file that Vite _can_ process, but that
isn't loaded yet, looks exactly the same. The realistic way to get into
that state is code splitting: with route-level splitting (e.g.
`React.lazy`, TanStack Router's `autoCodeSplitting`, lazy routes in
`vue-router`), every component behind an un-visited split boundary only
exists as a scan placeholder in the module graph. Editing any of them
reloaded the whole app. The same happens for component stylesheets that
a framework plugin compiles into the component (e.g. Angular via
Analog), which never show up as their own module.

A full reload is never useful for these files: if the file is loaded,
Vite's own HMR handles it, and if it isn't loaded, reloading the page
won't load it either. Any new candidates still apply through the regular
`css-update` flow because the file is registered via `addWatchFile`.

So instead, we now skip the fallback when the changed file is handled by
Vite's module pipeline:

- The file exists as a real module in another environment (e.g. an
SSR-only module). This check already existed and is folded into the same
code path.
- The file is part of the JS/TS or CSS families, which Vite transforms
natively.
- For any other file type (e.g. `.vue`, `.svelte`, or `.md` with an SSG
plugin), a file with the same extension exists as a real module in some
environment's module graph, then a plugin does handle this file type and
the changed file just isn't loaded (yet).

External templates like `.php` files still trigger a full reload exactly
like before.

Fixes: #20320
Fixes: #19903
Closes: #20323

## Test plan

1. Added integration tests to ensure extensions handled by default rely
on HMR
2. Added integration tests to make sure that unknown extensions that
have been handled already will also use HMR
3. Manually tested that changing a `.php` file still triggers a
`full-reload`
4. Manually tested the reproduction where local client state isn't
thrown away


<img width="594" height="100"
alt="file-14a86a90a1e4b810c2b80338ea688572"
src="https://github.com/user-attachments/assets/a4507502-4a5d-43ee-93b9-14c793a25891"
/>

<img width="1122" height="1376"
alt="file-a5121da2ad77b95fdd1703560ef1ff41"
src="https://github.com/user-attachments/assets/cc5c67b0-b5ad-481f-8823-a3266d75357d"
/>
2026-07-15 17:49:05 +02:00
Robin Malfait
7811d74f37
Ensure earlier @source is not accidentally ignored by later @source (#20335)
This PR fixes an issue where an `@source` pointing to a file in a nested
folder was not scanned when a later `@source` pointed to a file in a
parent folder.

E.g.:

```css
@source "./nested/index.html";
@source "./index.html";
```

When using `@source` pointing to a specific file, then we want to make
sure that we ignore _other_ files since they are not listed explicitly.
To ensure that these patterns don't read other files, we inject a `*`
ignore pattern before it. You can think of the above being expanded to:

```rs
Ignored { base: "/project/src/nested", pattern: "*" }
Pattern { base: "/project/src/nested", pattern: "/index.html" }
Ignored { base: "/project/src", pattern: "*" }
Pattern { base: "/project/src", pattern: "/index.html" }
```

The problem with this is that the `Ignored { base: "/project/src",
pattern: "*" }` pattern results in ignoring the `nested` folder as well.
This means that we never even walk into the `nested` folder, so the
earlier `@source "./nested/index.html"` never matches anything.

We could switch the order in user land, but that's going to be hard to
maintain (and order matters for undoing/redoing earlier rules, so we
can't re-order internally either). Instead, we can scope the ignore
pattern to the _current_ path only, and not deeply nested. In other
words, the pattern should become:

```diff
- *
+ /*
```

It's a very subtle difference, but the pattern from above will now
become:
```diff
  Ignored { base: "/project/src/nested", pattern: "*" }
  Pattern { base: "/project/src/nested", pattern: "/index.html" }
- Ignored { base: "/project/src", pattern: "*" }
+ Ignored { base: "/project/src", pattern: "/*" }
  Pattern { base: "/project/src", pattern: "/index.html" }
```

We already do this when an unrestricted root (e.g. `@source "./nested"`)
lives inside the base of a restricted pattern. This works because every
source base is also its own walk root, and a `/*` pattern only matches
direct children so it can't ignore anything when walking from the nested
root itself.

This PR extends that same check to restricted pattern bases: if another
`@source` pattern has its base nested inside the current base, we emit
`/*` instead of `*`.

Note that we only relax the pattern to `/*` when such a nested root
actually exists. Sibling folders that no `@source` points into (e.g. an
`ignore-me` folder next to `nested`) are still direct children, so they
still match `/*` and are never walked.

Fixes: #20333

## Test plan

1. Added a regression test with the reproduction setup
2. Added a similar test with another sibling folder that should still be
ignored
3. Tested it against the actual reproduction:

Before:
<img width="797" height="463" alt="image"
src="https://github.com/user-attachments/assets/893bb872-f5cf-4c5a-bfb5-2dc1043b5a39"
/>

After:
<img width="794" height="460" alt="image"
src="https://github.com/user-attachments/assets/a59aae45-68ad-4fe3-b21f-b568f535d24f"
/>

Notice that the `text-green-500` now appears as expected.
2026-07-15 13:35:00 +02:00
Robin Malfait
f861d5c5e6
use the same fix for template files
Forgot this as part of https://github.com/tailwindlabs/tailwindcss/pull/20329
2026-07-14 18:42:33 +02:00
Benoît Rouleau
a854b35bcf
Prevent @tailwindcss/upgrade from rewriting files inside node_modules when run from a workspace subpackage (#20329)
Fixes #20328.

## What changed

When `@tailwindcss/upgrade` is invoked from a subpackage of a workspace
(e.g. `pnpm --filter …` or `cd packages/foo && pnpm exec upgrade`), it
traverses `../../node_modules/…` and applies its v3 → v4 migration
transform (`@tailwind utilities;` → `@import 'tailwindcss/utilities'
layer(utilities);`) to files inside the installed `tailwindcss` package
itself — a self-referential import that later detonates as an infinite
CSS-resolution loop and reads like a "cache corruption" bug (matches the
report in #19726 / #20328).

Root cause: `globby`'s `isGitIgnored` only walks `.gitignore` files at
or below its `cwd`. When invoked from a subpackage, the workspace-root
`.gitignore` (which almost always lists `node_modules`) is never
consulted, and `../../node_modules/…` paths come back as **not**
ignored.

The fix anchors `isGitIgnored` at the git repository root instead of the
subpackage:

- New helper: `gitRoot(cwd)` in `src/utils/git.ts` — thin wrapper over
`git rev-parse --show-toplevel`.
- `analyze(stylesheets, { base })` in `src/codemods/css/analyze.ts` now
calls `isGitIgnored({ cwd: gitRoot(base) ?? base })`. Falls back to the
previous behavior when not inside a git repository or when `git` is
unavailable.

## Test

Added an integration test that reproduces the exact scenario: a pnpm
workspace with a subpackage that imports `tailwindcss/utilities.css`,
upgrade run from the subpackage, then asserts
`packages/css/node_modules/tailwindcss/utilities.css` still holds the
pristine `@tailwind utilities;` directive.

Verified the test fails on `main` and passes with this change.

The 415 existing upgrade unit tests still pass. Two npm-related snapshot
failures in `upgrade-errors.test.ts` (`half-upgraded v3 project to v4
(bun|npm)`) pre-exist this change — they're about newer npm's `npm
notice run` output that isn't in the snapshots, unrelated to what this
PR touches.


---

Edit by @RobinMalfait: going to run on all [ci-all] so we can make sure
it works on Windows as well.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-07-14 16:39:24 +00:00
Robin Malfait
e48c5e8047
Fix weird character rendering on Windows with Japanese locale (#20318)
This PR fixes an issue where some characters are incorrectly rendered on
Windows with the Japanese locale.

This is arguably a bug in the font that's loaded by Windows when it
encounters `system-ui`. But waiting for fixes there might ... take a
while.

Another option is to not change the defaults in Tailwind CSS and instead
let the users that support different locales implement a fallback by
overriding the `--font-sans` variable.

The biggest reason for me to _not_ change it in Tailwind CSS is that it
requires us to know what the (proper) fallback fonts need to be on a per
OS basis.

But the main reason why I did want to make the change is that MDN says
this about the `system-ui` font:

> Glyphs are taken from the default user interface font on a given
platform. Because typographic traditions vary widely across the world,
this generic is provided for typefaces that don't map cleanly into the
other generics.
>
> **Note:** As the name implies, `system-ui` is intended to make UI
elements look like native apps, and not for typesetting large paragraphs
of text. It may cause the displayed typeface to be undesirable for some
users—for example, the default Windows CJK font may render Latin scripts
poorly, and the `lang` attribute may not affect the displayed font. Some
operating systems do not allow customizing `system-ui`, while browsers
generally allow customizing the `sans-serif` font family. For large
paragraphs, use `sans-serif` or some other non-UI font family instead.
>
> —
https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-family#system-ui

There are PRs in other big projects that made this kind of change as
well. E.g.:

- https://github.com/withastro/starlight/pull/3729
- https://github.com/vuejs/vitepress/pull/4988

The reasoning for getting rid of `ui-sans-serif` is twofold:
1. Because the starlight PR seems very well tested, and they got rid of
it
2. In the event that the browser decided to load the broken font when it
encounters `ui-sans-serif`, then we will run into the same issue again.

Fixes: #19767
Fixes: #19768


## Test plan

1. `system-ui` is not used anymore, so the bug doesn't happen
3. Everything still looks the same for the places I checked, but it's
hard to know if this created _other_ issues on other OS + Locale
combinations...
2026-07-14 17:04:20 +02:00
Robin Malfait
b03e5e7731
Lazy load @parcel/watcher (#20325)
This PR ensure that we lazy load the `@parcel/watcher` in the
`@tailwindcss/cli`. This means that we don't have to load it at all when
using a normal build or when using `--watch --poll` combination.

The bigger reason is that on some platforms `@parcel/watcher` might not
work, and therefore the build will fail even if you don't use `--watch`
at all.

This PR fixes that by lazy loading it. Then, if we can't load it when
using `--watch`, a useful workaround is shown by using `--watch --poll`
instead.

This PR also improves showing errors such that the `error.cause`
property can be rendered as well.

Maybe in the future we can make use of deferred imports
(https://github.com/tc39/proposal-defer-import-eval)

Fixes: #20322

## Test plan

1. All tests still pass [ci-all]
2. Fabricated a fake error locally to prove that we can still use a
normal build and `--watch --poll` as a workaround

<img width="1122" height="1376"
alt="file-2f91d99cfb9eb0e24a196c2dd358c1f3"
src="https://github.com/user-attachments/assets/e37658ee-0827-422e-bd50-8ea5a85e1b7c"
/>
2026-07-13 15:53:42 +02:00
Robin Malfait
35a3e9c515
Always produce <length> value when optimizing --spacing(0) (#20319)
This PR fixes a type issue when using `--spacing(0)`. This construction
doesn't really make sense, but if you use it, it produces the value `0`
instead of `calc(var(--spacing) * 0)` which is fine if you use it in a
spot where a `<length>` data type can be used, then the `0` is
interpreted as a `<length>`.

E.g.: `padding: 0` and `padding: 0px` are equivalent.

However, if you use it in a CSS variable, then the `0` will turn in a
`<number>` if you don't have an `@property` definition for that CSS
variable.

This on its own isn't the issue, but if you later use it as part of a
`calc(…)` then the `<number>` instead of `<length>` type is being used.
As seen in #20315.

We could remove the optimization, and use `calc(var(--spacing) * 0)`
again, but this is a bit silly since it only makes your CSS file larger.

We could try to be smart, and only do it _if_ we're assigning to a CSS
variable (which #20317 is doing). But if your CSS variable _does_ use a
`<length>` then it's a non-issue. We would run into the same issue if
you use `width: calc(100% --spacing(0))`. This value is a bit silly
anyway, but it would originally resolve to `calc(100% +
calc(var(--spacing) * 0))`, the optimization of `calc(100% - 0)` would
make it invalid, so the fix in #20317 would not be enough.

Instead I opted for an inbetween solution, by always using `0px`. In
most cases we can use `0`, but in the places we can't the `0px` would at
least ensure that we are dealing with `<length>` data types.

This way we don't have to try to be smart to analyze where we use the
value, and `0px` is still better than the long `calc(var(--spacing) *
0)` value.

Fixes: #20315
Closes: #20317

## Test plan

1. Manually tested that now `--spacing(0)` does produce `0px` which has
a length type
2026-07-09 18:34:27 +02:00
Robin Malfait
4af47fbe94
Fix hues in achromatic theme colors to be none (#20314) 2026-07-09 13:36:34 +02:00
Robin Malfait
5835691d21
Handle CSS nesting natively (#20124)
This PR introduces a new feature where we will be handling the CSS
nesting ourselves.

We currently still rely on Lightning CSS in most places. But there are
situations where we don't use Lightning CSS out of the box:

1. During development, typically optimization/minification isn't setup
2. In places where it isn't as easy to run Lightning CSS such as in
`@tailwindcss/browser` or in Tailwind Play.

We handle CSS nesting in a single pass over the AST by tracking some
information as we go. It's not the most complex code, but there are some
tricky parts to make this happen in an efficient way, especially for the
few additional optimizations we handle.

While going over the AST, we will only emit CSS the moment we see
declarations or comments. This also means that this has a fun side
effect of removing CSS that ends up with empty nodes automatically.
(Caveat: there are exceptions for body-less rules such as `@layer foo;`
or `@charset "UTF-8";)

This also allowed us to do some cleanup in `optimizeAst` that tried to
do this as well, but now this will be handled by the code that handles
nesting automatically. Which is preferred because the version in
`optimizeAst` mutated the AST.

This also contains some optimizations where we merge adjacent at-rules
(with the same name / params), and adjacent rules with the same
selector, and get rid of declarations that are duplicated in a node.
(Caveat: there are exceptions, in case of `@font-family { … }` where we
don't want to merge them)

~~To ensure that this implementation is correct, I also added an oracle
implementation in the tests. This implementation does multiple passes
over the AST, because it does each step one by one, with minimal code.
Each step contains comments with examples to see what's happening in
that step. We then test the optimized version against this.~~ Once the
implementation was in place, and all the tests were passing, then I
deleted the oracle implementation. That way we don't have to keep it in
sync all the time.

While handling the nesting, we have to make sure that `&` exists and if
we replace it with a parent selector that we do use `:is(…)` semantics.
This means that:
```css
.foo {
  &:hover {
    color: red;
  }
}
```
Becomes:
```css
:is(.foo):hover {
  color: red;
}
```

We then also make sure that we optimize the selector by removing the
unnecessary `:is(…)` wrappers, but only if they were introduced by the
nesting logic. If _you_ wrote `:is(…)` in your CSS, we won't touch it.

If you look at the commits, the first thing we did is remove the
optimization step from Lightning CSS in the tests. Then we enabled our
CSS nesting handling code. This allows us to see the effect of the
changes we are making. At the end, we re-enabled Lightning CSS.

For now, this PR will be a step that happens before Lightning CSS is
executed, while still using Lightning CSS. But now this step will also
always happen in places where we don't use Lightning CSS at all.

This should not result in any breaking changes. It could result in
changed CSS output in environments where Lightning CSS isn't used. In
environments where it is being used, then there could be some
differences related to some selectors but they should result in the same
behavior with the same specificity.

While testing things, I noticed that there are some missed opportunities
for performance related to how we extract variables from declaration
values. I want to tackle `optimizeAst` in future PRs to make it simpler,
more performant, and maybe even merge it with the CSS nesting handling.

As part of testing this, I tested it against the tailwindcss.com
codebase which contains a lot of CSS (807.67 KB, 18 174 AST nodes)
because almost every utility is being used in examples.

The oracle implementation is rather slow:
```
[131.59ms]   ↳ oracle (step by step)
[129.23ms]     ↳ handleNesting(…)
[  2.32ms]     ↳ toCss(…)
```

But the final code is much faster (`<15ms`):
```
[ 10.11ms]   ↳ hand written (single pass)
[  8.40ms]     ↳ handleNesting(…)
[  1.67ms]     ↳ toCss(…)
```

In contrast, Lightning CSS takes: `[ 37.48ms] Optimized by Lightning
CSS`

One interesting thing to notice is that in big projects, this could add
`10ms` to the build, but Lightning CSS would then take less time to
process, which results in a no-op with better output.

One thing to keep in mind here is that Lightning CSS does more things,
such as normalizing values, handling vendor prefixes, CSS nesting, etc.

<details>

<summary>Some notes on how the algorithm works:</summary>

### The basic idea

When you have CSS that looks this:
```css
.foo {
  .bar {
    color: red;
  }
}
```

Then the AST looks like this:
```
[
  {
    kind: 'rule',
    selector: '.foo',
    nodes: [
      {
        kind: 'rule',
        selector: '.bar',
        nodes: [
          {
            kind: 'declaration',
            property: 'color',
            value: 'red',
            important: false
          }
        ]
      }
    ]
  }
]
```

When we walk this tree, and we encounter a `rule`, then we will track
the selector on a stack. When we are done walking over the rule, then we
will pop the selector from the stack. This means that the top-most
selector on the stack will always be the parent selector.
```ts
let selectorStack = []

walk(ast, {
  enter(node) {
    selectorStack.push(node.selector)
  },
  exit(node) {
    selectorStack.pop()
  },
})
```

The moment we encounter a `rule`, and if a previous rule was seen, then
we push the `selector` of the rule onto the stack, but in a way that the
`&` is already replaced by the selector. This way, a sibling rule will
also get the already-prepared parent selector.

The simple version looks like this:
```ts
walk(ast, {
  enter(node) {
    // In the real code we properly handle `&` replacement, and make sure that
    // parent selector is prepended if there is no `&` used in the selector of the
    // node.
    let selector =
      selectorStack.length > 0
        // At this point, we don't optimize anything related to the selector yet
        ? node.selector.replaceAll('&', `:is(${selectorStack.at(-1)})`)
        : node.selector

    selectorStack.push(selector)
  },
  exit(node) {
    selectorStack.pop()
  },
})
```

So far we aren't doing much yet, but the interesting part is when we
encounter a `declaration` (or a `comment`). The moment we see any of
those, then will we emit a node with the information from the
`selectorStack`.

We then also track the last node's `nodes` we created such that we can
push more declarations into it as a shortcut.

```ts
let result: AstNode[] = []
let nodes: AstNode[] | null = null
walk(ast, {
  enter(node) {
    if (node.kind === 'declaration') {
      // `nodes` is available, nothing special to do
      if (nodes) {
        nodes.push(node)
        return
      }

      // Track new nodes
      let nodes = [node]

      // Create a new node with a reference to `nodes` for future declarations
      let newNode = rule(selectorStack.at(-1), nodes)
      result.push(newNode)
    }
  },
})
```

The last important part is that whenever we see a new `rule`, then we
have to reset that `nodes` tracking variable such that we can create a
fresh node the next time we see a declaration.

For the `at-rules`, something similar happens but they are tracked in a
similar but separate stack. The idea there is that we can then wrap
those `at-rules` around the `newNode` we create. That way the at-rules
naturally float to the top.

I can keep going here, but I think if you're interested in this, then
you could go over the commits in this PR, or you can look at the
`ast.ts` implementation directly to see what's going on.

</details>

## Test plan

1. Existing tests should pass
2. New tests have been added to test the flattening of nested CSS
2026-07-07 18:28:41 +02:00
Lazizbek Ergashev
9b0e8af258
Ensure @tailwindcss/postcss rebuilds when the input CSS changes but its mtime is unchanged (#20310)
## Summary

`@tailwindcss/postcss` chooses between an incremental and a full rebuild
by comparing the mtimes of the entry file and its resolved
`@import`/`@config`/`@plugin` graph. It never looks at the input CSS
itself, so when that CSS is produced by an upstream tool (e.g. Sass) and
passed to the plugin via `process()`, it can change while the `from`
file's mtime stays the same. The plugin then re-emits its previously
cached output and silently drops the change.

This stores the input CSS per cache entry and takes the existing full
rebuild path when it differs from the previous compile, mirroring the
fix the CLI watcher already has for changed input files.

Fixes #20307

## Test plan

- Added a regression test in
`packages/@tailwindcss-postcss/src/index.test.ts` that compiles two
different inputs for the same on-disk `from` file (unchanged mtime) and
asserts the second compile reflects the new CSS.
- Confirmed it fails on `main` (the second compile returns the stale
first output) and passes with the fix.
- Ran the `@tailwindcss/postcss` package tests (all green) and checked
formatting with Prettier.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-07-06 11:32:20 +00:00
Robin Malfait
67c745efde
Fix bug in attribute selector parsing (#20303)
This PR fixes a bug in the selector parser where an attribute selector
followed by a type selector inside a compound selector resulted in the
wrong result.

Given you have this CSS:
```css
[data-foo]div {}
```

Then parsing it before this PR, would result in:
```ts
{
  kind: 'compound',
  nodes: [
    { kind: 'selector', value: '[data-foo]div' },
  ],
},
```

But with this PR, it's properly split:
```ts
{
  kind: 'compound',
  nodes: [
    { kind: 'selector', value: '[data-foo]' },
    { kind: 'selector', value: 'div' },
  ],
},
```

I also tweaked some of the comments in the parser that are unrelated,
but I was there already.

## Test plan

1. Added a regression test
2. All other tests should pass
2026-07-03 21:13:13 +02:00
한국
2683903b86
Support fractional opacity modifiers for named shadow sizes (#20302)
## Summary

`shadow-*`, `text-shadow-*`, `drop-shadow-*`, and `inset-shadow-*`
accept a bare (non-arbitrary) opacity modifier like `/50`, but the
named-size branch of all four utilities validates it with
`isPositiveInteger(candidate.modifier.value)` instead of
`isValidOpacityValue(candidate.modifier.value)` — the helper every other
opacity/alpha modifier in the codebase uses (`asColor`, used by `bg-*`,
`text-*`, `border-*`, `ring-*`, `fill-*`, `stroke-*`, `decoration-*`,
`accent-*`, `caret-*`, `outline-*`, `placeholder-*`, `divide-*`).

This means a fractional modifier like `/12.5` is silently ignored for a
*named size* (`shadow-sm/12.5` behaves exactly like `shadow-sm`,
dropping the modifier), while the exact same `/12.5` modifier works
correctly on the *color* variant of the same utility
(`shadow-red-500/12.5` → `color-mix(in oklab, var(--color-red-500)
12.5%, transparent)`), since that path already goes through `asColor`.

`drop-shadow-*` is worse: its named-size branch has an extra guard (`if
(candidate.modifier && !alpha) return`) that bails out of the *entire*
utility when a modifier is present but couldn't be resolved to an alpha
— so `drop-shadow-sm/12.5` produces no CSS at all.

This isn't a case of fractional percentages being unsupported by design
— the CHANGELOG entry that introduced `shadow-*/<alpha>` explicitly
describes it as controlling shadow **opacity**, and the color branch of
these same utilities already supports fractional values
(`shadow-red-500/2.25`, `/2.5`, `/2.75` are covered by existing tests).
The named-size branch just never got the same treatment.

**Repro** (verified with `pnpm --filter tailwindcss exec vitest run`):
- `shadow-red-500/12.5` → `color-mix(in oklab, var(--color-red-500)
12.5%, transparent)` (correct)
- `shadow-sm/12.5` → identical output to plain `shadow-sm` (modifier
silently dropped)
- `drop-shadow-sm/12.5` → **no CSS generated at all**
- `shadow-sm/50` (integer, control) → works correctly

## Fix

Replaced `isPositiveInteger(candidate.modifier.value)` with
`isValidOpacityValue(candidate.modifier.value)` in the four affected
utility definitions in `packages/tailwindcss/src/utilities.ts`
(`shadow`, `text-shadow`, `drop-shadow`, `inset-shadow`). No other
changes were needed — once `alpha` resolves correctly, `drop-shadow`'s
existing `if (candidate.modifier && !alpha) return` guard naturally
stops bailing out, since `alpha` is no longer `undefined` for valid
fractional modifiers.

## Test plan

- Added a regression test in
`packages/tailwindcss/src/utilities.test.ts` covering `shadow-sm/12.5`,
`text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and
`inset-shadow-sm/12.5`.
- Verified via `pnpm --filter tailwindcss exec vitest run` that this
test fails (modifier dropped / empty output) with the fix reverted, and
passes with it applied.
- Ran the full `tailwindcss` package test suite (`pnpm --filter
tailwindcss exec vitest run`) — 4685 tests passing, no regressions.
- Verified formatting on the changed files with `npx prettier --check`.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-07-03 12:34:02 +00:00
한국
04588b1e8f
Fix theme() in JS plugins returning unresolved object instead of DEFAULT value (#20299)
## Summary

When a CSS theme key defined via `@theme` (or a JS config's `theme`
object) shares a dash-separated prefix with a sibling key — e.g.
`--color-foo` and `--color-foo-bar` — calling `theme('colors.foo')` from
inside a JS plugin (`addUtilities`, `addComponents`, etc.) does not
resolve to the `foo` value. Instead it returns an internal
disambiguation object shaped like `{ DEFAULT: 'red', bar: 'blue',
__CSS_VALUES__: {...} }`, because there's no way to tell from CSS custom
property names alone whether `foo-bar` is a sibling key or a nested
sub-key of `foo`.

This same ambiguity was already fixed for the CSS-embedded `theme()`
function in #19097 (which unwraps to the `DEFAULT` key when present),
and the changelog entry for that PR states it fixes this "in JS configs
**and plugins**" — but the fix only touched `apply-compat-hooks.ts`'s
`resolveThemeValue`, not `createThemeFn`'s `theme` function that's
exposed directly to plugins in `plugin-functions.ts`. This PR closes
that gap by applying the same DEFAULT-unwrapping there.

Without this fix, passing the raw object into `addUtilities` (a very
natural thing to do, since a plugin author expects a string) produces
broken CSS — the reserved `DEFAULT` key gets mangled into a garbage
property name (`-d-e-f-a-u-l-t`) by the kebab-case conversion, and the
internal `__CSS_VALUES__` bookkeeping leaks into the generated
stylesheet.

### Minimal reproduction

```js
// tailwind.config.js (registered via @config, or any @plugin-registered plugin)
const plugin = require('tailwindcss/plugin')

module.exports = {
  plugins: [
    plugin(function ({ addUtilities, theme }) {
      addUtilities({
        '.example-foo': { color: theme('colors.foo') },
      })
    }),
  ],
}
```
```css
@import "tailwindcss";
@config "./tailwind.config.js";
@theme {
  --color-foo: red;
  --color-foo-bar: blue;
}
```

**Before:**
```css
.example-foo color {
  -d-e-f-a-u-l-t: red;
  bar: blue;
}
.example-foo color __CSS_VALUES__ {
  -d-e-f-a-u-l-t: 0;
  bar: 0;
}
```

**After:**
```css
.example-foo {
  color: red;
}
```

## Test plan

- Added a regression test in
`packages/tailwindcss/src/compat/plugin-api.test.ts` ("theme() resolves
the DEFAULT value when a bare CSS theme key shares a prefix with a
sibling key")
- Verified via `pnpm --filter tailwindcss exec vitest run` that this
test fails with the exact broken output shown above when the fix is
reverted, and passes once it's applied
- Ran the full `tailwindcss` package test suite (`pnpm --filter
tailwindcss exec vitest run`) — 4684 tests passing, no regressions
- Verified formatting on the changed files with `npx prettier --check`

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-07-02 13:20:14 +00:00
highoncomputers
b53fa096c9
fix: exclude iframes from focus-visible auto outline in Preflight (#20292)
## Description

Firefox's UA stylesheet already sets `iframe:focus-visible {
outline-style: none; }`, so Preflight's `:-moz-focusring { outline:
auto; }` rule overrides that and applies an unwanted auto outline to
focused iframes.

Adding `:where(:not(iframe))` to the selector preserves the improved
focus ring behavior for all other elements while respecting Firefox's
native iframe focus styling.

Fixes #19795

Edit by @RobinMalfait 

## Test plan

Before:
<img width="1054" height="383"
alt="file-f833142116d36e1e620290b97455f001"
src="https://github.com/user-attachments/assets/81aa170b-2cef-4964-934d-61f258335e1a"
/>


After:
<img width="1056" height="310"
alt="file-400d9214fedf5b43693e10580a4869de"
src="https://github.com/user-attachments/assets/8f8bb81d-6070-425d-8820-327bf9e88e79"
/>

---------

Co-authored-by: root <root@localhost.localdomain>
Co-authored-by: Kirk Loretz <kirk-loretz-fsn@users.noreply.github.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-07-02 10:39:28 +00:00
Robin Malfait
ef79119d4e
Bump dependencies (#20300)
This PR bumps most of the dependencies to the latest version. 

This also marked some dependencies using a range such that you get
updates for free during the installation:
```diff
 catalog:
-  enhanced-resolve: 5.21.6
+  enhanced-resolve: ^5.24.1

-  vite: 8.0.14
+  vite: ^8.1.2

-  webpack: 5.107.0
+  webpack: ^5.108.3
```

Fixes: #20291

## Test plan

1. All tests on all OSes still pass [ci-all]
2026-07-02 11:45:49 +02:00
Robin Malfait
e46b3d74ee
Canonicalization: make hex colors case insensitive (#20298)
This PR fixes an issue where hex-based colors in arbitrary properties
and values were considered case-sensitive even though they are
case-insensitive in CSS.

If you look at the linked issue, there is this input CSS:
```css
@theme {
  --color-brand-purple: #3f3cbb;
}
```

We expect that both `bg-[#3f3cbb]` and `bg-[#3F3CBB]` get canonicalized
to `color-brand-purple` but before this pr, only the first one would get
canonicalized that way (since it's a perfect match).

Technically a bunch more values are case-insensitive but a lot of them
_are_ sensitive so to get this 100% correct, a lot more parsing needs to
happen. I think we can start with this and expand the logic when needed.

Fixes: #20295

## Test plan

1. Added a regression test based on the linked issue
2. Added tests for arbitrary properties (`[color:#fff]` vs
`[color:#FFF]`), and tests for arbitrary properties (`bg-[#fff]` vs
`bg-[#FFF]`)
2026-07-02 00:04:44 +02:00
Robin Malfait
39656f7d9a
Add --poll option to @tailwindcss/cli (#20297)
This PR re-adds the `--poll` option to the `@tailwindcss/cli` that we
had in Tailwind CSS v3, but didn't in Tailwind CSS v4.

In Tailwind CSS v4, we started using `@parcel/watcher` instead of
chokidar for our watcher in the CLI. However, this currently doesn't
support a `--poll` option.

This PR implements our own `--poll` option such that you can use it in
environments where fs events don't work properly (e.g. Docker).

Polling can be enabled by using `--watch --poll`, in this case we will
poll every `250ms` (I'm open for a different default value). You can
also pick your own interval by using `--watch --poll 500` which is
defined in milliseconds.

The `--poll` option will be less efficient than a normal `--watch`. But
if you are in a situation where you can't use `--watch` on its own then
this is a good fallback.

One thing you can do today is run the build command manually. If you do
have some tooling that _does_ work on your machine (such as
[`watchexec`](https://github.com/watchexec/watchexec)) then you can
automatically perform a full build. The biggest downside of this
approach is that you are doing a full build every time, instead of an
incremental build.

With this PR, we try to fix that by still allowing incremental builds.
This should result in the same behavior as the normal `--watch`
function:

1. First run, will trigger a full build
1. When any of the source files changes:
- If all classes were already known, then it will be a no-op, but you
will see a log in the terminal about it.
- If a new class is detected, then the CSS will be updated, but it will
be much more efficient than a full rebuild
1. When the input CSS file changes, or any of its dependencies, then a
full rebuild will be triggered (such that your new `@utility` are
available, and `@theme` values are updated).

The implementation is a little bit more complex just because I didn't
want to spam the terminal output even if we are polling every `250ms`.

In the Oxide scanner we do track the modified times of each file. Every
`250ms` we traverse the file system and skip the files that we know
didn't change (since the mtime is the same). If the file was touched,
then we will parse it again to extract possible Tailwind CSS classes. We
will also track which files were scanned such that we can know whether
we have to trigger a full-rebuild or not (in case the input.css file or
any of its dependencies was changed).

Fixes: #18109
Fixes: #18540
Fixes: #15750

## Test plan

1. Existing tests pass
2. An integration test has been added for the `--poll` option
3. Tested it on the tailwindcss.com codebase:

<img width="679" height="320" alt="image"
src="https://github.com/user-attachments/assets/af858da5-3e07-448d-86bd-8eacdf3bf8d1"
/>

Annotated:
```
≈ tailwindcss v4.3.2

Done in 105ms                    Initial build
Done in 3ms                      Saved a file that resulted in a no-op
Done in 2ms                      Saved a file that resulted in a no-op
Done in 3ms                      Saved a file that resulted in a no-op
Done in 53ms                     Saved a file with a new class
Done in 2ms                      Saved a file that resulted in a no-op
Done in 2ms                      Saved a file that resulted in a no-op
Done in 3ms                      Saved a file that resulted in a no-op
Done in 88ms                     Saved a the input.css file
Done in 3ms                      Saved a file that resulted in a no-op
Polling for changes…
```
We check the file system every `250ms` by default, but we won't log to
prevent spamming the terminal.
2026-07-01 21:25:26 +02:00
137 changed files with 13733 additions and 3611 deletions

View file

@ -27,8 +27,10 @@ jobs:
- name: Linux
os: namespace-profile-default
# Playwright 1.62+ dropped WebKit support for macOS 14, and hangs
# instead of failing when launching WebKit on a macos-14 runner.
- name: macOS
os: macos-14
os: macos-15
# Exclude windows and macos from being built on feature branches
run-all:

View file

@ -7,7 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- Nothing yet!
### Added
- Add `@tailwindcss/turbopack` package to run Tailwind CSS with Next.js ([20367](https://github.com/tailwindlabs/tailwindcss/pull/20367))
### Fixed
- Ensure watch mode detects changes to symlinked `@source` files whose real paths aren't otherwise scanned ([#20356](https://github.com/tailwindlabs/tailwindcss/pull/20356))
- Ensure custom variants using `@scope` wrap the generated utilities instead of nesting inside them ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369))
- Fix flattening of `@scope` at-rules ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369))
- Fix standalone declarations in `@scope`, wrap them in `:where(:scope)` ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369))
- Always emit a space for empty fallback values in CSS variables (e.g. `var(--tw-blur,)` → `var(--tw-blur, )`) ([#20373](https://github.com/tailwindlabs/tailwindcss/pull/20373))
- Canonicalization: convert arbitrary breakpoint and container query variants to named equivalents (e.g. `max-[64rem]` → `max-lg`) ([#20380](https://github.com/tailwindlabs/tailwindcss/pull/20380))
- Prevent `@tailwindcss/vite` from crashing on every edit under Vite's experimental `bundledDev` mode ([#20379](https://github.com/tailwindlabs/tailwindcss/pull/20379))
- Ensure `@tailwindcss/oxide` falls back to WASM on platforms without native bindings ([#20383](https://github.com/tailwindlabs/tailwindcss/pull/20383))
- Detect classes in Ruby percent literals using angle brackets or custom delimiters (e.g. `%w<flex>`, `%w|flex|`), including in Slim and Haml templates ([#20387](https://github.com/tailwindlabs/tailwindcss/pull/20387))
- Preserve whitespace in `--default(…)` values in custom functional utilities (e.g. `--default(box alphabetic)` no longer becomes `boxalphabetic`) ([#20392](https://github.com/tailwindlabs/tailwindcss/pull/20392))
- Don't scan gitignored directories (e.g. `node_modules` and `.git`) when the project uses a safelist-style `.gitignore` (e.g. `/*` followed by `!/…` negations) ([#20397](https://github.com/tailwindlabs/tailwindcss/pull/20397))
- Ensure root `theme('…')` namespace lookups in JavaScript plugins and config files return the full namespace object instead of the value of its `DEFAULT` key ([#20399](https://github.com/tailwindlabs/tailwindcss/pull/20399))
- Skip ignored directories entirely when computing watch globs (`scanner.globs`), instead of walking their full contents on every rebuild ([#20408](https://github.com/tailwindlabs/tailwindcss/pull/20408))
- Oxide: drop invalid UTF-8 candidates ([#20389](https://github.com/tailwindlabs/tailwindcss/pull/20389))
- `@tailwindcss/vite` no longer forces a full page reload for external files (e.g.: `.php` files) ([#20414](https://github.com/tailwindlabs/tailwindcss/issues/20414))
- Canonicalization: don't merge utilities that reference different theme variables set to CSS-wide keywords like `unset` ([#20417](https://github.com/tailwindlabs/tailwindcss/pull/20417))
- Don't generate utilities when a modifier is used that would otherwise be silently ignored (e.g. `rounded-sm/[5]`, `shadow-sm/foo`, `stroke-2/50`) ([#20419](https://github.com/tailwindlabs/tailwindcss/pull/20419))
- Only normalize top-level `and`, `or`, and `not` keywords in `supports-[…]` variants (e.g. `selector(a: not (.foo))` → `selector(a:not(.foo))`) ([#20420](https://github.com/tailwindlabs/tailwindcss/pull/20420))
- Don't warn about Angular's `::ng-deep` and `:host-context()` when optimizing CSS ([#20434](https://github.com/tailwindlabs/tailwindcss/pull/20434))
- Don't generate CSS for candidates containing an empty additional modifier (e.g. `bg-red-500/50/` and `group-hover/foo//bar:flex`) ([#20466](https://github.com/tailwindlabs/tailwindcss/pull/20466))
- Sort `min-*`, `max-*`, and container query variants with decimal values numerically (e.g. `min-[40.25rem]` before `min-[40.5rem]`) ([#20512](https://github.com/tailwindlabs/tailwindcss/pull/20512))
- Ensure CSS comments ending with `\*/` are closed correctly instead of swallowing the CSS that follows (e.g. `/* C:\temp\*/`) ([#20508](https://github.com/tailwindlabs/tailwindcss/pull/20508))
- Improve style invalidation performance of `group-*` and `peer-*` variants ([#20513](https://github.com/tailwindlabs/tailwindcss/pull/20513))
## [4.3.3] - 2026-07-16
### Fixed
- Support `--watch --poll[=ms]` in `@tailwindcss/cli` when filesystem events are unreliable or unavailable ([#20297](https://github.com/tailwindlabs/tailwindcss/pull/20297))
- Canonicalization: match arbitrary hex colors against theme colors case-insensitively (e.g. `bg-[#fff]` and `bg-[#FFF]` → `bg-white`) ([#20298](https://github.com/tailwindlabs/tailwindcss/pull/20298))
- Prevent Preflight from overriding Firefox's native `iframe:focus-visible` outline styles ([#20292](https://github.com/tailwindlabs/tailwindcss/pull/20292))
- Ensure `theme('colors.foo')` in JS plugins resolves correctly when both `--color-foo` and `--color-foo-bar` exist ([#20299](https://github.com/tailwindlabs/tailwindcss/pull/20299))
- Ensure fractional opacity modifiers work with named shadow sizes like `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5` ([#20302](https://github.com/tailwindlabs/tailwindcss/pull/20302))
- Parse selectors like `[data-foo]div` as two selectors instead of one ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303))
- Ensure `@tailwindcss/postcss` rebuilds when a preprocessor like Sass changes the input CSS without changing the input file on disk ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310))
- Ensure CSS nesting is handled even when Lightning CSS isn't run, such as in `@tailwindcss/browser` and Tailwind Play ([#20124](https://github.com/tailwindlabs/tailwindcss/pull/20124))
- Prevent achromatic theme colors from shifting hue when mixed in polar color spaces like `oklch` ([#20314](https://github.com/tailwindlabs/tailwindcss/pull/20314))
- Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `<length>` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319))
- Load `@parcel/watcher` only when needed in `@tailwindcss/cli --watch` mode, so one-off builds and `--watch --poll` work when `@parcel/watcher` can't be loaded ([#20325](https://github.com/tailwindlabs/tailwindcss/pull/20325))
- Use explicit platform fonts instead of `system-ui` and `ui-sans-serif` so CJK text respects the page's `lang` attribute on Windows ([#20318](https://github.com/tailwindlabs/tailwindcss/pull/20318))
- Prevent `@tailwindcss/upgrade` from rewriting ignored files when run from a subdirectory ([#20329](https://github.com/tailwindlabs/tailwindcss/pull/20329))
- Ensure earlier `@source` rules pointing to nested files are scanned when later `@source` rules point to files in parent folders ([#20335](https://github.com/tailwindlabs/tailwindcss/pull/20335))
- Prevent `@tailwindcss/vite` from triggering full page reloads when scanned files are processed by Vite but haven't been loaded as modules yet ([#20336](https://github.com/tailwindlabs/tailwindcss/pull/20336))
## [4.3.2] - 2026-06-26
@ -4075,7 +4123,8 @@ No release notes
- Everything!
[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.2...HEAD
[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.3...HEAD
[4.3.3]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.2...v4.3.3
[4.3.2]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.1...v4.3.2
[4.3.1]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.0...v4.3.1
[4.3.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.4...v4.3.0

128
Cargo.lock generated
View file

@ -40,7 +40,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
dependencies = [
"memchr",
"regex-automata 0.4.8",
"regex-automata 0.4.18",
"serde",
]
@ -59,6 +59,17 @@ dependencies = [
"syn",
]
[[package]]
name = "console"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "convert_case"
version = "0.11.0"
@ -104,19 +115,9 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]]
name = "ctor"
version = "0.10.1"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf"
dependencies = [
"ctor-proc-macro",
"dtor",
]
[[package]]
name = "ctor-proc-macro"
version = "0.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a949c44fcacbbbb7ada007dc7acb34603dd97cd47de5d054f2b6493ecebb483"
checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f"
[[package]]
name = "diff"
@ -124,21 +125,6 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "dtor"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
dependencies = [
"dtor-proc-macro",
]
[[package]]
name = "dtor-proc-macro"
version = "0.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2647271c92754afcb174e758003cfd1cbf1e43e5a7853d7b1813e63e19e39a73"
[[package]]
name = "dunce"
version = "1.0.5"
@ -151,6 +137,12 @@ version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "errno"
version = "0.3.9"
@ -266,14 +258,14 @@ dependencies = [
[[package]]
name = "globset"
version = "0.4.17"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eab69130804d941f8075cfd713bf8848a2c3b3f201a9457a11e6f87e1ab62305"
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata 0.4.8",
"regex-automata 0.4.18",
"regex-syntax 0.8.5",
]
@ -298,7 +290,7 @@ dependencies = [
"globset",
"log",
"memchr",
"regex-automata 0.4.8",
"regex-automata 0.4.18",
"same-file",
"walkdir",
"winapi-util",
@ -306,7 +298,7 @@ dependencies = [
[[package]]
name = "ignore"
version = "0.4.24"
version = "0.4.33"
dependencies = [
"bstr",
"crossbeam-channel",
@ -315,12 +307,24 @@ dependencies = [
"globset",
"log",
"memchr",
"regex-automata 0.4.8",
"regex-automata 0.4.18",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "insta"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
dependencies = [
"console",
"once_cell",
"similar",
"tempfile",
]
[[package]]
name = "itertools"
version = "0.11.0"
@ -387,9 +391,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "napi"
version = "3.8.5"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa73b028610e2b26e9e40bd2c8ff8a98e6d7ed5d67d89ebf4bfd2f992616b024"
checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941"
dependencies = [
"bitflags",
"ctor",
@ -402,15 +406,15 @@ dependencies = [
[[package]]
name = "napi-build"
version = "2.3.1"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]]
name = "napi-derive"
version = "3.5.4"
version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7430702d3cc05cf55f0a2c9e41d991c3b7a53f91e6146a8f282b1bfc7f3fd133"
checksum = "a49c513341a61a16a10af6efcce46b30d0822ba2d4fb197d24d33dfc199c78d5"
dependencies = [
"convert_case",
"ctor",
@ -422,9 +426,9 @@ dependencies = [
[[package]]
name = "napi-derive-backend"
version = "5.0.3"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ca5a083f2c9b49a0c7d33ec75c083498849c6fcc46f5497317faa39ea77f5d5"
checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd"
dependencies = [
"convert_case",
"proc-macro2",
@ -435,9 +439,9 @@ dependencies = [
[[package]]
name = "napi-sys"
version = "3.2.1"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b"
checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a"
dependencies = [
"libloading",
]
@ -470,9 +474,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.19.0"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "overload"
@ -516,9 +520,9 @@ dependencies = [
[[package]]
name = "rayon"
version = "1.10.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
@ -526,9 +530,9 @@ dependencies = [
[[package]]
name = "rayon-core"
version = "1.12.1"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
@ -542,7 +546,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.4.8",
"regex-automata 0.4.18",
"regex-syntax 0.8.5",
]
@ -557,9 +561,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.8"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
@ -627,6 +631,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "slab"
version = "0.4.12"
@ -671,7 +681,8 @@ dependencies = [
"dunce",
"fast-glob",
"globwalk",
"ignore 0.4.24",
"ignore 0.4.33",
"insta",
"log",
"pretty_assertions",
"rayon",
@ -857,6 +868,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"

View file

@ -1,6 +1,6 @@
[package]
name = "ignore"
version = "0.4.24" #:version
version = "0.4.33" #:version
authors = ["Andrew Gallant <jamslam@gmail.com>"]
description = """
A fast library for efficiently matching ignore files such as `.gitignore`
@ -12,7 +12,10 @@ repository = "https://github.com/BurntSushi/ripgrep/tree/master/crates/ignore"
readme = "README.md"
keywords = ["glob", "ignore", "gitignore", "pattern", "file"]
license = "Unlicense OR MIT"
# CHANGED: Use an explicit edition instead of `edition.workspace = true` since this crate is
# vendored into the Tailwind CSS workspace.
edition = "2024"
rust-version = "1.88"
[lib]
name = "ignore"
@ -20,15 +23,17 @@ bench = false
[dependencies]
crossbeam-deque = "0.8.3"
globset = "0.4.17"
# CHANGED: Use the published globset crate instead of a path dependency.
globset = "0.4.20"
log = "0.4.20"
memchr = "2.6.3"
same-file = "1.0.6"
walkdir = "2.4.0"
# CHANGED: Added `dunce` to canonicalize paths without UNC prefixes on Windows.
dunce = "1.0.5"
[dependencies.regex-automata]
version = "0.4.0"
version = "0.4.18"
default-features = false
features = ["std", "perf", "syntax", "meta", "nfa", "hybrid", "dfa-onepass"]

View file

@ -18,9 +18,7 @@ fn main() {
let stdout_thread = std::thread::spawn(move || {
let mut stdout = std::io::BufWriter::new(std::io::stdout());
for dent in rx {
stdout
.write_all(&Vec::from_path_lossy(dent.path()))
.unwrap();
stdout.write_all(&Vec::from_path_lossy(dent.path())).unwrap();
stdout.write_all(b"\n").unwrap();
}
});

View file

@ -47,6 +47,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["cml"], &["*.cml"]),
(&["coffeescript"], &["*.coffee"]),
(&["config"], &["*.cfg", "*.conf", "*.config", "*.ini"]),
(&["container"], &["*Containerfile*", "*Dockerfile*"]),
(&["coq"], &["*.v"]),
(&["cpp"], &[
"*.[ChH]", "*.cc", "*.[ch]pp", "*.[ch]xx", "*.hh", "*.inl",
@ -109,6 +110,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["hbs"], &["*.hbs"]),
(&["hs"], &["*.hs", "*.lhs"]),
(&["html"], &["*.htm", "*.html", "*.ejs"]),
(&["hurl"], &["*.hurl"]),
(&["hy"], &["*.hy"]),
(&["idris"], &["*.idr", "*.lidr"]),
(&["janet"], &["*.janet"]),
@ -185,6 +187,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["mint"], &["*.mint"]),
(&["mk"], &["mkfile"]),
(&["ml"], &["*.ml"]),
(&["mojo"], &["*.mojo"]),
(&["motoko"], &["*.mo"]),
(&["msbuild"], &[
"*.csproj", "*.fsproj", "*.vcxproj", "*.proj", "*.props", "*.targets",
@ -206,11 +209,12 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
"*.php", "*.php3", "*.php4", "*.php5", "*.php7", "*.php8",
"*.pht", "*.phtml"
]),
(&["pkgbuild"], &["PKGBUILD"]),
(&["po"], &["*.po"]),
(&["pod"], &["*.pod"]),
(&["postscript"], &["*.eps", "*.ps"]),
(&["prolog"], &["*.pl", "*.pro", "*.prolog", "*.P"]),
(&["protobuf"], &["*.proto"]),
(&["proto", "protobuf"], &["*.proto"]),
(&["ps"], &["*.cdxml", "*.ps1", "*.ps1xml", "*.psd1", "*.psm1"]),
(&["puppet"], &["*.epp", "*.erb", "*.pp", "*.rb"]),
(&["purs"], &["*.purs"]),
@ -231,6 +235,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["red"], &["*.r", "*.red", "*.reds"]),
(&["rescript"], &["*.res", "*.resi"]),
(&["robot"], &["*.robot"]),
(&["rocq"], &["*.v"]),
(&["rst"], &["*.rst"]),
(&["ruby"], &[
// Idiomatic files
@ -274,6 +279,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["spark"], &["*.spark"]),
(&["spec"], &["*.spec"]),
(&["sql"], &["*.sql", "*.psql"]),
(&["ssa"], &["*.ssa"]),
(&["stylus"], &["*.styl"]),
(&["sv"], &["*.v", "*.vg", "*.sv", "*.svh", "*.h"]),
(&["svelte"], &["*.svelte", "*.svelte.ts"]),
@ -359,4 +365,14 @@ mod tests {
previous_name = name;
}
}
#[test]
fn default_types_aliases_are_sorted() {
for (aliases, _) in DEFAULT_TYPES.iter() {
assert!(
aliases.is_sorted(),
"this alias list is not sorted: {aliases:?}",
);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -102,7 +102,9 @@ impl Gitignore {
///
/// Note that I/O errors are ignored. For more granular control over
/// errors, use `GitignoreBuilder`.
pub fn new<P: AsRef<Path>>(gitignore_path: P) -> (Gitignore, Option<Error>) {
pub fn new<P: AsRef<Path>>(
gitignore_path: P,
) -> (Gitignore, Option<Error>) {
let path = gitignore_path.as_ref();
let parent = path.parent().unwrap_or(Path::new("/"));
let mut builder = GitignoreBuilder::new(parent);
@ -123,6 +125,17 @@ impl Gitignore {
/// The global config file path is specified by git's `core.excludesFile`
/// config option.
///
/// # Behavior
///
/// This routine does its best to discover any global git exclude files.
/// This will try to parse out the `excludesFile` config option in your
/// global git configuration, if necessary.
///
/// The specific things this routine tries (which are subject to change
/// based on how git behaves) are:
///
///
///
/// Git's config file location is `$HOME/.gitconfig`. If `$HOME/.gitconfig`
/// does not exist or does not specify `core.excludesFile`, then
/// `$XDG_CONFIG_HOME/git/ignore` is read. If `$XDG_CONFIG_HOME` is not
@ -145,7 +158,8 @@ impl Gitignore {
num_ignores: 0,
num_whitelists: 0,
matches: None,
// CHANGED: Add a flag to have Gitignore rules that apply only to files.
// CHANGED: Add a flag to have Gitignore rules that apply only to
// files.
only_on_files: false,
}
}
@ -190,7 +204,11 @@ impl Gitignore {
/// determined by a common suffix of the directory containing this
/// gitignore) is stripped. If there is no common suffix/prefix overlap,
/// then `path` is assumed to be relative to this matcher.
pub fn matched<P: AsRef<Path>>(&self, path: P, is_dir: bool) -> Match<&Glob> {
pub fn matched<P: AsRef<Path>>(
&self,
path: P,
is_dir: bool,
) -> Match<&Glob> {
if self.is_empty() {
return Match::None;
}
@ -243,11 +261,16 @@ impl Gitignore {
}
/// Like matched, but takes a path that has already been stripped.
fn matched_stripped<P: AsRef<Path>>(&self, path: P, is_dir: bool) -> Match<&Glob> {
fn matched_stripped<P: AsRef<Path>>(
&self,
path: P,
is_dir: bool,
) -> Match<&Glob> {
if self.is_empty() {
return Match::None;
}
// CHANGED: Rules marked as only_on_files can not match against directories.
// CHANGED: Rules marked as only_on_files can not match against
// directories.
if self.only_on_files && is_dir {
return Match::None;
}
@ -270,7 +293,10 @@ impl Gitignore {
/// Strips the given path such that it's suitable for matching with this
/// gitignore matcher.
fn strip<'a, P: 'a + AsRef<Path> + ?Sized>(&'a self, path: &'a P) -> &'a Path {
fn strip<'a, P: 'a + AsRef<Path> + ?Sized>(
&'a self,
path: &'a P,
) -> &'a Path {
let mut path = path.as_ref();
// A leading ./ is completely superfluous. We also strip it from
// our gitignore root path, so we need to strip it from our candidate
@ -326,7 +352,8 @@ impl GitignoreBuilder {
globs: vec![],
case_insensitive: false,
allow_unclosed_class: true,
// CHANGED: Add a flag to have Gitignore rules that apply only to files.
// CHANGED: Add a flag to have Gitignore rules that apply only to
// files.
only_on_files: false,
}
}
@ -337,18 +364,21 @@ impl GitignoreBuilder {
pub fn build(&self) -> Result<Gitignore, Error> {
let nignore = self.globs.iter().filter(|g| !g.is_whitelist()).count();
let nwhite = self.globs.iter().filter(|g| g.is_whitelist()).count();
let set = self.builder.build().map_err(|err| Error::Glob {
glob: None,
err: err.to_string(),
})?;
let set = self
.builder
.build()
.map_err(|err| Error::Glob { glob: None, err: err.to_string() })?;
Ok(Gitignore {
set,
root: self.root.clone(),
globs: self.globs.clone(),
num_ignores: nignore as u64,
num_whitelists: nwhite as u64,
matches: Some(Arc::new(Pool::new(|| vec![]))),
// CHANGED: Add a flag to have Gitignore rules that apply only to files.
matches: Some(Arc::new(
Pool::with_available_parallelism_capacity(|| vec![]),
)),
// CHANGED: Add a flag to have Gitignore rules that apply only to
// files.
only_on_files: self.only_on_files,
})
}
@ -411,11 +441,8 @@ impl GitignoreBuilder {
// Match Git's handling of .gitignore files that begin with the Unicode BOM
const UTF8_BOM: &str = "\u{feff}";
let line = if i == 0 {
line.trim_start_matches(UTF8_BOM)
} else {
&line
};
let line =
if i == 0 { line.trim_start_matches(UTF8_BOM) } else { &line };
if let Err(err) = self.add_line(Some(path.to_path_buf()), &line) {
errs.push(err.tagged(path, lineno));
@ -537,7 +564,10 @@ impl GitignoreBuilder {
/// affected.
///
/// This is disabled by default.
pub fn case_insensitive(&mut self, yes: bool) -> Result<&mut GitignoreBuilder, Error> {
pub fn case_insensitive(
&mut self,
yes: bool,
) -> Result<&mut GitignoreBuilder, Error> {
// TODO: This should not return a `Result`. Fix this in the next semver
// release.
self.case_insensitive = yes;
@ -556,7 +586,10 @@ impl GitignoreBuilder {
/// modes since the glob parser becomes more permissive. You might want to
/// enable this when compatibility (e.g., with POSIX glob implementations)
/// is more important than good error messages.
pub fn allow_unclosed_class(&mut self, yes: bool) -> &mut GitignoreBuilder {
pub fn allow_unclosed_class(
&mut self,
yes: bool,
) -> &mut GitignoreBuilder {
self.allow_unclosed_class = yes;
self
}
@ -576,32 +609,56 @@ impl GitignoreBuilder {
///
/// Note that the file path returned may not exist.
pub fn gitconfig_excludes_path() -> Option<PathBuf> {
// git supports $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config. Notably,
// both can be active at the same time, where $HOME/.gitconfig takes
// precedent. So if $HOME/.gitconfig defines a `core.excludesFile`, then
// we're done.
match gitconfig_home_contents().and_then(|x| parse_excludes_file(&x)) {
Some(path) => return Some(path),
None => {}
// When GIT_CONFIG_GLOBAL is set, it replaces both $HOME/.gitconfig and
// $XDG_CONFIG_HOME/git/config (per git 2.32+). Otherwise, git supports
// $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config simultaneously, where
// $HOME/.gitconfig takes precedent.
gitconfig_global_env_contents()
.and_then(|x| parse_excludes_file(&x))
.or_else(|| {
gitconfig_home_contents().and_then(|x| parse_excludes_file(&x))
})
.or_else(|| {
gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x))
})
// System-level config has the lowest priority for core.excludesFile.
// GIT_CONFIG_SYSTEM overrides the default /etc/gitconfig path.
.or_else(|| {
gitconfig_system_contents().and_then(|x| parse_excludes_file(&x))
})
.or_else(excludes_file_default)
}
/// Returns the file contents of git's global config file from the path
/// specified by the `GIT_CONFIG_GLOBAL` environment variable.
fn gitconfig_global_env_contents() -> Option<Vec<u8>> {
let path = std::env::var_os("GIT_CONFIG_GLOBAL").map(PathBuf::from)?;
if path.as_os_str().is_empty() {
return None;
}
match gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x)) {
Some(path) => return Some(path),
None => {}
}
excludes_file_default()
let mut file = BufReader::new(File::open(path).ok()?);
let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents)
}
/// Returns the file contents of git's system-level config file.
///
/// Checks `GIT_CONFIG_SYSTEM` first, then falls back to `/etc/gitconfig`.
fn gitconfig_system_contents() -> Option<Vec<u8>> {
let path = std::env::var_os("GIT_CONFIG_SYSTEM")
.map(PathBuf::from)
.filter(|x| !x.as_os_str().is_empty())
.unwrap_or_else(|| PathBuf::from("/etc/gitconfig"));
let mut file = BufReader::new(File::open(path).ok()?);
let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents)
}
/// Returns the file contents of git's global config file, if one exists, in
/// the user's home directory.
fn gitconfig_home_contents() -> Option<Vec<u8>> {
let home = match home_dir() {
None => return None,
Some(home) => home,
};
let mut file = match File::open(home.join(".gitconfig")) {
Err(_) => return None,
Ok(file) => BufReader::new(file),
};
let home = home_dir()?;
let mut file = BufReader::new(File::open(home.join(".gitconfig")).ok()?);
let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents)
}
@ -610,19 +667,11 @@ fn gitconfig_home_contents() -> Option<Vec<u8>> {
/// the user's XDG_CONFIG_HOME directory.
fn gitconfig_xdg_contents() -> Option<Vec<u8>> {
let path = std::env::var_os("XDG_CONFIG_HOME")
.and_then(|x| {
if x.is_empty() {
None
} else {
Some(PathBuf::from(x))
}
})
.map(PathBuf::from)
.filter(|x| !x.as_os_str().is_empty())
.or_else(|| home_dir().map(|p| p.join(".config")))
.map(|x| x.join("git/config"));
let mut file = match path.and_then(|p| File::open(p).ok()) {
None => return None,
Some(file) => BufReader::new(file),
};
.map(|x| x.join("git/config"))?;
let mut file = BufReader::new(File::open(path).ok()?);
let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents)
}
@ -632,13 +681,8 @@ fn gitconfig_xdg_contents() -> Option<Vec<u8>> {
/// Specifically, this respects XDG_CONFIG_HOME.
fn excludes_file_default() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.and_then(|x| {
if x.is_empty() {
None
} else {
Some(PathBuf::from(x))
}
})
.map(PathBuf::from)
.filter(|x| !x.as_os_str().is_empty())
.or_else(|| home_dir().map(|p| p.join(".config")))
.map(|x| x.join("git/ignore"))
}
@ -667,9 +711,7 @@ fn parse_excludes_file(data: &[u8]) -> Option<PathBuf> {
re.captures(data, &mut caps);
let span = caps.get_group(1)?;
let candidate = &data[span];
std::str::from_utf8(candidate)
.ok()
.map(|s| PathBuf::from(expand_tilde(s)))
std::str::from_utf8(candidate).ok().map(|s| PathBuf::from(expand_tilde(s)))
}
/// Expands ~ in file paths to the value of $HOME.
@ -831,7 +873,10 @@ mod tests {
fn parse_excludes_file4() {
let data = bytes("[core]\nexcludesFile = \"~/foo/bar\"");
let got = super::parse_excludes_file(&data);
assert_eq!(path_string(got.unwrap()), super::expand_tilde("~/foo/bar"));
assert_eq!(
path_string(got.unwrap()),
super::expand_tilde("~/foo/bar")
);
}
#[test]

File diff suppressed because it is too large Load diff

View file

@ -48,13 +48,16 @@ See the documentation for `WalkBuilder` for many other options.
use std::path::{Path, PathBuf};
pub use crate::incremental::{IncrementalIgnore, IncrementalMatch};
pub use crate::walk::{
DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, WalkParallel, WalkState,
DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder,
WalkParallel, WalkState,
};
mod default_types;
mod dir;
pub mod gitignore;
mod incremental;
pub mod overrides;
mod pathutil;
pub mod types;
@ -120,34 +123,31 @@ impl Clone for Error {
fn clone(&self) -> Error {
match *self {
Error::Partial(ref errs) => Error::Partial(errs.clone()),
Error::WithLineNumber { line, ref err } => Error::WithLineNumber {
line,
err: err.clone(),
},
Error::WithPath { ref path, ref err } => Error::WithPath {
path: path.clone(),
err: err.clone(),
},
Error::WithDepth { depth, ref err } => Error::WithDepth {
depth,
err: err.clone(),
},
Error::Loop {
ref ancestor,
ref child,
} => Error::Loop {
Error::WithLineNumber { line, ref err } => {
Error::WithLineNumber { line, err: err.clone() }
}
Error::WithPath { ref path, ref err } => {
Error::WithPath { path: path.clone(), err: err.clone() }
}
Error::WithDepth { depth, ref err } => {
Error::WithDepth { depth, err: err.clone() }
}
Error::Loop { ref ancestor, ref child } => Error::Loop {
ancestor: ancestor.clone(),
child: child.clone(),
},
Error::Io(ref err) => match err.raw_os_error() {
Some(e) => Error::Io(std::io::Error::from_raw_os_error(e)),
None => Error::Io(std::io::Error::new(err.kind(), err.to_string())),
None => {
Error::Io(std::io::Error::new(err.kind(), err.to_string()))
}
},
Error::Glob { ref glob, ref err } => Error::Glob {
glob: glob.clone(),
err: err.clone(),
},
Error::UnrecognizedFileType(ref err) => Error::UnrecognizedFileType(err.clone()),
Error::Glob { ref glob, ref err } => {
Error::Glob { glob: glob.clone(), err: err.clone() }
}
Error::UnrecognizedFileType(ref err) => {
Error::UnrecognizedFileType(err.clone())
}
Error::InvalidDefinition => Error::InvalidDefinition,
}
}
@ -269,19 +269,14 @@ impl Error {
/// Turn an error into a tagged error with the given depth.
fn with_depth(self, depth: usize) -> Error {
Error::WithDepth {
depth,
err: Box::new(self),
}
Error::WithDepth { depth, err: Box::new(self) }
}
/// Turn an error into a tagged error with the given file path and line
/// number. If path is empty, then it is omitted from the error.
fn tagged<P: AsRef<Path>>(self, path: P, lineno: u64) -> Error {
let errline = Error::WithLineNumber {
line: lineno,
err: Box::new(self),
};
let errline =
Error::WithLineNumber { line: lineno, err: Box::new(self) };
if path.as_ref().as_os_str().is_empty() {
return errline;
}
@ -301,12 +296,12 @@ impl Error {
};
}
let path = err.path().map(|p| p.to_path_buf());
let mut ig_err = Error::Io(std::io::Error::from(err));
let mut ig_err = Error::WithDepth {
depth,
err: Box::new(Error::Io(std::io::Error::from(err))),
};
if let Some(path) = path {
ig_err = Error::WithPath {
path,
err: Box::new(ig_err),
};
ig_err = Error::WithPath { path, err: Box::new(ig_err) };
}
ig_err
}
@ -333,7 +328,8 @@ impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Error::Partial(ref errs) => {
let msgs: Vec<String> = errs.iter().map(|err| err.to_string()).collect();
let msgs: Vec<String> =
errs.iter().map(|err| err.to_string()).collect();
write!(f, "{}", msgs.join("\n"))
}
Error::WithLineNumber { line, ref err } => {
@ -343,10 +339,7 @@ impl std::fmt::Display for Error {
write!(f, "{}: {}", path.display(), err)
}
Error::WithDepth { ref err, .. } => err.fmt(f),
Error::Loop {
ref ancestor,
ref child,
} => write!(
Error::Loop { ref ancestor, ref child } => write!(
f,
"File system loop found: \
{} points to an ancestor {}",
@ -354,14 +347,8 @@ impl std::fmt::Display for Error {
ancestor.display()
),
Error::Io(ref err) => err.fmt(f),
Error::Glob {
glob: None,
ref err,
} => write!(f, "{}", err),
Error::Glob {
glob: Some(ref glob),
ref err,
} => {
Error::Glob { glob: None, ref err } => write!(f, "{}", err),
Error::Glob { glob: Some(ref glob), ref err } => {
write!(f, "error parsing glob '{}': {}", glob, err)
}
Error::UnrecognizedFileType(ref ty) => {
@ -507,7 +494,8 @@ mod tests {
};
/// A convenient result type alias.
pub(crate) type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
pub(crate) type Result<T> =
std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
macro_rules! err {
($($tt:tt)*) => {
@ -545,8 +533,9 @@ mod tests {
if path.is_dir() {
continue;
}
fs::create_dir_all(&path)
.map_err(|e| err!("failed to create {}: {}", path.display(), e))?;
fs::create_dir_all(&path).map_err(|e| {
err!("failed to create {}: {}", path.display(), e)
})?;
return Ok(TempDir(path));
}
Err(err!("failed to create temp dir after {} tries", TRIES))

View file

@ -94,7 +94,11 @@ impl Override {
/// given) is stripped. If there is no common suffix/prefix overlap, then
/// `path` is assumed to reside in the same directory as the root path for
/// this set of overrides.
pub fn matched<'a, P: AsRef<Path>>(&'a self, path: P, is_dir: bool) -> Match<Glob<'a>> {
pub fn matched<'a, P: AsRef<Path>>(
&'a self,
path: P,
is_dir: bool,
) -> Match<Glob<'a>> {
if self.is_empty() {
return Match::None;
}
@ -146,7 +150,10 @@ impl OverrideBuilder {
/// affected.
///
/// This is disabled by default.
pub fn case_insensitive(&mut self, yes: bool) -> Result<&mut OverrideBuilder, Error> {
pub fn case_insensitive(
&mut self,
yes: bool,
) -> Result<&mut OverrideBuilder, Error> {
// TODO: This should not return a `Result`. Fix this in the next semver
// release.
self.builder.case_insensitive(yes)?;
@ -276,11 +283,8 @@ mod tests {
#[test]
fn default_case_sensitive() {
let ov = OverrideBuilder::new(ROOT)
.add("*.html")
.unwrap()
.build()
.unwrap();
let ov =
OverrideBuilder::new(ROOT).add("*.html").unwrap().build().unwrap();
assert!(ov.matched("foo.html", false).is_whitelist());
assert!(ov.matched("foo.HTML", false).is_ignore());
assert!(ov.matched("foo.htm", false).is_ignore());

View file

@ -2,55 +2,89 @@ use std::{ffi::OsStr, path::Path};
use crate::walk::DirEntry;
/// Returns true if and only if this entry is considered to be hidden.
/// Returns true if and only if this path is considered to be hidden.
///
/// This only returns true if the base name of the path starts with a `.`.
/// # Platform behavior
///
/// On Unix, this implements a more optimized check.
#[cfg(unix)]
pub(crate) fn is_hidden(dent: &DirEntry) -> bool {
use std::os::unix::ffi::OsStrExt;
if let Some(name) = file_name(dent.path()) {
name.as_bytes().get(0) == Some(&b'.')
} else {
false
}
}
/// Returns true if and only if this entry is considered to be hidden.
/// ## Windows
///
/// On Windows, this returns true if one of the following is true:
/// This returns true if one of the following is true:
///
/// * The base name of the path starts with a `.`.
/// * The file attributes have the `HIDDEN` property set.
#[cfg(windows)]
pub(crate) fn is_hidden(dent: &DirEntry) -> bool {
use std::os::windows::fs::MetadataExt;
use winapi_util::file;
// This looks like we're doing an extra stat call, but on Windows, the
// directory traverser reuses the metadata retrieved from each directory
// entry and stores it on the DirEntry itself. So this is "free."
if let Ok(md) = dent.metadata() {
if file::is_hidden(md.file_attributes() as u64) {
return true;
}
}
if let Some(name) = file_name(dent.path()) {
name.to_str().map(|s| s.starts_with(".")).unwrap_or(false)
} else {
false
}
}
/// Returns true if and only if this entry is considered to be hidden.
///
/// ## All other platforms
///
/// This only returns true if the base name of the path starts with a `.`.
#[cfg(not(any(unix, windows)))]
pub(crate) fn is_hidden(dent: &DirEntry) -> bool {
if let Some(name) = file_name(dent.path()) {
name.to_str().map(|s| s.starts_with(".")).unwrap_or(false)
pub(crate) fn is_hidden_path(dent: &Path) -> bool {
#[cfg(not(windows))]
fn imp(path: &Path) -> bool {
is_hidden_path_only(path)
}
#[cfg(windows)]
fn imp(path: &Path) -> bool {
use std::os::windows::fs::MetadataExt;
use winapi_util::file;
if let Ok(md) = path.metadata() {
if file::is_hidden(md.file_attributes() as u64) {
return true;
}
}
is_hidden_path_only(path)
}
imp(dent)
}
/// Returns true if and only if this directory entry is considered to be
/// hidden.
///
/// # Platform behavior
///
/// ## Windows
///
/// This returns true if one of the following is true:
///
/// * The base name of the path starts with a `.`.
/// * The file attributes have the `HIDDEN` property set.
///
/// ## All other platforms
///
/// This only returns true if the base name of the path starts with a `.`.
pub(crate) fn is_hidden_entry(dent: &DirEntry) -> bool {
#[cfg(not(windows))]
fn imp(dent: &DirEntry) -> bool {
is_hidden_path_only(dent.path())
}
#[cfg(windows)]
fn imp(dent: &DirEntry) -> bool {
use std::os::windows::fs::MetadataExt;
use winapi_util::file;
// This looks like we're doing an extra stat call, but on Windows, the
// directory traverser reuses the metadata retrieved from each directory
// entry and stores it on the DirEntry itself. So this is "free."
if let Ok(md) = dent.metadata() {
if file::is_hidden(md.file_attributes() as u64) {
return true;
}
}
is_hidden_path_only(dent.path())
}
imp(dent)
}
/// Returns true if and only if this path is considered to be hidden from only
/// the path itself.
///
/// This has the same behavior on all platforms.
fn is_hidden_path_only(path: &Path) -> bool {
if let Some(name) = file_name(path) {
name.as_encoded_bytes().starts_with(b".")
} else {
false
}
@ -59,83 +93,79 @@ pub(crate) fn is_hidden(dent: &DirEntry) -> bool {
/// Strip `prefix` from the `path` and return the remainder.
///
/// If `path` doesn't have a prefix `prefix`, then return `None`.
#[cfg(unix)]
pub(crate) fn strip_prefix<'a, P: AsRef<Path> + ?Sized>(
prefix: &'a P,
path: &'a Path,
) -> Option<&'a Path> {
use std::os::unix::ffi::OsStrExt;
#[cfg(unix)]
fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> {
use std::os::unix::ffi::OsStrExt;
let prefix = prefix.as_ref().as_os_str().as_bytes();
let path = path.as_os_str().as_bytes();
if prefix.len() > path.len() || prefix != &path[0..prefix.len()] {
None
} else {
Some(&Path::new(OsStr::from_bytes(&path[prefix.len()..])))
let prefix = prefix.as_os_str().as_bytes();
let path = path.as_os_str().as_bytes();
if prefix.len() > path.len() || prefix != &path[0..prefix.len()] {
None
} else {
Some(&Path::new(OsStr::from_bytes(&path[prefix.len()..])))
}
}
}
/// Strip `prefix` from the `path` and return the remainder.
///
/// If `path` doesn't have a prefix `prefix`, then return `None`.
#[cfg(not(unix))]
pub(crate) fn strip_prefix<'a, P: AsRef<Path> + ?Sized>(
prefix: &'a P,
path: &'a Path,
) -> Option<&'a Path> {
path.strip_prefix(prefix).ok()
#[cfg(not(unix))]
fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> {
path.strip_prefix(prefix).ok()
}
imp(prefix.as_ref(), path)
}
/// Returns true if this file path is just a file name. i.e., Its parent is
/// the empty string.
#[cfg(unix)]
pub(crate) fn is_file_name<P: AsRef<Path>>(path: P) -> bool {
use std::os::unix::ffi::OsStrExt;
use memchr::memchr;
let path = path.as_ref().as_os_str().as_bytes();
memchr(b'/', path).is_none()
}
/// Returns true if this file path is just a file name. i.e., Its parent is
/// the empty string.
#[cfg(not(unix))]
pub(crate) fn is_file_name<P: AsRef<Path>>(path: P) -> bool {
path.as_ref()
.parent()
.map(|p| p.as_os_str().is_empty())
.unwrap_or(false)
}
/// The final component of the path, if it is a normal file.
///
/// If the path terminates in ., .., or consists solely of a root of prefix,
/// file_name will return None.
#[cfg(unix)]
pub(crate) fn file_name<'a, P: AsRef<Path> + ?Sized>(path: &'a P) -> Option<&'a OsStr> {
use memchr::memrchr;
use std::os::unix::ffi::OsStrExt;
let path = path.as_ref().as_os_str().as_bytes();
if path.is_empty() {
return None;
} else if path.len() == 1 && path[0] == b'.' {
return None;
} else if path.last() == Some(&b'.') {
return None;
} else if path.len() >= 2 && &path[path.len() - 2..] == &b".."[..] {
return None;
#[cfg(unix)]
{
memchr::memchr(b'/', path.as_ref().as_os_str().as_encoded_bytes())
.is_none()
}
#[cfg(not(unix))]
{
path.as_ref()
.parent()
.map(|p| p.as_os_str().is_empty())
.unwrap_or(false)
}
let last_slash = memrchr(b'/', path).map(|i| i + 1).unwrap_or(0);
Some(OsStr::from_bytes(&path[last_slash..]))
}
/// The final component of the path, if it is a normal file.
///
/// If the path terminates in ., .., or consists solely of a root of prefix,
/// file_name will return None.
#[cfg(not(unix))]
pub(crate) fn file_name<'a, P: AsRef<Path> + ?Sized>(path: &'a P) -> Option<&'a OsStr> {
path.as_ref().file_name()
/// If the path terminates in `.`, `..`, or consists solely of a root of
/// prefix, this will return `None`.
pub(crate) fn file_name<'a, P: AsRef<Path> + ?Sized>(
path: &'a P,
) -> Option<&'a OsStr> {
#[cfg(unix)]
fn imp(path: &Path) -> Option<&OsStr> {
use std::os::unix::ffi::OsStrExt;
use memchr::memrchr;
let path = path.as_os_str().as_bytes();
if path.is_empty() {
return None;
} else if path.len() == 1 && path[0] == b'.' {
return None;
} else if path.last() == Some(&b'.') {
return None;
} else if path.len() >= 2 && &path[path.len() - 2..] == &b".."[..] {
return None;
}
let last_slash = memrchr(b'/', path).map(|i| i + 1).unwrap_or(0);
Some(OsStr::from_bytes(&path[last_slash..]))
}
#[cfg(not(unix))]
fn imp(path: &Path) -> Option<&OsStr> {
path.file_name()
}
imp(path.as_ref())
}

View file

@ -204,8 +204,12 @@ impl<T> Selection<T> {
fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Selection<U> {
match self {
Selection::Select(name, inner) => Selection::Select(name, f(inner)),
Selection::Negate(name, inner) => Selection::Negate(name, f(inner)),
Selection::Select(name, inner) => {
Selection::Select(name, f(inner))
}
Selection::Negate(name, inner) => {
Selection::Negate(name, f(inner))
}
}
}
@ -227,7 +231,9 @@ impl Types {
has_selected: false,
glob_to_selection: vec![],
set: GlobSetBuilder::new().build().unwrap(),
matches: Arc::new(Pool::new(|| vec![])),
matches: Arc::new(Pool::with_available_parallelism_capacity(
|| vec![],
)),
}
}
@ -254,7 +260,11 @@ impl Types {
/// The path is considered ignored if it matches a negated file type.
/// If at least one file type is selected and `path` doesn't match, then
/// the path is also considered ignored.
pub fn matched<'a, P: AsRef<Path>>(&'a self, path: P, is_dir: bool) -> Match<Glob<'a>> {
pub fn matched<'a, P: AsRef<Path>>(
&'a self,
path: P,
is_dir: bool,
) -> Match<Glob<'a>> {
// File types don't apply to directories, and we can't do anything
// if our glob set is empty.
if is_dir || self.set.is_empty() {
@ -306,10 +316,7 @@ impl TypesBuilder {
/// of default type definitions can be added with `add_defaults`, and
/// additional type definitions can be added with `select` and `negate`.
pub fn new() -> TypesBuilder {
TypesBuilder {
types: HashMap::new(),
selections: vec![],
}
TypesBuilder { types: HashMap::new(), selections: vec![] }
}
/// Build the current set of file type definitions *and* selections into
@ -343,17 +350,18 @@ impl TypesBuilder {
}
selections.push(selection.clone().map(move |_| def));
}
let set = build_set.build().map_err(|err| Error::Glob {
glob: None,
err: err.to_string(),
})?;
let set = build_set
.build()
.map_err(|err| Error::Glob { glob: None, err: err.to_string() })?;
Ok(Types {
defs,
selections,
has_selected,
glob_to_selection,
set,
matches: Arc::new(Pool::new(|| vec![])),
matches: Arc::new(Pool::with_available_parallelism_capacity(
|| vec![],
)),
})
}
@ -377,12 +385,10 @@ impl TypesBuilder {
pub fn select(&mut self, name: &str) -> &mut TypesBuilder {
if name == "all" {
for name in self.types.keys() {
self.selections
.push(Selection::Select(name.to_string(), ()));
self.selections.push(Selection::Select(name.to_string(), ()));
}
} else {
self.selections
.push(Selection::Select(name.to_string(), ()));
self.selections.push(Selection::Select(name.to_string(), ()));
}
self
}
@ -393,12 +399,10 @@ impl TypesBuilder {
pub fn negate(&mut self, name: &str) -> &mut TypesBuilder {
if name == "all" {
for name in self.types.keys() {
self.selections
.push(Selection::Negate(name.to_string(), ()));
self.selections.push(Selection::Negate(name.to_string(), ()));
}
} else {
self.selections
.push(Selection::Negate(name.to_string(), ()));
self.selections.push(Selection::Negate(name.to_string(), ()));
}
self
}
@ -453,7 +457,10 @@ impl TypesBuilder {
3 => {
let name = parts[0];
let types_string = parts[2];
if name.is_empty() || parts[1] != "include" || types_string.is_empty() {
if name.is_empty()
|| parts[1] != "include"
|| types_string.is_empty()
{
return Err(Error::InvalidDefinition);
}
let types = types_string.split(',');
@ -463,7 +470,8 @@ impl TypesBuilder {
return Err(Error::InvalidDefinition);
}
for type_name in types {
let globs = self.types.get(type_name).unwrap().globs.clone();
let globs =
self.types.get(type_name).unwrap().globs.clone();
for glob in globs {
self.add(name, &glob)?;
}
@ -549,30 +557,9 @@ mod tests {
matched!(not, matchnot1, types(), vec!["rust"], vec![], "index.html");
matched!(not, matchnot2, types(), vec![], vec!["rust"], "main.rs");
matched!(
not,
matchnot3,
types(),
vec!["foo"],
vec!["rust"],
"main.rs"
);
matched!(
not,
matchnot4,
types(),
vec!["rust"],
vec!["foo"],
"main.rs"
);
matched!(
not,
matchnot5,
types(),
vec!["rust"],
vec!["foo"],
"main.foo"
);
matched!(not, matchnot3, types(), vec!["foo"], vec!["rust"], "main.rs");
matched!(not, matchnot4, types(), vec!["rust"], vec!["foo"], "main.rs");
matched!(not, matchnot5, types(), vec!["rust"], vec!["foo"], "main.foo");
matched!(not, matchnot6, types(), vec!["combo"], vec![], "leftpad.js");
matched!(not, matchnot7, types(), vec!["py"], vec![], "index.html");
matched!(not, matchnot8, types(), vec!["python"], vec![], "doc.md");

View file

@ -17,7 +17,9 @@ use {
use crate::{
Error, PartialErrorBuilder,
dir::{Ignore, IgnoreBuilder},
// CHANGED: Also import `Gitignore` for `WalkBuilder::add_gitignore`.
gitignore::{Gitignore, GitignoreBuilder},
incremental::{IncrementalIgnore, IncrementalIgnoreOptions},
overrides::Override,
types::Types,
};
@ -104,24 +106,15 @@ impl DirEntry {
}
fn new_stdin() -> DirEntry {
DirEntry {
dent: DirEntryInner::Stdin,
err: None,
}
DirEntry { dent: DirEntryInner::Stdin, err: None }
}
fn new_walkdir(dent: walkdir::DirEntry, err: Option<Error>) -> DirEntry {
DirEntry {
dent: DirEntryInner::Walkdir(dent),
err,
}
DirEntry { dent: DirEntryInner::Walkdir(dent), err }
}
fn new_raw(dent: DirEntryRaw, err: Option<Error>) -> DirEntry {
DirEntry {
dent: DirEntryInner::Raw(dent),
err,
}
DirEntry { dent: DirEntryInner::Raw(dent), err }
}
}
@ -187,9 +180,11 @@ impl DirEntryInner {
));
Err(err.with_path("<stdin>"))
}
Walkdir(ref x) => x
.metadata()
.map_err(|err| Error::Io(io::Error::from(err)).with_path(x.path())),
Walkdir(ref x) => x.metadata().map_err(|err| {
Error::Io(io::Error::from(err))
.with_depth(x.depth())
.with_path(x.path())
}),
Raw(ref x) => x.metadata(),
}
}
@ -308,7 +303,9 @@ impl DirEntryRaw {
} else {
fs::symlink_metadata(&self.path)
}
.map_err(|err| Error::Io(io::Error::from(err)).with_path(&self.path))
.map_err(|err| {
Error::Io(err).with_depth(self.depth).with_path(&self.path)
})
}
fn file_type(&self) -> FileType {
@ -316,9 +313,7 @@ impl DirEntryRaw {
}
fn file_name(&self) -> &OsStr {
self.path
.file_name()
.unwrap_or_else(|| self.path.as_os_str())
self.path.file_name().unwrap_or_else(|| self.path.as_os_str())
}
fn depth(&self) -> usize {
@ -330,13 +325,13 @@ impl DirEntryRaw {
self.ino
}
fn from_entry(depth: usize, ent: &fs::DirEntry) -> Result<DirEntryRaw, Error> {
fn from_entry(
depth: usize,
ent: &fs::DirEntry,
) -> Result<DirEntryRaw, Error> {
let ty = ent.file_type().map_err(|err| {
let err = Error::Io(io::Error::from(err)).with_path(ent.path());
Error::WithDepth {
depth,
err: Box::new(err),
}
let err = Error::Io(err).with_depth(depth).with_path(ent.path());
Error::WithDepth { depth, err: Box::new(err) }
})?;
DirEntryRaw::from_entry_os(depth, ent, ty)
}
@ -348,11 +343,8 @@ impl DirEntryRaw {
ty: fs::FileType,
) -> Result<DirEntryRaw, Error> {
let md = ent.metadata().map_err(|err| {
let err = Error::Io(io::Error::from(err)).with_path(ent.path());
Error::WithDepth {
depth,
err: Box::new(err),
}
let err = Error::Io(err).with_depth(depth).with_path(ent.path());
Error::WithDepth { depth, err: Box::new(err) }
})?;
Ok(DirEntryRaw {
path: ent.path(),
@ -395,8 +387,13 @@ impl DirEntryRaw {
}
#[cfg(windows)]
fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result<DirEntryRaw, Error> {
let md = fs::metadata(&pb).map_err(|err| Error::Io(err).with_path(&pb))?;
fn from_path(
depth: usize,
pb: PathBuf,
link: bool,
) -> Result<DirEntryRaw, Error> {
let md = fs::metadata(&pb)
.map_err(|err| Error::Io(err).with_depth(depth).with_path(&pb))?;
Ok(DirEntryRaw {
path: pb,
ty: md.file_type(),
@ -407,10 +404,15 @@ impl DirEntryRaw {
}
#[cfg(unix)]
fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result<DirEntryRaw, Error> {
fn from_path(
depth: usize,
pb: PathBuf,
link: bool,
) -> Result<DirEntryRaw, Error> {
use std::os::unix::fs::MetadataExt;
let md = fs::metadata(&pb).map_err(|err| Error::Io(err).with_path(&pb))?;
let md = fs::metadata(&pb)
.map_err(|err| Error::Io(err).with_depth(depth).with_path(&pb))?;
Ok(DirEntryRaw {
path: pb,
ty: md.file_type(),
@ -423,7 +425,11 @@ impl DirEntryRaw {
// Placeholder implementation to allow compiling on non-standard platforms
// (e.g. wasm32).
#[cfg(not(any(windows, unix)))]
fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result<DirEntryRaw, Error> {
fn from_path(
depth: usize,
pb: PathBuf,
link: bool,
) -> Result<DirEntryRaw, Error> {
Err(Error::Io(io::Error::new(
io::ErrorKind::Other,
"unsupported platform",
@ -502,7 +508,8 @@ pub struct WalkBuilder {
///
/// When `None`, the CWD is fetched from `std::env::current_dir()`. If
/// that fails, then global gitignores are ignored (an error is logged).
global_gitignores_relative_to: OnceLock<Result<PathBuf, Arc<std::io::Error>>>,
global_gitignores_relative_to:
OnceLock<Result<PathBuf, Arc<std::io::Error>>>,
}
#[derive(Clone)]
@ -544,8 +551,16 @@ impl WalkBuilder {
/// is better to call `add` on this builder than to create multiple
/// `Walk` values.
pub fn new<P: AsRef<Path>>(path: P) -> WalkBuilder {
WalkBuilder::from_iter([path])
}
/// Create an empty builder to which paths can be added.
///
/// Note that if you call `build` on this instance before calling `add`
/// on it, it will return exactly zero items during iteration.
pub fn empty() -> WalkBuilder {
WalkBuilder {
paths: vec![path.as_ref().to_path_buf()],
paths: vec![],
ig_builder: IgnoreBuilder::new(),
max_depth: None,
min_depth: None,
@ -560,6 +575,21 @@ impl WalkBuilder {
}
}
/// Create a new builder for a recursive directory iterator from the
/// sequence of paths.
///
/// Note that if the iterator is empty, this is the same as
/// `WalkBuilder::empty`.
pub fn from_iter<P: AsRef<Path>, I: IntoIterator<Item = P>>(
paths: I,
) -> WalkBuilder {
let mut builder = WalkBuilder::empty();
for path in paths.into_iter() {
builder.add(path);
}
builder
}
/// Build a new `Walk` iterator.
pub fn build(&self) -> Walk {
let follow_links = self.follow_links;
@ -585,10 +615,14 @@ impl WalkBuilder {
if let Some(ref sorter) = sorter {
match sorter.clone() {
Sorter::ByName(cmp) => {
wd = wd.sort_by(move |a, b| cmp(a.file_name(), b.file_name()));
wd = wd.sort_by(move |a, b| {
cmp(a.file_name(), b.file_name())
});
}
Sorter::ByPath(cmp) => {
wd = wd.sort_by(move |a, b| cmp(a.path(), b.path()));
wd = wd.sort_by(move |a, b| {
cmp(a.path(), b.path())
});
}
}
}
@ -597,31 +631,76 @@ impl WalkBuilder {
})
.collect::<Vec<_>>()
.into_iter();
let ig_root = self
.get_or_set_current_dir()
.map(|cwd| self.ig_builder.build_with_cwd(Some(cwd.to_path_buf())))
.unwrap_or_else(|| self.ig_builder.build());
let ig_root = self.build_ignore();
Walk {
its,
it: None,
ig_root: ig_root.clone(),
ig: ig_root.clone(),
max_depth: self.max_depth,
max_filesize: self.max_filesize,
skip: self.skip.clone(),
filter: self.filter.clone(),
}
}
/// Build matchers for checking paths against ignore files without
/// recursively walking the configured roots.
///
/// The returned matchers use the path-based filtering configuration
/// on this builder, including glob overrides, file type selections,
/// parent ignore files, `.ignore`, `.gitignore`, global Git
/// ignore files, explicitly added ignore files and custom ignore
/// file names. For example, ripgrep configures `.rgignore` via
/// [`WalkBuilder::add_custom_ignore_filename`]. Minimum and maximum depth
/// limits, maximum file size and hidden-file filtering are also applied.
/// Other options that only control traversal or require a directory entry,
/// such as custom entry predicates, are not applied.
///
/// One matcher is returned for each configured path, in the same order as
/// the paths were added to this builder. Each matcher accepts paths
/// relative to its own [`IncrementalIgnore::root`]. The matcher for the
/// special `-` path representing standard input always returns a non-match
/// for all inputs.
///
/// Ignore matchers are loaded lazily and cached by directory.
/// Thus, the first query may read ignore files from the root and
/// its parents, while later queries reuse the compiled matchers.
/// Errors encountered while loading ignore files are returned by
/// [`IncrementalIgnore::matched_with_errors`]. Once an ignore file has
/// been loaded, changes to it are not observed. Build new matchers to
/// reload changed ignore files.
///
/// Matchers built together share the builder's base ignore configuration
/// and compiled parent matchers.
pub fn build_matchers(&self) -> Vec<IncrementalIgnore> {
let ignore = self.build_ignore();
let options = IncrementalIgnoreOptions {
min_depth: self.min_depth,
max_depth: self.max_depth,
max_filesize: self.max_filesize,
hidden: self.ig_builder.is_hidden(),
follow_links: self.follow_links,
};
self.paths
.iter()
.map(move |path| {
IncrementalIgnore::new(
path.clone(),
ignore.clone(),
options.clone(),
)
})
.collect()
}
/// Build a new `WalkParallel` iterator.
///
/// Note that this *doesn't* return something that implements `Iterator`.
/// Instead, the returned value must be run with a closure. e.g.,
/// `builder.build_parallel().run(|| |path| { println!("{path:?}"); WalkState::Continue })`.
pub fn build_parallel(&self) -> WalkParallel {
let ig_root = self
.get_or_set_current_dir()
.map(|cwd| self.ig_builder.build_with_cwd(Some(cwd.to_path_buf())))
.unwrap_or_else(|| self.ig_builder.build());
let ig_root = self.build_ignore();
WalkParallel {
paths: self.paths.clone().into_iter(),
ig_root,
@ -651,7 +730,10 @@ impl WalkBuilder {
/// The default, `None`, imposes no depth restriction.
pub fn max_depth(&mut self, depth: Option<usize>) -> &mut WalkBuilder {
self.max_depth = depth;
if self.min_depth.is_some() && self.max_depth.is_some() && self.max_depth < self.min_depth {
if self.min_depth.is_some()
&& self.max_depth.is_some()
&& self.max_depth < self.min_depth
{
self.max_depth = self.min_depth;
}
self
@ -662,7 +744,10 @@ impl WalkBuilder {
/// The default, `None`, imposes no minimum depth restriction.
pub fn min_depth(&mut self, depth: Option<usize>) -> &mut WalkBuilder {
self.min_depth = depth;
if self.max_depth.is_some() && self.min_depth.is_some() && self.min_depth > self.max_depth {
if self.max_depth.is_some()
&& self.min_depth.is_some()
&& self.min_depth > self.max_depth
{
self.min_depth = self.max_depth;
}
self
@ -705,7 +790,12 @@ impl WalkBuilder {
/// An error will also occur if this walker could not get the current
/// working directory (and `WalkBuilder::current_dir` isn't set).
pub fn add_ignore<P: AsRef<Path>>(&mut self, path: P) -> Option<Error> {
// CHANGED: Dropped this code
// CHANGED: Root the ignore file at `""` instead of the current working
// directory. Explicit ignores are scoped to the directory of the
// ignore file (see `matched_ignore`), and a root of `""` makes the
// rules apply to every walked path regardless of the walk root. This
// also avoids depending on the current working directory entirely.
//
// let path = path.as_ref();
// let Some(cwd) = self.get_or_set_current_dir() else {
// let err = std::io::Error::other(format!(
@ -729,7 +819,11 @@ impl WalkBuilder {
errs.into_error_option()
}
/// CHANGED: Add a Gitignore to the builder.
/// CHANGED: Add a prebuilt Gitignore to the builder.
///
/// Like the ignore file added via `add_ignore`, these rules are matched
/// against the full path of each walked entry, scoped to the `Gitignore`'s
/// root path.
pub fn add_gitignore(&mut self, gi: Gitignore) {
self.ig_builder.add_ignore(gi);
}
@ -982,7 +1076,10 @@ impl WalkBuilder {
///
/// Global gitignore files come from things like a user's git configuration
/// or from gitignore files added via [`WalkBuilder::add_ignore`].
pub fn current_dir(&mut self, cwd: impl Into<PathBuf>) -> &mut WalkBuilder {
pub fn current_dir(
&mut self,
cwd: impl Into<PathBuf>,
) -> &mut WalkBuilder {
let cwd = cwd.into();
self.ig_builder.current_dir(cwd.clone());
if let Err(cwd) = self.global_gitignores_relative_to.set(Ok(cwd)) {
@ -1002,7 +1099,10 @@ impl WalkBuilder {
let result = std::env::current_dir().map_err(Arc::new);
match result {
Ok(ref path) => {
log::trace!("automatically discovered CWD: {}", path.display());
log::trace!(
"automatically discovered CWD: {}",
path.display()
);
}
Err(ref err) => {
log::debug!(
@ -1016,6 +1116,13 @@ impl WalkBuilder {
});
result.as_ref().ok().map(|path| &**path)
}
/// Build the root ignore matcher shared by all consumers of this builder.
fn build_ignore(&self) -> Ignore {
self.get_or_set_current_dir()
.map(|cwd| self.ig_builder.build_with_cwd(Some(cwd.to_path_buf())))
.unwrap_or_else(|| self.ig_builder.build())
}
}
/// Walk is a recursive directory iterator over file paths in one or more
@ -1029,6 +1136,7 @@ pub struct Walk {
it: Option<WalkEventIter>,
ig_root: Ignore,
ig: Ignore,
max_depth: Option<usize>,
max_filesize: Option<u64>,
skip: Option<Arc<Handle>>,
filter: Option<Filter>,
@ -1044,6 +1152,17 @@ impl Walk {
WalkBuilder::new(path).build()
}
/// Create a new recursive directory iterator from the sequence of paths
/// given.
///
/// Note that if the provided iterator is empty, then `Walk` is guaranteed
/// to yield zero entries.
pub fn from_iter<P: AsRef<Path>, I: IntoIterator<Item = P>>(
paths: I,
) -> Walk {
WalkBuilder::from_iter(paths).build()
}
fn skip_entry(&self, ent: &DirEntry) -> Result<bool, Error> {
if ent.depth() == 0 {
return Ok(false);
@ -1128,12 +1247,17 @@ impl Iterator for Walk {
self.it.as_mut().unwrap().it.skip_current_dir();
// Still need to push this on the stack because
// we'll get a WalkEvent::Exit event for this dir.
// We don't care if it errors though.
let (igtmp, _) = self.ig.add_child(ent.path());
// Its ignore files cannot apply to any visited entry.
let (igtmp, _) =
self.ig.add_child_with_entries(ent.path(), &[]);
self.ig = igtmp;
continue;
}
let (igtmp, err) = self.ig.add_child(ent.path());
let (igtmp, err) = if self.max_depth == Some(ent.depth()) {
self.ig.add_child_with_entries(ent.path(), &[])
} else {
self.ig.add_child(ent.path())
};
self.ig = igtmp;
ent.err = err;
return Some(Ok(ent));
@ -1175,11 +1299,7 @@ enum WalkEvent {
impl From<WalkDir> for WalkEventIter {
fn from(it: WalkDir) -> WalkEventIter {
WalkEventIter {
depth: 0,
it: it.into_iter(),
next: None,
}
WalkEventIter { depth: 0, it: it.into_iter(), next: None }
}
}
@ -1252,7 +1372,9 @@ pub trait ParallelVisitorBuilder<'s> {
fn build(&mut self) -> Box<dyn ParallelVisitor + 's>;
}
impl<'a, 's, P: ParallelVisitorBuilder<'s>> ParallelVisitorBuilder<'s> for &'a mut P {
impl<'a, 's, P: ParallelVisitorBuilder<'s>> ParallelVisitorBuilder<'s>
for &'a mut P
{
fn build(&mut self) -> Box<dyn ParallelVisitor + 's> {
(**self).build()
}
@ -1273,14 +1395,17 @@ struct FnBuilder<F> {
builder: F,
}
impl<'s, F: FnMut() -> FnVisitor<'s>> ParallelVisitorBuilder<'s> for FnBuilder<F> {
impl<'s, F: FnMut() -> FnVisitor<'s>> ParallelVisitorBuilder<'s>
for FnBuilder<F>
{
fn build(&mut self) -> Box<dyn ParallelVisitor + 's> {
let visitor = (self.builder)();
Box::new(FnVisitorImp { visitor })
}
}
type FnVisitor<'s> = Box<dyn FnMut(Result<DirEntry, Error>) -> WalkState + Send + 's>;
type FnVisitor<'s> =
Box<dyn FnMut(Result<DirEntry, Error>) -> WalkState + Send + 's>;
struct FnVisitorImp<'s> {
visitor: FnVisitor<'s>,
@ -1370,7 +1495,9 @@ impl WalkParallel {
}
};
match DirEntryRaw::from_path(0, path, false) {
Ok(dent) => (DirEntry::new_raw(dent, None), root_device),
Ok(dent) => {
(DirEntry::new_raw(dent, None), root_device)
}
Err(err) => {
if visitor.visit(Err(err)).is_quit() {
return;
@ -1394,21 +1521,28 @@ impl WalkParallel {
let quit_now = Arc::new(AtomicBool::new(false));
let active_workers = Arc::new(AtomicUsize::new(threads));
let stacks = Stack::new_for_each_thread(threads, stack);
// Collect all of the workers first. In the case that
// `builder.build()` panics, we want that to happen and
// propagate before we actually start to run any of the
// workers.
let workers: Vec<_> = stacks
.into_iter()
.map(|stack| Worker {
visitor: builder.build(),
stack,
quit_now: quit_now.clone(),
active_workers: active_workers.clone(),
max_depth: self.max_depth,
min_depth: self.min_depth,
max_filesize: self.max_filesize,
follow_links: self.follow_links,
skip: self.skip.clone(),
filter: self.filter.clone(),
})
.collect();
std::thread::scope(|s| {
let handles: Vec<_> = stacks
let handles: Vec<_> = workers
.into_iter()
.map(|stack| Worker {
visitor: builder.build(),
stack,
quit_now: quit_now.clone(),
active_workers: active_workers.clone(),
max_depth: self.max_depth,
min_depth: self.min_depth,
max_filesize: self.max_filesize,
follow_links: self.follow_links,
skip: self.skip.clone(),
filter: self.filter.clone(),
})
.map(|worker| s.spawn(|| worker.run()))
.collect();
for handle in handles {
@ -1419,9 +1553,7 @@ impl WalkParallel {
fn threads(&self) -> usize {
if self.threads == 0 {
std::thread::available_parallelism()
.map_or(1, |n| n.get())
.min(12)
std::thread::available_parallelism().map_or(1, |n| n.get()).min(12)
} else {
self.threads
}
@ -1452,6 +1584,12 @@ struct Work {
root_device: Option<u64>,
}
#[derive(Default)]
struct ReadDirResult {
entries: Vec<fs::DirEntry>,
errors: Vec<Error>,
}
impl Work {
/// Returns true if and only if this work item is a directory.
fn is_dir(&self) -> bool {
@ -1478,6 +1616,13 @@ impl Work {
err
}
/// Adds ignore rules for this directory without reading its contents.
fn add_ignore(&mut self) {
let (ig, err) = self.ignore.add_child(self.dent.path());
self.ignore = ig;
self.dent.err = err;
}
/// Reads the directory contents of this work item and adds ignore
/// rules for this directory.
///
@ -1485,7 +1630,7 @@ impl Work {
/// an error is returned. If there was a problem reading the ignore
/// rules for this directory, then the error is attached to this
/// work item's directory entry.
fn read_dir(&mut self) -> Result<fs::ReadDir, Error> {
fn read_dir(&mut self) -> Result<ReadDirResult, Error> {
let readdir = match fs::read_dir(self.dent.path()) {
Ok(readdir) => readdir,
Err(err) => {
@ -1495,10 +1640,24 @@ impl Work {
return Err(err);
}
};
let (ig, err) = self.ignore.add_child(self.dent.path());
// Actually descend into the directory and read its contents
let mut result = ReadDirResult::default();
for entry in readdir {
match entry {
Ok(entry) => result.entries.push(entry),
Err(err) => result.errors.push(
Error::from(err)
.with_path(self.dent.path())
.with_depth(self.dent.depth() + 1),
),
}
}
let (ig, err) = self
.ignore
.add_child_with_entries(self.dent.path(), &result.entries);
self.ignore = ig;
self.dent.err = err;
Ok(readdir)
Ok(result)
}
}
@ -1522,11 +1681,11 @@ impl Stack {
// breadth-first. We do depth-first because a breadth first traversal
// on wide directories with a lot of gitignores is disastrous (for
// example, searching a directory tree containing all of crates.io).
let deques: Vec<Deque<Message>> = std::iter::repeat_with(Deque::new_lifo)
.take(threads)
.collect();
let stealers =
Arc::<[Stealer<Message>]>::from(deques.iter().map(Deque::stealer).collect::<Vec<_>>());
let deques: Vec<Deque<Message>> =
std::iter::repeat_with(Deque::new_lifo).take(threads).collect();
let stealers = Arc::<[Stealer<Message>]>::from(
deques.iter().map(Deque::stealer).collect::<Vec<_>>(),
);
let stacks: Vec<Stack> = deques
.into_iter()
.enumerate()
@ -1668,8 +1827,13 @@ impl<'s> Worker<'s> {
// have sufficient read permissions to list the directory.
// In that case we still want to provide the closure with a valid
// entry before passing the error value.
let readdir = work.read_dir();
let depth = work.dent.depth();
let readdir = if descend && self.max_depth.is_none_or(|m| depth < m) {
Some(work.read_dir())
} else {
work.add_ignore();
None
};
if should_visit {
let state = self.visitor.visit(Ok(work.dent));
if !state.is_continue() {
@ -1680,6 +1844,10 @@ impl<'s> Worker<'s> {
return WalkState::Skip;
}
let readdir = match readdir {
Some(readdir) => readdir,
None => return WalkState::Skip,
};
let readdir = match readdir {
Ok(readdir) => readdir,
Err(err) => {
@ -1687,11 +1855,19 @@ impl<'s> Worker<'s> {
}
};
if self.max_depth.map_or(false, |max| depth >= max) {
return WalkState::Skip;
for result in readdir.entries {
let state = self.generate_work(
&work.ignore,
depth + 1,
work.root_device,
result,
);
if state.is_quit() {
return state;
}
}
for result in readdir {
let state = self.generate_work(&work.ignore, depth + 1, work.root_device, result);
for err in readdir.errors {
let state = self.visitor.visit(Err(err));
if state.is_quit() {
return state;
}
@ -1717,14 +1893,8 @@ impl<'s> Worker<'s> {
ig: &Ignore,
depth: usize,
root_device: Option<u64>,
result: Result<fs::DirEntry, io::Error>,
fs_dent: fs::DirEntry,
) -> WalkState {
let fs_dent = match result {
Ok(fs_dent) => fs_dent,
Err(err) => {
return self.visitor.visit(Err(Error::from(err).with_depth(depth)));
}
};
let mut dent = match DirEntryRaw::from_entry(depth, &fs_dent) {
Ok(dent) => DirEntry::new_raw(dent, None),
Err(err) => {
@ -1760,26 +1930,24 @@ impl<'s> Worker<'s> {
return WalkState::Continue;
}
}
let should_skip_filesize = if self.max_filesize.is_some() && !dent.is_dir() {
skip_filesize(
self.max_filesize.unwrap(),
dent.path(),
&dent.metadata().ok(),
)
} else {
false
};
let should_skip_filtered = if let Some(Filter(predicate)) = &self.filter {
!predicate(&dent)
} else {
false
};
let should_skip_filesize =
if self.max_filesize.is_some() && !dent.is_dir() {
skip_filesize(
self.max_filesize.unwrap(),
dent.path(),
&dent.metadata().ok(),
)
} else {
false
};
let should_skip_filtered =
if let Some(Filter(predicate)) = &self.filter {
!predicate(&dent)
} else {
false
};
if !should_skip_filesize && !should_skip_filtered {
self.send(Work {
dent,
ignore: ig.clone(),
root_device,
});
self.send(Work { dent, ignore: ig.clone(), root_device });
}
WalkState::Continue
}
@ -1820,6 +1988,9 @@ impl<'s> Worker<'s> {
}
// Wait for next `Work` or `Quit` message.
loop {
if self.is_quit_now() {
return None;
}
if let Some(v) = self.recv() {
self.activate_worker();
value = Some(v);
@ -1873,24 +2044,25 @@ impl<'s> Worker<'s> {
}
}
impl<'s> Drop for Worker<'s> {
fn drop(&mut self) {
if std::thread::panicking() {
self.quit_now();
}
}
}
fn check_symlink_loop(
ig_parent: &Ignore,
child_path: &Path,
child_depth: usize,
) -> Result<(), Error> {
let hchild = Handle::from_path(child_path).map_err(|err| {
Error::from(err)
.with_path(child_path)
.with_depth(child_depth)
Error::from(err).with_path(child_path).with_depth(child_depth)
})?;
for ig in ig_parent
.parents()
.take_while(|ig| !ig.is_absolute_parent())
{
for ig in ig_parent.parents().take_while(|ig| !ig.is_absolute_parent()) {
let h = Handle::from_path(ig.path()).map_err(|err| {
Error::from(err)
.with_path(child_path)
.with_depth(child_depth)
Error::from(err).with_path(child_path).with_depth(child_depth)
})?;
if hchild == h {
return Err(Error::Loop {
@ -1905,7 +2077,11 @@ fn check_symlink_loop(
// Before calling this function, make sure that you ensure that is really
// necessary as the arguments imply a file stat.
fn skip_filesize(max_filesize: u64, path: &Path, ent: &Option<Metadata>) -> bool {
fn skip_filesize(
max_filesize: u64,
path: &Path,
ent: &Option<Metadata>,
) -> bool {
let filesize = match *ent {
Some(ref md) => Some(md.len()),
None => None,
@ -1977,9 +2153,9 @@ fn path_equals(dent: &DirEntry, handle: &Handle) -> Result<bool, Error> {
if dent.is_stdin() || never_equal(dent, handle) {
return Ok(false);
}
Handle::from_path(dent.path())
.map(|h| &h == handle)
.map_err(|err| Error::Io(err).with_path(dent.path()))
Handle::from_path(dent.path()).map(|h| &h == handle).map_err(|err| {
Error::Io(err).with_depth(dent.depth()).with_path(dent.path())
})
}
/// Returns true if the given walkdir entry corresponds to a directory.
@ -1997,16 +2173,14 @@ fn walkdir_is_dir(dent: &walkdir::DirEntry) -> bool {
if !dent.file_type().is_symlink() || dent.depth() > 0 {
return false;
}
dent.path()
.metadata()
.ok()
.map_or(false, |md| md.file_type().is_dir())
dent.path().metadata().ok().map_or(false, |md| md.file_type().is_dir())
}
/// Returns true if and only if the given path is on the same device as the
/// given root device.
fn is_same_file_system(root_device: u64, path: &Path) -> Result<bool, Error> {
let dent_device = device_num(path).map_err(|err| Error::Io(err).with_path(path))?;
let dent_device =
device_num(path).map_err(|err| Error::Io(err).with_path(path))?;
Ok(root_device == dent_device)
}
@ -2065,11 +2239,7 @@ mod tests {
}
fn normal_path(unix: &str) -> String {
if cfg!(windows) {
unix.replace("\\", "/")
} else {
unix.to_string()
}
if cfg!(windows) { unix.replace("\\", "/") } else { unix.to_string() }
}
fn walk_collect(prefix: &Path, builder: &WalkBuilder) -> Vec<String> {
@ -2089,7 +2259,10 @@ mod tests {
paths
}
fn walk_collect_parallel(prefix: &Path, builder: &WalkBuilder) -> Vec<String> {
fn walk_collect_parallel(
prefix: &Path,
builder: &WalkBuilder,
) -> Vec<String> {
let mut paths = vec![];
for dent in walk_collect_entries_parallel(builder) {
let path = dent.path().strip_prefix(prefix).unwrap();
@ -2278,6 +2451,27 @@ mod tests {
);
}
#[test]
fn max_depth_does_not_load_unreachable_ignore_files() {
let td = tmpdir();
let leaf = td.path().join("leaf");
mkdirp(&leaf);
wfile(leaf.join(".ignore"), "{invalid\n");
let mut builder = WalkBuilder::new(td.path());
builder.max_depth(Some(1));
let entry = builder
.build()
.find_map(|result| {
let entry = result.unwrap();
(entry.path() == leaf).then_some(entry)
})
.unwrap();
assert!(entry.error().is_none());
assert_paths(td.path(), &builder, &["leaf"]);
}
#[test]
fn min_depth() {
let td = tmpdir();
@ -2388,7 +2582,9 @@ mod tests {
assert_eq!(1, dents.len());
assert!(!dents[0].path_is_symlink());
let dents = walk_collect_entries_parallel(&WalkBuilder::new(td.path().join("foo")));
let dents = walk_collect_entries_parallel(&WalkBuilder::new(
td.path().join("foo"),
));
assert_eq!(1, dents.len());
assert!(!dents[0].path_is_symlink());
}
@ -2474,8 +2670,88 @@ mod tests {
assert_paths(
td.path(),
&WalkBuilder::new(td.path()).filter_entry(|entry| entry.file_name() != OsStr::new("a")),
&WalkBuilder::new(td.path())
.filter_entry(|entry| entry.file_name() != OsStr::new("a")),
&["x", "x/y", "x/y/foo"],
);
}
#[test]
fn empty() {
let td = tmpdir();
assert_paths(td.path(), &WalkBuilder::empty(), &[]);
let empty_paths: Vec<&OsStr> = Vec::new();
assert_paths(td.path(), &WalkBuilder::from_iter(empty_paths), &[]);
}
#[test]
fn from_iter() {
let td = tmpdir();
mkdirp(td.path().join("a/b/c"));
mkdirp(td.path().join("d/e/f"));
mkdirp(td.path().join("x/y"));
wfile(td.path().join("a/b/foo"), "");
wfile(td.path().join("d/e/f/foo"), "");
wfile(td.path().join("x/y/foo"), "");
let paths = vec![
td.path().join("a"),
td.path().join("d"),
td.path().join("x"),
];
assert_paths(
td.path(),
&WalkBuilder::from_iter(paths),
&[
"x",
"x/y",
"x/y/foo",
"d",
"d/e",
"d/e/f",
"d/e/f/foo",
"a",
"a/b",
"a/b/foo",
"a/b/c",
],
);
}
// This should always panic and never hang.
//
// Ref: https://github.com/BurntSushi/ripgrep/issues/3009
#[test]
#[should_panic]
fn panic_in_parallel() {
let td = tmpdir();
wfile(td.path().join("foo.txt"), "");
WalkBuilder::new(td.path())
.threads(40)
.build_parallel()
.run(|| Box::new(|_| panic!("oops!")));
}
// This should always panic and never hang. The first call to the visitor
// builder is used while processing the root paths. Previously, a panic on
// the third call occurred after the first worker had already been spawned,
// leaving it waiting indefinitely for workers that were never created.
#[test]
#[should_panic(expected = "builder panic")]
fn panic_in_parallel_builder() {
let td = tmpdir();
wfile(td.path().join("foo.txt"), "");
let mut builds = 0;
WalkBuilder::new(td.path()).threads(2).build_parallel().run(|| {
builds += 1;
if builds == 3 {
panic!("builder panic");
}
Box::new(|_| WalkState::Continue)
});
}
}

View file

@ -2,7 +2,8 @@ use std::path::Path;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
const IGNORE_FILE: &'static str = "tests/gitignore_matched_path_or_any_parents_tests.gitignore";
const IGNORE_FILE: &'static str =
"tests/gitignore_matched_path_or_any_parents_tests.gitignore";
fn get_gitignore() -> Gitignore {
let mut builder = GitignoreBuilder::new("ROOT");
@ -23,7 +24,9 @@ fn test_path_should_be_under_root() {
#[test]
fn test_files_in_root() {
let gitignore = get_gitignore();
let m = |path: &str| gitignore.matched_path_or_any_parents(Path::new(path), false);
let m = |path: &str| {
gitignore.matched_path_or_any_parents(Path::new(path), false)
};
// 0x
assert!(m("ROOT/file_root_00").is_ignore());
@ -53,7 +56,9 @@ fn test_files_in_root() {
#[test]
fn test_files_in_deep() {
let gitignore = get_gitignore();
let m = |path: &str| gitignore.matched_path_or_any_parents(Path::new(path), false);
let m = |path: &str| {
gitignore.matched_path_or_any_parents(Path::new(path), false)
};
// 0x
assert!(m("ROOT/parent_dir/file_deep_00").is_ignore());
@ -83,8 +88,9 @@ fn test_files_in_deep() {
#[test]
fn test_dirs_in_root() {
let gitignore = get_gitignore();
let m =
|path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir);
let m = |path: &str, is_dir: bool| {
gitignore.matched_path_or_any_parents(Path::new(path), is_dir)
};
// 00
assert!(m("ROOT/dir_root_00", true).is_ignore());
@ -186,20 +192,25 @@ fn test_dirs_in_root() {
#[test]
fn test_dirs_in_deep() {
let gitignore = get_gitignore();
let m =
|path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir);
let m = |path: &str, is_dir: bool| {
gitignore.matched_path_or_any_parents(Path::new(path), is_dir)
};
// 00
assert!(m("ROOT/parent_dir/dir_deep_00", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_00/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_00/child_dir", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_00/child_dir/file", false).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_00/child_dir/file", false).is_ignore()
);
// 01
assert!(m("ROOT/parent_dir/dir_deep_01", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_01/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_01/child_dir", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_01/child_dir/file", false).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_01/child_dir/file", false).is_ignore()
);
// 02
assert!(m("ROOT/parent_dir/dir_deep_02", true).is_none());
@ -241,51 +252,67 @@ fn test_dirs_in_deep() {
assert!(m("ROOT/parent_dir/dir_deep_20", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_20/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_20/child_dir", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_20/child_dir/file", false).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_20/child_dir/file", false).is_ignore()
);
// 21
assert!(m("ROOT/parent_dir/dir_deep_21", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_21/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_21/child_dir", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_21/child_dir/file", false).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_21/child_dir/file", false).is_ignore()
);
// 22
// dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_22", true).is_none());
assert!(m("ROOT/parent_dir/dir_deep_22/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_22/child_dir", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_22/child_dir/file", false).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_22/child_dir/file", false).is_ignore()
);
// 23
// dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_23", true).is_none());
assert!(m("ROOT/parent_dir/dir_deep_23/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_23/child_dir", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_23/child_dir/file", false).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_23/child_dir/file", false).is_ignore()
);
// 30
assert!(m("ROOT/parent_dir/dir_deep_30", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_30/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_30/child_dir", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_30/child_dir/file", false).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_30/child_dir/file", false).is_ignore()
);
// 31
assert!(m("ROOT/parent_dir/dir_deep_31", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_31/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_31/child_dir", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_31/child_dir/file", false).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_31/child_dir/file", false).is_ignore()
);
// 32
// dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_32", true).is_none());
assert!(m("ROOT/parent_dir/dir_deep_32/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_32/child_dir", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_32/child_dir/file", false).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_32/child_dir/file", false).is_ignore()
);
// 33
// dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_33", true).is_none());
assert!(m("ROOT/parent_dir/dir_deep_33/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_33/child_dir", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_33/child_dir/file", false).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_33/child_dir/file", false).is_ignore()
);
}

View file

@ -202,5 +202,6 @@ index.js
browser.js
tailwindcss-oxide.wasi-browser.js
tailwindcss-oxide.wasi.cjs
tailwindcss-oxide.wasi.d.cts
wasi-worker-browser.mjs
wasi-worker.mjs

View file

@ -8,10 +8,10 @@ crate-type = ["cdylib"]
[dependencies]
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
napi = { version = "3.8.5", default-features = false, features = ["napi4"] }
napi-derive = "3.5.4"
napi = { version = "3.11.0", default-features = false, features = ["napi4"] }
napi-derive = "3.6.0"
tailwindcss-oxide = { path = "../oxide" }
rayon = "1.10.0"
rayon = "1.12.0"
[build-dependencies]
napi-build = "2.3.1"
napi-build = "2.3.2"

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-android-arm-eabi",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-android-arm64",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-darwin-arm64",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-darwin-x64",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-freebsd-x64",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-arm-gnueabihf",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-arm64-gnu",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-arm64-musl",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-x64-gnu",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-x64-musl",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,3 +1,3 @@
# `@tailwindcss/oxide-wasm32-wasi`
This is the **wasm32-wasip1-threads** binary for `@tailwindcss/oxide`
This is the **wasm32-wasip1-threads** build of `@tailwindcss/oxide`

View file

@ -1,9 +1,6 @@
{
"name": "@tailwindcss/oxide-wasm32-wasi",
"version": "4.3.2",
"cpu": [
"wasm32"
],
"version": "4.3.3",
"main": "tailwindcss-oxide.wasi.cjs",
"files": [
"tailwindcss-oxide.wasm32-wasi.wasm",
@ -14,7 +11,7 @@
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
},
"publishConfig": {
"provenance": true,
@ -27,11 +24,11 @@
},
"browser": "tailwindcss-oxide.wasi-browser.js",
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.4",
"@emnapi/core": "^1.11.1",
"@emnapi/runtime": "^1.11.1",
"@tybys/wasm-util": "^0.10.2",
"@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.2.2",
"@emnapi/core": "^1.11.3",
"@emnapi/runtime": "^1.11.3",
"@tybys/wasm-util": "^0.10.3",
"@emnapi/wasi-threads": "^1.2.3",
"tslib": "^2.8.1"
},
"bundledDependencies": [

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-win32-arm64-msvc",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-win32-x64-msvc",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide",
"version": "4.3.2",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -33,9 +33,11 @@
},
"license": "MIT",
"devDependencies": {
"@napi-rs/cli": "3.7.0",
"@napi-rs/wasm-runtime": "^1.1.5",
"emnapi": "1.11.1"
"@emnapi/core": "1.11.3",
"@emnapi/runtime": "1.11.3",
"@napi-rs/cli": "3.7.4",
"@napi-rs/wasm-runtime": "^1.2.2",
"emnapi": "1.11.3"
},
"engines": {
"node": ">= 20"

View file

@ -165,6 +165,11 @@ impl Scanner {
self.scanner.get_files()
}
#[napi(getter)]
pub fn scanned_files(&self) -> Vec<String> {
self.scanner.get_scanned_files()
}
#[napi(getter)]
pub fn globs(&mut self) -> Vec<GlobEntry> {
self

View file

@ -20,6 +20,7 @@ ignore = { path = "../ignore" }
regex = "1.11.1"
[dev-dependencies]
insta = "1.48.0"
tempfile = "3.13.0"
pretty_assertions = "1.4.1"
unicode-width = "0.2.0"

View file

@ -25,6 +25,7 @@ impl BracketStack {
b'(' => b')',
b'[' => b']',
b'{' => b'}',
b'<' => b'>',
_ => std::hint::unreachable_unchecked(),
};
}

View file

@ -203,6 +203,81 @@ impl PreProcessor for Haml {
}
}
// Handle Ruby syntax with `%w[]` arrays embedded in Haml attribute hashes. E.g.:
//
// ```haml
// %div{class: %w[bg-blue-500 w-10 h-10]}
// ```
//
// A `%` that follows a value is not a percent literal. E.g.: the `50%w` in
// `hit rate 50%w.`
b'%' if matches!(cursor.next(), b'w' | b'W')
&& !cursor.prev().is_ascii_alphanumeric()
&& !matches!(cursor.prev(), b'_' | b')' | b']' | b'}') =>
{
// Boundary characters
let (open, close) = match cursor.input.get(cursor.pos + 2) {
Some(b'[') => (b'[', b']'),
Some(b'(') => (b'(', b')'),
Some(b'{') => (b'{', b'}'),
Some(b'<') => (b'<', b'>'),
// Any other ASCII punctuation can be used as a custom delimiter
Some(&c) if c.is_ascii_punctuation() => (c, c),
// Everything else is not a valid delimiter
_ => {
cursor.advance();
continue;
}
};
result[cursor.pos] = b' '; // Replace `%`
cursor.advance();
result[cursor.pos] = b' '; // Replace `w`
cursor.advance();
result[cursor.pos] = b' '; // Replace the opening delimiter
cursor.advance();
// Paired delimiters can be nested as long as they are balanced. E.g.:
// `%w[foo[bar]baz]` produces a flat array.
let mut depth = 1_usize;
while cursor.pos < len {
match cursor.curr() {
// Skip escaped characters, unless the backslash is the delimiter
// itself
b'\\' if close != b'\\' => {
// Use backslash to embed spaces in the strings.
if cursor.next() == b' ' {
result[cursor.pos] = b' ';
}
cursor.advance();
}
// Start of a nested delimiter pair
c if c == open && open != close => depth += 1,
// Closing delimiter
c if c == close => {
depth -= 1;
// End of the literal, replace the closing delimiter with a space
if depth == 0 {
result[cursor.pos] = b' ';
break;
}
}
// Everything else is valid content
_ => {}
}
cursor.advance();
}
}
// Replace following characters with spaces if they are not inside of brackets
b'#' | b'=' if bracket_stack.is_empty() => {
result[cursor.pos] = b' ';
@ -424,6 +499,81 @@ mod tests {
);
}
// https://github.com/tailwindlabs/tailwindcss/issues/20386
#[test]
fn test_embedded_ruby_percent_w_delimiters() {
for (input, expected) in [
// %w[…] in an attribute hash
(
"%div{class: %w[flex px-2.5]}",
"%div class: flex px-2.5 ",
),
// %w<…>
(
"%div{class: %w<flex px-2.5>}",
"%div class: flex px-2.5 ",
),
// Nested `<…>` does not end the literal
(
"%div{class: %w<flex <nested> px-2.5>}",
"%div class: flex <nested> px-2.5 ",
),
// Custom delimiters
(
"%div{class: %w|flex px-2.5|}",
"%div class: flex px-2.5 ",
),
(
"%div{class: %W!flex px-2.5!}",
"%div class: flex px-2.5 ",
),
(
"%div{class: %w#text-sm leading-6#}",
"%div class: text-sm leading-6 ",
),
(
"%div{class: %w=italic tracking-wide=}",
"%div class: italic tracking-wide ",
),
// Nested paired delimiters stay balanced inside the literal
(
"%div{class: %w[content-['[hello]'] p-4]}",
"%div class: content-['[hello]'] p-4 ",
),
// Escaped spaces embed a space in a single array element
(
r#"%div{class: %w[foo\ bar baz-1]}"#,
r#"%div class: foo bar baz-1 "#,
),
// A `%` that follows a value is not a percent literal
("%p hit rate 50%w.", "%p hit rate 50%w "),
] {
Haml::test(input, expected);
}
let input = r#"
%div{class: %w[bg-blue-500 w-10 h-10]}
%div{class: %w<flex px-2.5>}
%div{class: %w|underline font-bold|}
- classes = %w<mt-4 grid>
"#;
Haml::test_extract_contains(
input,
vec![
"bg-blue-500",
"w-10",
"h-10",
"flex",
"px-2.5",
"underline",
"font-bold",
"mt-4",
"grid",
],
);
}
// https://github.com/tailwindlabs/tailwindcss/pull/17051#issuecomment-2711181352
#[test]
fn test_haml_full_file_17051() {

View file

@ -158,6 +158,15 @@ impl PreProcessor for Ruby {
continue;
}
// A `%` that follows a value is a modulo operation, not a percent literal. E.g.: the
// `50%w` in `hit rate 50%w.`
if cursor.prev().is_ascii_alphanumeric()
|| matches!(cursor.prev(), b'_' | b')' | b']' | b'}')
{
cursor.advance();
continue;
}
cursor.advance_twice();
// Boundary character
@ -165,8 +174,13 @@ impl PreProcessor for Ruby {
b'[' => b']',
b'(' => b')',
b'{' => b'}',
b'#' => b'#',
b'<' => b'>',
b' ' => b'\n',
// Any other ASCII punctuation can be used as a custom delimiter
c if c.is_ascii_punctuation() => c,
// Everything else is not a valid delimiter
_ => {
cursor.advance();
continue;
@ -183,8 +197,8 @@ impl PreProcessor for Ruby {
while cursor.pos < len {
match cursor.curr() {
// Skip escaped characters
b'\\' => {
// Skip escaped characters, unless the backslash is the delimiter itself
b'\\' if boundary != b'\\' => {
// Use backslash to embed spaces in the strings.
if cursor.next() == b' ' {
result[cursor.pos] = b' ';
@ -198,6 +212,11 @@ impl PreProcessor for Ruby {
bracket_stack.push(cursor.curr());
}
// Start of a nested `<…>`, which Ruby allows inside a `%w<…>` literal
b'<' if boundary == b'>' => {
bracket_stack.push(cursor.curr());
}
// End of a nested bracket
b']' | b')' | b'}' if !bracket_stack.is_empty() => {
if !bracket_stack.pop(cursor.curr()) {
@ -206,6 +225,14 @@ impl PreProcessor for Ruby {
}
}
// End of a nested `<…>`
b'>' if boundary == b'>' && !bracket_stack.is_empty() => {
if !bracket_stack.pop(cursor.curr()) {
// Unbalanced
cursor.advance();
}
}
// End of the pattern, replace the boundary character with a space
_ if cursor.curr() == boundary => {
if boundary != b'\n' {
@ -253,6 +280,24 @@ mod tests {
"%w(flex data-[state=pending]:bg-(--my-color) flex-col)",
"%w flex data-[state=pending]:bg-(--my-color) flex-col ",
),
// %w<…>
("%w<flex px-2.5>", "%w flex px-2.5 "),
(
"%w<flex data-[state=pending]:bg-(--my-color) flex-col>",
"%w flex data-[state=pending]:bg-(--my-color) flex-col ",
),
// Nested `<…>` does not end the literal
("%w<flex <nested> px-2.5>", "%w flex <nested> px-2.5 "),
// %w|…|, %w:…:, %w!…!
("%w|flex px-2.5|", "%w flex px-2.5 "),
("%w:flex px-2.5:", "%w flex px-2.5 "),
("%w!flex px-2.5!", "%w flex px-2.5 "),
(r#"%w\flex px-2.5\"#, r#"%w flex px-2.5 "#),
// A `%` that follows a value is a modulo operation, not a percent literal
(
"hit rate 50%w.\n%w[flex px-2.5]",
"hit rate 50%w.\n%w flex px-2.5 ",
),
// %w …\n
("%w flex px-2.5\n", "%w flex px-2.5\n"),
@ -330,6 +375,20 @@ mod tests {
"%w(flex data-[state=pending]:bg-(--my-color) flex-col)",
vec!["flex", "data-[state=pending]:bg-(--my-color)", "flex-col"],
),
// %w<…>
("%w<flex px-2.5>", vec!["flex", "px-2.5"]),
("%w<px-2.5 flex>", vec!["flex", "px-2.5"]),
("%w<2xl:flex>", vec!["2xl:flex"]),
(
"%w<flex data-[state=pending]:bg-(--my-color) flex-col>",
vec!["flex", "data-[state=pending]:bg-(--my-color)", "flex-col"],
),
// Nested `<…>` does not end the literal
("%w<flex <nested> px-2.5>", vec!["flex", "px-2.5"]),
// %w|…|, %w:…:, %w!…!
("%w|flex px-2.5|", vec!["flex", "px-2.5"]),
("%w:flex px-2.5:", vec!["flex", "px-2.5"]),
("%w!flex px-2.5!", vec!["flex", "px-2.5"]),
(
"# test\n# test\n# {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!]\n%w[flex px-2.5]",

View file

@ -65,17 +65,74 @@ impl PreProcessor for Slim {
// class=%w[bg-blue-500 w-10 h-10]
// ]
// ```
//
// A `%` that follows a value is not a percent literal. E.g.: the `50%w` in
// `hit rate 50%w.`
b'%' if matches!(cursor.next(), b'w' | b'W')
&& matches!(cursor.input.get(cursor.pos + 2), Some(b'[' | b'(' | b'{')) =>
&& !cursor.prev().is_ascii_alphanumeric()
&& !matches!(cursor.prev(), b'_' | b')' | b']' | b'}') =>
{
// Boundary characters
let (open, close) = match cursor.input.get(cursor.pos + 2) {
Some(b'[') => (b'[', b']'),
Some(b'(') => (b'(', b')'),
Some(b'{') => (b'{', b'}'),
Some(b'<') => (b'<', b'>'),
// Any other ASCII punctuation can be used as a custom delimiter
Some(&c) if c.is_ascii_punctuation() => (c, c),
// Everything else is not a valid delimiter
_ => {
cursor.advance();
continue;
}
};
result[cursor.pos] = b' '; // Replace `%`
cursor.advance();
result[cursor.pos] = b' '; // Replace `w`
cursor.advance();
result[cursor.pos] = b' '; // Replace `[` or `(` or `{`
bracket_stack.push(cursor.curr());
cursor.advance(); // Move past the bracket
continue;
result[cursor.pos] = b' '; // Replace the opening delimiter
cursor.advance();
// Paired delimiters can be nested as long as they are balanced. E.g.:
// `%w[foo[bar]baz]` produces a flat array.
let mut depth = 1_usize;
while cursor.pos < len {
match cursor.curr() {
// Skip escaped characters, unless the backslash is the delimiter
// itself
b'\\' if close != b'\\' => {
// Use backslash to embed spaces in the strings.
if cursor.next() == b' ' {
result[cursor.pos] = b' ';
}
cursor.advance();
}
// Start of a nested delimiter pair
c if c == open && open != close => depth += 1,
// Closing delimiter
c if c == close => {
depth -= 1;
// End of the literal, replace the closing delimiter with a space
if depth == 0 {
result[cursor.pos] = b' ';
break;
}
}
// Everything else is valid content
_ => {}
}
cursor.advance();
}
}
// Any `[` preceded by an alphanumeric value will not be part of a candidate.
@ -316,16 +373,77 @@ mod tests {
]
"#;
let expected = r#"
div
class= bg-blue-500 w-10 h-10]
]
div
class= w-10 bg-green-500 h-10]
]
"#;
let expected = "
div \n class= bg-blue-500 w-10 h-10 \n ]
div \n class= w-10 bg-green-500 h-10 \n ]
";
Slim::test(input, expected);
Slim::test_extract_contains(input, vec!["bg-blue-500", "bg-green-500", "w-10", "h-10"]);
}
// https://github.com/tailwindlabs/tailwindcss/issues/20386
#[test]
fn test_embedded_ruby_percent_w_delimiters() {
for (input, expected) in [
// %w<…>, Slim only counts `[({` nesting in attribute values, so the code must be
// wrapped in parentheses to contain spaces
(
"div class=(%w<bg-blue-500 w-10 h-10>)",
"div class= bg-blue-500 w-10 h-10 )",
),
// Nested `<…>` does not end the literal
(
"div class=(%w<flex <nested> px-2.5>)",
"div class= flex <nested> px-2.5 )",
),
// Custom delimiters
("div class=(%w|flex px-2.5|)", "div class= flex px-2.5 )"),
("div class=(%W!flex px-2.5!)", "div class= flex px-2.5 )"),
(
"div class=(%w#text-sm leading-6#)",
"div class= text-sm leading-6 )",
),
(
"div class=(%w=italic tracking-wide=)",
"div class= italic tracking-wide )",
),
// Nested paired delimiters stay balanced inside the literal
(
"div class=(%w[content-['[hello]'] p-4])",
"div class= content-['[hello]'] p-4 )",
),
// Escaped spaces embed a space in a single array element
(
r#"div class=(%w[foo\ bar baz-1])"#,
r#"div class= foo bar baz-1 )"#,
),
// Ruby control line, which is plain Ruby code
("- classes = %w<mt-4 flex>", "- classes = mt-4 flex "),
// A `%` that follows a value is not a percent literal
("| hit rate 50%w.", "| hit rate 50%w "),
] {
Slim::test(input, expected);
}
let input = r#"
div[
class=(%w<bg-blue-500 w-10 h-10>)
]
- classes = %w|w-10 bg-green-500 h-10|
= tag.div class: %W!px-2.5 flex!
"#;
Slim::test_extract_contains(
input,
vec![
"bg-blue-500",
"bg-green-500",
"w-10",
"h-10",
"px-2.5",
"flex",
],
);
}
}

View file

@ -112,6 +112,7 @@ pub fn resolve_globs(
}
if !dirs.contains(path) {
it.skip_current_dir();
continue;
}

View file

@ -37,20 +37,52 @@ pub fn init_tracing() {
return;
}
let file_path = format!("tailwindcss-{}.log", std::process::id());
let file = OpenOptions::new()
let root = Path::new(".tailwindcss");
let logs_dir = root.join("logs");
if let Err(err) = std::fs::create_dir_all(&logs_dir) {
eprintln!(
"{} Failed to create {}, skipping debug logs ({err})",
dim("[DEBUG]"),
highlight(&logs_dir.display().to_string())
);
return;
}
// Ensure everything inside `.tailwindcss/` is ignored by git. The file is only created if it
// doesn't exist yet, an existing `.gitignore` is left untouched.
if let Ok(mut file) = std::fs::File::create_new(root.join(".gitignore")) {
_ = file.write_all(b"*\n");
}
let file_path = logs_dir.join(format!(
"scanner-{}-{}.log",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0),
std::process::id()
));
let file = match OpenOptions::new()
.create(true)
.append(true)
.open(&file_path)
.unwrap_or_else(|_| panic!("Failed to open {file_path}"));
{
Ok(file) => file,
Err(err) => {
eprintln!(
"{} Failed to create {}, skipping debug logs ({err})",
dim("[DEBUG]"),
highlight(&file_path.display().to_string())
);
return;
}
};
let file_path = Path::new(&file_path);
let absolute_file_path = dunce::canonicalize(file_path)
.unwrap_or_else(|_| panic!("Failed to canonicalize {file_path:?}"));
let absolute_file_path = dunce::canonicalize(&file_path).unwrap_or_else(|_| file_path.clone());
eprintln!(
"{} Writing debug info to: {}\n",
dim("[DEBUG]"),
highlight(absolute_file_path.as_path().to_str().unwrap())
highlight(&absolute_file_path.display().to_string())
);
let file = Arc::new(Mutex::new(file));

View file

@ -78,11 +78,12 @@ pub struct Scanner {
/// Track unique set of candidates
candidates: FxHashSet<String>,
/// Track mtimes for files so re-scans can skip unchanged files.
/// Only populated after the first scan completes (to avoid unnecessary
/// metadata calls on initial build).
/// Track mtimes for files so incremental scans can skip unchanged files.
mtimes: FxHashMap<PathBuf, SystemTime>,
/// Files that were scanned during the last `scan()` call.
scanned_files: Vec<String>,
/// Whether we've completed at least one full scan. When false, we skip
/// mtime tracking entirely so the initial build stays fast.
has_scanned_once: bool,
@ -122,9 +123,10 @@ impl Scanner {
pub fn scan(&mut self) -> Vec<String> {
self.sources_scanned = false;
let (scanned_blobs, css_files) = self.discover_sources();
let (scanned_blobs, css_files, files) = self.discover_sources();
self.extract_candidates(scanned_blobs, css_files);
self.scanned_files = files;
// Return all candidates sorted
let mut result = self.candidates.iter().cloned().collect::<Vec<_>>();
@ -179,6 +181,11 @@ impl Scanner {
continue;
}
// The walked path can contain symlinks, while the changed files have already
// been canonicalized. Lazily canonicalize the walked path so we can compare
// the real paths as well.
let mut canonical_path: Option<PathBuf> = None;
let mut drop_file_indexes = vec![];
for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() {
let ChangedContent::File(file, _) = changed_file else {
@ -187,7 +194,15 @@ impl Scanner {
// When the file is found on disk it means that all the rules pass. We can
// extract the current file and remove it from the list of passed in files.
if file == path {
let matches = file == path
|| (file.file_name() == path.file_name() && {
if canonical_path.is_none() {
canonical_path = dunce::canonicalize(path).ok();
}
canonical_path.as_deref() == Some(file.as_path())
});
if matches {
self.files.insert(path.to_path_buf()); // Track for future use
content_to_scan.push(changed_file.clone()); // Track for parsing
drop_file_indexes.push(idx);
@ -256,6 +271,11 @@ impl Scanner {
.collect()
}
#[tracing::instrument(skip_all)]
pub fn get_scanned_files(&self) -> Vec<String> {
self.scanned_files.clone()
}
#[tracing::instrument(skip_all)]
pub fn get_globs(&mut self) -> Vec<GlobEntry> {
if let Some(globs) = &self.globs {
@ -336,15 +356,14 @@ impl Scanner {
let i = s.as_ptr() as usize - offset;
let original = &original_content[i..i + s.len()];
if original.contains_str("-[]") {
return Some(unsafe {
(String::from_utf8_unchecked(original.to_vec()), i)
});
return String::from_utf8(original.to_vec())
.ok()
.map(|candidate| (candidate, i));
}
// SAFETY: When we parsed the candidates, we already guaranteed that the byte
// slices are valid, therefore we don't have to re-check here when we want to
// convert it back to a string.
Some(unsafe { (String::from_utf8_unchecked(s.to_vec()), i) })
String::from_utf8(s.to_vec())
.ok()
.map(|candidate| (candidate, i))
}
_ => None,
@ -353,14 +372,14 @@ impl Scanner {
}
#[tracing::instrument(skip_all)]
fn discover_sources(&mut self) -> (Vec<Vec<u8>>, Vec<PathBuf>) {
fn discover_sources(&mut self) -> (Vec<Vec<u8>>, Vec<PathBuf>, Vec<String>) {
if self.sources_scanned {
return (vec![], vec![]);
return (vec![], vec![], vec![]);
}
self.sources_scanned = true;
let Some(walker) = &mut self.walker else {
return (vec![], vec![]);
return (vec![], vec![], vec![]);
};
// Use synchronous walk for the initial build (lower overhead) and parallel
@ -372,7 +391,8 @@ impl Scanner {
};
let mut css_files: Vec<PathBuf> = vec![];
let mut content_paths: Vec<(PathBuf, String)> = Vec::new();
let mut content_paths: Vec<(PathBuf, String)> = vec![];
let mut changed_files = vec![];
// Fresh state
self.files.clear();
@ -380,42 +400,93 @@ impl Scanner {
self.extensions.clear();
self.globs = None;
for (path, is_dir, extension) in all_entries {
if is_dir {
self.dirs.insert(path);
} else {
// Deduplicate: parallel walk can visit the same file from multiple threads
if !self.files.insert(path.clone()) {
continue;
// Cache canonicalized folders in case a file itself is not symlinked, but any of the parent
// folders are symlinked.
let mut cached_canonical_dirs: FxHashMap<PathBuf, PathBuf> = FxHashMap::default();
for entry in all_entries {
match entry {
WalkEntry::Dir(path) => {
self.dirs.insert(path);
}
self.extensions.insert(extension.clone());
// On re-scans, check mtime to skip unchanged files.
// On the first scan we skip this entirely to avoid extra
// metadata syscalls.
let changed = if self.has_scanned_once {
let current_mtime = path.metadata().ok().and_then(|m| m.modified().ok());
match current_mtime {
Some(mtime) => {
let prev = self.mtimes.insert(path.clone(), mtime);
prev.is_none_or(|prev| prev != mtime)
}
None => true,
WalkEntry::File {
path,
mtime,
is_symlink,
} => {
// Deduplicate: parallel walk can visit the same file from multiple threads
if !self.files.insert(path.clone()) {
continue;
}
} else {
true
};
if !changed {
continue;
}
// Track canonicalized paths in addition to potentially symlinked file paths
let canonical = if is_symlink {
dunce::canonicalize(&path).ok()
} else {
path.parent().and_then(|parent| {
// Perf: cache the canonicalized parent path such that sibling files don't
// have to canonicalize over and over again.
let canonical_parent = cached_canonical_dirs
.entry(parent.to_path_buf())
.or_insert_with(|| {
dunce::canonicalize(parent)
.unwrap_or_else(|_| parent.to_path_buf())
});
match extension.as_str() {
// Special handing for CSS files, we don't want to extract candidates from
// these files, but we do want to extract used CSS variables.
"css" => css_files.push(path),
_ => content_paths.push((path, extension)),
if canonical_parent.as_path() != parent {
path.file_name()
.map(|file_name| canonical_parent.join(file_name))
} else {
None
}
})
};
if let Some(canonical) = canonical {
if canonical != path {
self.files.insert(canonical);
}
}
let extension = path
.extension()
.and_then(|x| x.to_str())
.unwrap_or_default()
.to_owned();
self.extensions.insert(extension.to_owned());
// On incremental scans, check mtime to skip unchanged files.
// On the first scan, track mtimes while still scanning every file.
let changed = if self.has_scanned_once {
match mtime {
Some(mtime) => {
let prev = self.mtimes.insert(path.clone(), mtime);
prev.is_none_or(|prev| prev != mtime)
}
None => true,
}
} else {
if let Some(mtime) = mtime {
self.mtimes.insert(path.clone(), mtime);
}
true
};
if !changed {
continue;
}
if let Ok(file) = path.clone().into_os_string().into_string() {
changed_files.push(file);
}
match extension.as_str() {
// Special handing for CSS files, we don't want to extract candidates from
// these files, but we do want to extract used CSS variables.
"css" => css_files.push(path),
_ => content_paths.push((path, extension)),
}
}
}
}
@ -442,7 +513,9 @@ impl Scanner {
self.has_scanned_once = true;
}
(scanned_blobs, css_files)
changed_files.par_sort_unstable();
(scanned_blobs, css_files, changed_files)
}
}
@ -543,35 +616,50 @@ where
a
})
.into_iter()
.map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) })
.filter_map(|s| String::from_utf8(s.to_vec()).ok())
.collect()
}
type WalkEntry = (PathBuf, bool, String);
#[derive(Debug)]
enum WalkEntry {
Dir(PathBuf),
File {
path: PathBuf,
mtime: Option<SystemTime>,
/// Whether the path itself is a symlink
is_symlink: bool,
},
}
impl From<ignore::DirEntry> for WalkEntry {
fn from(entry: ignore::DirEntry) -> Self {
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let is_symlink = entry.path_is_symlink();
let path = entry.into_path();
if is_dir {
WalkEntry::Dir(path)
} else {
let mtime = path.metadata().ok().and_then(|m| m.modified().ok());
WalkEntry::File {
path,
mtime,
is_symlink,
}
}
}
}
/// Walk the file system synchronously. Used for the initial build where the overhead of spawning
/// parallel walker threads is not worth it.
#[tracing::instrument(skip_all)]
fn walk_synchronous(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
let mut entries = Vec::new();
for entry in walker.build().filter_map(Result::ok) {
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let path = entry.into_path();
if is_dir {
entries.push((path, true, String::new()));
} else {
let ext = path
.extension()
.and_then(|x| x.to_str())
.unwrap_or_default()
.to_owned();
entries.push((path, false, ext));
}
}
entries
walker
.build()
.filter_map(Result::ok)
.map(WalkEntry::from)
.collect()
}
/// Walk the file system in parallel. Used in watch mode where the parallel walker overhead is
@ -591,7 +679,7 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
}
}
let collected: Arc<Mutex<Vec<WalkEntry>>> = Arc::new(Mutex::new(Vec::new()));
let collected: Arc<Mutex<Vec<WalkEntry>>> = Arc::new(Mutex::new(vec![]));
walker.build_parallel().run(|| {
let mut buf = FlushOnDrop {
@ -604,19 +692,7 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
return ignore::WalkState::Continue;
};
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let path = entry.into_path();
if is_dir {
buf.local.push((path, true, String::new()));
} else {
let ext = path
.extension()
.and_then(|x| x.to_str())
.unwrap_or_default()
.to_owned();
buf.local.push((path, false, ext));
}
buf.local.push(WalkEntry::from(entry));
if buf.local.len() >= 256 {
buf.shared.lock().unwrap().append(&mut buf.local);

View file

@ -148,6 +148,16 @@ fn expand_restricted_patterns(sources: Vec<SourceEntry>) -> Vec<SourceEntry> {
})
.collect::<Vec<_>>();
// Bases of restricted patterns. Each of these becomes its own walk root with its own
// `*` + `!<pattern>` rules, so an ancestor base must not ignore them recursively.
let pattern_roots = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Pattern { base, .. } => Some(base.clone()),
_ => None,
})
.collect::<Vec<_>>();
let mut restricted_roots: FxHashSet<PathBuf> = FxHashSet::default();
let mut expanded = vec![];
@ -167,14 +177,17 @@ fn expand_restricted_patterns(sources: Vec<SourceEntry>) -> Vec<SourceEntry> {
}
// Ignore everything in the directory. We will later add the specific patterns we are
// interested in. When another source root is nested in this base, only ignore direct
// children so the nested source can still be walked from its own root.
// interested in.
if restricted_roots.insert(base.clone()) {
let pattern = if unrestricted_roots.iter().any(|root| root.starts_with(base)) {
"/*"
} else {
"*"
};
// When another source root is nested inside this base — an unrestricted root, or the
// base of another restricted pattern (which is walked from its own root with its own
// rules) — only ignore direct children so the nested root can still be walked.
let has_nested_root = unrestricted_roots.iter().any(|root| root.starts_with(base))
|| pattern_roots
.iter()
.any(|root| root != base && root.starts_with(base));
let pattern = if has_nested_root { "/*" } else { "*" };
expanded.push(SourceEntry::Ignored {
base: base.clone(),
@ -750,12 +763,11 @@ pub fn public_source_entries_to_private_source_entries(
.collect::<Vec<_>>();
// Compiled `.gitignore` matchers are cached per directory so we read and parse each
// `.gitignore` file at most once, even though entries commonly share ancestor directories
// (e.g. the repository root). A cached `None` means the directory has no `.gitignore` file.
// `.gitignore` file at most once, even though entries commonly share ancestor directories (e.g.
// the repository root). A cached `None` means the directory has no `.gitignore` file.
let mut gitignores: FxHashMap<PathBuf, Option<Gitignore>> = FxHashMap::default();
// Boundary for the `.gitignore` walk when a source is not inside a git repository (see
// below).
// Boundary for the `.gitignore` walk when a source is not inside a git repository (see below).
let cwd = std::env::current_dir()
.map(|cwd| dunce::canonicalize(&cwd).unwrap_or(cwd))
.ok();
@ -777,18 +789,39 @@ pub fn public_source_entries_to_private_source_entries(
let gitignore = gitignores.entry(dir.to_path_buf()).or_insert_with(|| {
let path = dir.join(".gitignore");
// `Gitignore::new` roots the matcher at the directory
// containing the file, so patterns match relative to it.
// `Gitignore::new` roots the matcher at the directory containing the file,
// so patterns match relative to it.
path.is_file().then(|| Gitignore::new(&path).0)
});
if let Some(gitignore) = gitignore {
if gitignore
.matched_path_or_any_parents(&base, true)
.is_ignore()
{
source = SourceEntry::External { base: base.into() };
break;
// Only `.gitignore` files in ancestors of `base` can ignore `base` itself.
// Patterns in `base`'s own `.gitignore` only match paths _inside_ `base`, never
// `base` itself (the file walker still applies them to `base`'s contents).
//
// Skipping `base`'s own `.gitignore` also prevents a false positive for
// whitelist style `.gitignore` files, because relativizing `base` against
// itself yields the empty path, which incorrectly matches `/*`.
//
// E.g.:
//
// ```gitignore
// /*
// !/.gitignore
// !/app
// !/public
// ```
//
// Everything inside `base` except `.gitignore`, `app` and `public` is ignored,
// but `base` itself is not.
if dir != base {
if let Some(gitignore) = gitignore {
if gitignore
.matched_path_or_any_parents(&base, true)
.is_ignore()
{
source = SourceEntry::External { base: base.into() };
break;
}
}
}
@ -797,12 +830,11 @@ pub fn public_source_entries_to_private_source_entries(
break;
}
// Without a git repository there is no repository root to stop at. Stop
// once the directory contains the current working directory instead, so
// `.gitignore` files outside of the project (e.g. in the user's home
// directory) can never promote a source to an external source. Note that
// the file walker still applies those `.gitignore` files when deciding
// which files to scan.
// Without a git repository there is no repository root to stop at. Stop once
// the directory contains the current working directory instead, so `.gitignore`
// files outside of the project (e.g. in the user's home directory) can never
// promote a source to an external source. Note that the file walker still
// applies those `.gitignore` files when deciding which files to scan.
if !inside_git_repo && cwd.as_ref().is_some_and(|cwd| cwd.starts_with(dir)) {
break;
}

File diff suppressed because it is too large Load diff

View file

@ -375,6 +375,40 @@ describe.each([
},
)
test(
'watch mode with polling',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'src/index.css': css`@import 'tailwindcss/utilities';`,
'src/index.html': html`
<div class="underline"></div>
`,
},
},
async ({ fs, spawn }) => {
let process = await spawn(
`${command} --input src/index.css --output dist/out.css --watch --poll=50`,
)
await process.onStderr((m) => m.includes('Done in'))
await fs.expectFileToContain('dist/out.css', [candidate`underline`])
await fs.write('src/index.html', html`
<div class="underline flex"></div>
`)
await fs.expectFileToContain('dist/out.css', [candidate`flex`])
},
)
test(
"watch mode with unknown @source paths shouldn't crash on Windows",
{
@ -2242,6 +2276,70 @@ test(
},
)
// https://github.com/tailwindlabs/tailwindcss/discussions/20382
test(
'auto source detection respects allow-list .gitignore files',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'.gitignore': txt`
/*
!/.gitignore
!/app
!/public
!/package.json
`,
'app/root.css': css` @import 'tailwindcss/utilities'; `,
// Included by the `!/app` pattern in `.gitignore`
//
// → Should be included
'app/index.html': html`
<div class="content-['app/index.html']"></div>
`,
// Included by the `!/public` pattern in `.gitignore`
//
// → Should be included
'public/index.html': html`
<div class="content-['public/index.html']"></div>
`,
// Ignored by the `/*` in `.gitignore`
//
// → Should be ignored
'build/index.html': html`
<div class="content-['build/index.html']"></div>
`,
// Ignored by the `/*` in `.gitignore` and the default `node_modules` rules
//
// → Should be ignored
'node_modules/my-ui-lib/index.html': html`
<div
class="content-['node_modules/my-ui-lib/index.html']"
></div>
`,
},
},
async ({ fs, exec }) => {
await exec('pnpm tailwindcss --input app/root.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [
candidate`content-['app/index.html']`,
candidate`content-['public/index.html']`,
])
await fs.expectFileNotToContain('dist/out.css', [
candidate`content-['build/index.html']`,
candidate`content-['node_modules/my-ui-lib/index.html']`,
])
},
)
test(
'@source works with symlinks (referencing folder in current folder)',
{
@ -2797,8 +2895,8 @@ test(
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial,
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--color-red-500: oklch(63.7% 0.237 25.331);
@ -2817,7 +2915,7 @@ test(
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
@ -2869,7 +2967,7 @@ test(
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}
progress {

View file

@ -109,13 +109,11 @@ test(
expect(await fs.dumpFiles('dist/out.css')).toMatchInlineSnapshot(`
"
--- dist/out.css ---
.prose-custom {
:where(hr):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
--x: 1;
@media (width >= 640px) {
&.custom {
--x: 2;
}
.prose-custom :where(hr):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
--x: 1;
@media (width >= 640px) {
&.custom {
--x: 2;
}
}
}

View file

@ -1,4 +1,4 @@
import { css, js, json, test } from '../utils'
import { css, js, json, test, yaml } from '../utils'
// This test runs the wasm build using the `node:wasi` runtime.
//
@ -57,3 +57,187 @@ testFn(
`)
},
)
testFn(
'`@tailwindcss/oxide` falls back to the wasm build when no native binding is available',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/oxide": "workspace:^"
}
}
`,
'pnpm-workspace.yaml': yaml`
# Trick pnpm in only supporting an architecture that @tailwindcss/oxide
# doesn't support, and therefore should fallback to the wasm version.
supportedArchitectures:
os:
- openbsd
cpu:
- x64
`,
'src/index.js': js`
const className = "content-['src/index.js']"
module.exports = { className }
`,
'index.mjs': js`
import { createRequire } from 'node:module'
import { join } from 'node:path'
let require = createRequire(import.meta.url)
let { Scanner } = require('@tailwindcss/oxide')
let loaded = Object.keys(require.cache)
let scanner = new Scanner({
sources: [
{
base: join(process.cwd(), 'src'),
pattern: '**/*',
negated: false,
},
],
})
console.log(
JSON.stringify({
native: loaded.filter((file) => file.endsWith('.node')),
wasi: loaded.some((file) => file.endsWith('tailwindcss-oxide.wasi.cjs')),
candidates: scanner.scan(),
}),
)
process.exit()
`,
},
},
async ({ expect, exec }) => {
// Since vitest runs under `pnpm run`, pnpm's bin shims export a NODE_PATH
// that includes the repository's hidden hoist directory
// (`node_modules/.pnpm/node_modules`), which links every workspace package,
// including all native `@tailwindcss/oxide-*` bindings.
//
// Node uses `NODE_PATH` exactly when the local `node_modules` lookup fails,
// which would defeat the simulated unsupported platform, so clear it.
let output = await exec(`node index.mjs`, { env: { NODE_PATH: '' } })
let { native, wasi, candidates } = JSON.parse(output)
// No native binding was installed or loaded, ...
expect(native).toEqual([])
// ... the wasm32-wasi binding is what actually loaded, ...
expect(wasi).toBe(true)
// ... and scanning real files on disk works through it.
expect(candidates).toMatchInlineSnapshot(`
[
"className",
"const",
"content-['src/index.js']",
"exports",
]
`)
},
)
testFn(
'the wasm build loads even when preopening the filesystem root is denied',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/oxide": "workspace:^"
}
}
`,
'pnpm-workspace.yaml': yaml`
# Trick pnpm in only supporting an architecture that @tailwindcss/oxide
# doesn't support, and therefore should fallback to the wasm version.
supportedArchitectures:
os:
- openbsd
cpu:
- x64
`,
// The wasm bindings generated by `@napi-rs/cli` preopen the filesystem
// root, which sandboxed platforms (e.g. OpenHarmony, Android) deny with
// `UVWASI_EACCES`, making the wasm fallback fail to load on exactly the
// platforms that need it.
//
// We patch `@napi-rs/cli`'s templates to retry with narrower preopens
// (see `patches/@napi-rs__cli@3.7.4.patch`). Simulate such a sandbox by
// denying `/` preopens.
'preload.cjs': js`
const wasi = require('node:wasi')
const RealWASI = wasi.WASI
wasi.WASI = class WASI extends RealWASI {
constructor(options) {
if (options?.preopens?.['/'] !== undefined) {
const error = new Error('UVWASI_EACCES, uvwasi_init')
error.code = 'UVWASI_EACCES'
error.syscall = 'uvwasi_init'
throw error
}
super(options)
}
}
`,
'src/index.js': js`
const className = "content-['src/index.js']"
module.exports = { className }
`,
'index.mjs': js`
import { createRequire } from 'node:module'
import { join } from 'node:path'
let require = createRequire(import.meta.url)
let { Scanner } = require('@tailwindcss/oxide')
let loaded = Object.keys(require.cache)
let scanner = new Scanner({
sources: [
{
base: join(process.cwd(), 'src'),
pattern: '**/*',
negated: false,
},
],
})
console.log(
JSON.stringify({
wasi: loaded.some((file) => file.endsWith('tailwindcss-oxide.wasi.cjs')),
candidates: scanner.scan(),
}),
)
process.exit()
`,
},
},
async ({ expect, exec }) => {
// See the note about NODE_PATH in the test above.
let output = await exec(`node --require ./preload.cjs index.mjs`, { env: { NODE_PATH: '' } })
// Only parse the first line, because Node prints an `ExperimentalWarning`
// about WASI to stderr, which `exec` appends to the output.
let { wasi, candidates } = JSON.parse(output.trim().split('\n')[0])
// The wasm32-wasi binding loaded despite `/` being denied, ...
expect(wasi).toBe(true)
// ... and scanning files under the current working directory still works
// through the narrower preopen.
expect(candidates).toMatchInlineSnapshot(`
[
"className",
"const",
"content-['src/index.js']",
"exports",
]
`)
},
)

View file

@ -0,0 +1,83 @@
import { candidate, css, fetchStyles, js, json, jsx, retryAssertion, test } from '../utils'
test(
'@tailwindcss/turbopack loader (dev)',
{
timeout: 120_000,
fs: {
'package.json': json`
{
"dependencies": {
"next": "^16.2.7",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwindcss": "workspace:^",
"@tailwindcss/turbopack": "workspace:^"
}
}
`,
'next.config.mjs': js`
export default {
turbopack: {
rules: {
'*.css': {
loaders: ['@tailwindcss/turbopack'],
as: '*.css',
},
},
},
}
`,
'app/layout.js': jsx`
import './globals.css'
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
`,
'app/page.js': jsx`
export default function Page() {
return <div className="flex">Hello, Next.js!</div>
}
`,
'app/globals.css': css` @import 'tailwindcss'; `,
},
},
async ({ fs, spawn, expect }) => {
let process = await spawn('pnpm next dev')
let url = ''
await process.onStdout((message) => {
let match = /Local:\s*(http.*)/.exec(message)
if (match) url = match[1]
return Boolean(url)
})
await process.onStdout((message) => message.includes('Ready in'))
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`flex`)
expect(styles).not.toContain(candidate`underline`)
})
await fs.write(
'app/page.js',
jsx`
export default function Page() {
return <div className="flex underline">Hello, Next.js!</div>
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`flex`)
expect(styles).toContain(candidate`underline`)
})
},
)

View file

@ -2557,7 +2557,7 @@ test(
@import './utilities.css';
@import './generated/ignore-me.css';
`,
'src/generated/.gitignore': `
'src/generated/.gitignore': txt`
*
!.gitignore
`,
@ -2817,7 +2817,7 @@ test(
<div class="ring-3"></div>
--- ./src/input.css ---
@import 'tailwindcss';
@import 'tailwindcss';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
@ -3371,6 +3371,61 @@ test(
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/20328
test(
'ignored files should not be touched when upgrading from a nested directory',
{
fs: {
'pnpm-workspace.yaml': yaml`
#
packages:
- packages/*
`,
'package.json': json`
{
"name": "root",
"private": true
}
`,
'packages/css/package.json': json`
{
"name": "css-pkg",
"private": true,
"devDependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'packages/css/src/input.css': css`
@import 'tailwindcss/utilities.css' layer(utilities) source(none);
`,
'packages/css/src/index.html': html`<div class="flex text-red-500">Hi</div>`,
// Ensure files/folders ignored by a `.gitignore` in the root take effect
// when executing the upgrade tool from a sub-package.
'.gitignore': txt`
node_modules/
`,
},
},
async ({ root, exec, fs, expect }) => {
await exec('git init', { cwd: root })
await exec('pnpm exec upgrade --force', {
cwd: path.join(root, 'packages/css'),
})
expect(await fs.dumpFiles('packages/css/node_modules/tailwindcss/utilities.css'))
.toMatchInlineSnapshot(`
"
--- packages/css/node_modules/tailwindcss/utilities.css ---
@tailwind utilities;
"
`)
},
)
test(
'upgrade <style> blocks carefully',
{

View file

@ -594,17 +594,17 @@ test(
@import 'tailwindcss';
@theme {
--color-gray-50: oklch(98.5% 0 0);
--color-gray-100: oklch(97% 0 0);
--color-gray-200: oklch(92.2% 0 0);
--color-gray-300: oklch(87% 0 0);
--color-gray-400: oklch(70.8% 0 0);
--color-gray-500: oklch(55.6% 0 0);
--color-gray-600: oklch(43.9% 0 0);
--color-gray-700: oklch(37.1% 0 0);
--color-gray-800: oklch(26.9% 0 0);
--color-gray-900: oklch(20.5% 0 0);
--color-gray-950: oklch(14.5% 0 0);
--color-gray-50: oklch(98.5% 0 none);
--color-gray-100: oklch(97% 0 none);
--color-gray-200: oklch(92.2% 0 none);
--color-gray-300: oklch(87% 0 none);
--color-gray-400: oklch(70.8% 0 none);
--color-gray-500: oklch(55.6% 0 none);
--color-gray-600: oklch(43.9% 0 none);
--color-gray-700: oklch(37.1% 0 none);
--color-gray-800: oklch(26.9% 0 none);
--color-gray-900: oklch(20.5% 0 none);
--color-gray-950: oklch(14.5% 0 none);
}
/*

View file

@ -612,6 +612,7 @@ function overwriteVersionsInPnpmWorkspace(content: string): string {
workspace.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/turbopack>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg)
} else {

View file

@ -0,0 +1,193 @@
import path from 'node:path'
import { candidate, css, html, json, retryAssertion, test, ts, txt, yaml } from '../utils'
// Vite's experimental `bundledDev` mode invokes the `hotUpdate` hook without a
// `server`, which used to crash the plugin on every file edit.
//
// - https://github.com/tailwindlabs/tailwindcss/issues/20378
// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode
test(
'dev mode (experimental `bundledDev`)',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': txt`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8.1"
}
}
`,
'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
experimental: {
bundledDev: true,
},
plugins: [tailwindcss()],
})
`,
'project-a/index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline">Hello, world!</div>
</body>
`,
'project-a/src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@source '../../project-b/src/**/*.html';
`,
'project-b/src/index.html': html`
<div class="flex" />
`,
},
},
async ({ root, spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev', {
cwd: path.join(root, 'project-a'),
env: {
// Some CI machines have an upstream rolldown bug where its file watcher
// never delivers a single event. The debug output is the only way to
// tell that apart from a real regression (see below).
DEBUG: 'vite:full-bundle-mode',
},
})
// Any debug output beyond the "INITIAL:" startup lines proves rolldown's
// watcher delivered at least one file event.
let watcherAlive = false
// `hotUpdate` errors don't kill the dev server, they are only printed to
// stderr. Track them explicitly so a crash fails the test even if the
// rebuild happens to succeed anyway.
let pluginErrors: string[] = []
process.onStderr((message) => {
if (message.includes('vite:full-bundle-mode')) {
if (!message.includes('INITIAL:')) watcherAlive = true
} else if (message.includes('@tailwindcss/vite')) {
pluginErrors.push(message)
}
return false
})
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
// In `bundledDev` mode the stylesheet is not served separately. Instead the
// generated CSS is embedded in the bundled JS and injected at runtime, so
// extract the bundle from the served HTML. While the bundle is being built,
// Vite serves a temporary fallback page instead.
//
// The bundle can be split into multiple chunks (e.g. the HMR client runtime
// and the app itself), and the chunk containing the CSS is not always the
// first one, so fetch every referenced script and stylesheet.
async function fetchBundledStyles(): Promise<string> {
let index = await fetch(`${url}/`)
let html = await index.text()
if (html.includes('__vite_is_fallback_page__')) {
throw new Error('Bundling still in progress')
}
let sources = [
...html.matchAll(/<script[^>]*\ssrc="([^"]+)"/g),
...html.matchAll(/<link[^>]*\srel="stylesheet"[^>]*\shref="([^"]+)"/g),
].map((match) => match[1])
if (sources.length === 0) throw new Error(`No scripts or stylesheets found in:\n\n${html}`)
let contents = await Promise.all(
sources.map(async (src) => {
let response = await fetch(new URL(src, `${url}/`))
return await response.text()
}),
)
return contents.join('\n')
}
await retryAssertion(
async () => {
let styles = await fetchBundledStyles()
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
},
{ timeout: 10_000, delay: 100 },
)
// Edit a module file and a manually `@source`d file. Without a connected
// HMR websocket client (this test only uses HTTP fetches), a file change
// does not trigger an eager rebuild — it only marks the bundle as stale,
// and the next fetch kicks off the rebuild. So poll with fetches without
// touching the files again: rewriting would re-mark the bundle stale right
// before each fetch and the rebuild could never be observed.
await fs.write(
'project-a/index.html',
html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline m-2">Hello, world!</div>
</body>
`,
)
await fs.write('project-b/src/index.html', html`
<div class="flex font-bold" />
`)
try {
await retryAssertion(
async () => {
let styles = await fetchBundledStyles()
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
expect(styles).toContain(candidate`m-2`)
expect(styles).toContain(candidate`font-bold`)
},
{ timeout: 15_000, delay: 100 },
)
} catch (error) {
// If rolldown's watcher delivered any file event, the updates were
// genuinely dropped somewhere along the way — a real failure.
if (watcherAlive) throw error
// Otherwise the watcher never delivered a single event, so the updates
// can never be observed no matter how long we wait — an upstream
// rolldown bug on some CI machines, not a plugin regression. The crash
// regression this test guards is still covered: `hotUpdate` is driven
// by Vite's own (working) chokidar watcher and asserted via
// `pluginErrors` below.
//
// Skipping is only sound while the server still healthily serves the
// original bundle. If it stopped serving (crashed, or wedged on the
// fallback page), that's real breakage a silent watcher must not mask.
console.log(error)
let styles = await fetchBundledStyles()
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
console.warn(
'Skipping update assertions: rolldown’s file watcher delivered no events on this machine.',
)
}
expect(pluginErrors).toEqual([])
},
)

View file

@ -294,6 +294,113 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/20346
test(
'dev mode + symlinked `@source` files pick up changes to the real file',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
- packages/*
`,
'project-a/package.json': txt`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"repro-package": "workspace:*",
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''}
"vite": "^7"
}
}
`,
'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'project-a/index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline">Hello, world!</div>
</body>
`,
// The `@source` points through the `node_modules` symlink that pnpm
// creates for the workspace package. Vite's file watcher ignores
// `node_modules` by default, so the symlinked path can not be watched.
// The real file (in `packages/repro-package`) has to be watched
// instead.
'project-a/src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@source '../node_modules/repro-package/source.html';
`,
'packages/repro-package/package.json': json`
{
"name": "repro-package",
"private": true,
"version": "1.0.0"
}
`,
'packages/repro-package/source.html': html`
<div class="content-['v1']">
Hello, world!
</div>
`,
},
},
async ({ root, spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev', {
cwd: path.join(root, 'project-a'),
})
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`content-['v1']`)
})
await retryAssertion(async () => {
// Changes to the real file (behind the symlink) should be picked up
await fs.write(
'packages/repro-package/source.html',
html`
<div class="content-['v1'] content-['v2']">
Hello, world!
</div>
`,
)
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`content-['v1']`)
expect(styles).toContain(candidate`content-['v2']`)
})
},
)
test(
'watch mode',
{
@ -473,11 +580,11 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
},
)
describe.sequential.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])(
describe.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])(
'Using Vite %s',
(version) => {
test(
'external source file changes trigger a full reload',
'external source file changes update the CSS',
{
fs: {
'package.json': json`{}`,
@ -554,26 +661,33 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
expect(styles).toContain(candidate`content-['project-b/src/index.php']`)
})
// Flush all messages so that we can be sure the next messages are from
// the file changes we're about to make
// Flush all messages so that we can be sure the next messages are
// from the file changes we're about to make
process.flush()
// Changing an external .php file should trigger a full reload
// Changing an external .php file hot-updates the generated CSS
{
await fs.write(
'project-b/src/index.php',
txt`<div class="content-['updated:project-b/src/index.php']"></div>`,
)
// Ensure the page reloaded
// On Vite < 7.1, Vite itself hard-invalidates watched files that
// aren't part of the module graph and reloads the page.
//
// On newer versions nothing reloads the page: the CSS hot-updates
// through the regular pipeline because the changed file is a
// watch dependency of the CSS root.
//
// Reloading the page for external template changes is the
// responsibility of the backend integration (e.g. `laravel-vite-plugin`'s `refresh` option, or `vite-plugin-full-reload`).
//
// https://github.com/tailwindlabs/tailwindcss/issues/20411
if (version === '^6' || version === '7.0.8') {
await process.onStdout((m) => m.includes('page reload') && m.includes('index.php'))
} else {
await process.onStderr(
(m) => m.includes('vite:hmr (client)') && m.includes('index.php'),
)
await process.onStdout((m) => m.includes('hmr update') && m.includes('index.css'))
}
await process.onStderr((m) => m.includes('vite:hmr (ssr)') && m.includes('index.php'))
// Ensure the styles were regenerated with the new content
let styles = await fetchStyles(url, '/index.html')
@ -584,6 +698,234 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/20320
// https://github.com/tailwindlabs/tailwindcss/issues/19903
test(
'editing scanned files that Vite can process as modules does not trigger a full reload',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''}
"vite": "^8"
}
}
`,
'project-a/vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
plugins: [
tailwindcss(),
{
// A plugin that processes a custom file type into a JS
// module, similar to e.g. \`.vue\` or \`.svelte\` files
name: 'custom-file-type',
transform(code, id) {
if (id.endsWith('.custom')) {
return { code: 'export default ' + JSON.stringify(code), map: null }
}
},
},
{
// Log all HMR payloads to a file so the test can assert on
// them
name: 'hmr-wiretap',
configureServer(server) {
let logFile = path.resolve('hmr.log')
fs.writeFileSync(logFile, '')
for (let environment of Object.values(server.environments)) {
let send = environment.hot.send.bind(environment.hot)
environment.hot.send = (payload) => {
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
return send(payload)
}
}
},
},
],
})
`,
'project-a/index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'project-a/src/main.ts': ts`
import compA from './comp-a.custom'
import snippet from './snippet.php?raw'
console.log(compA, snippet)
`,
'project-a/src/snippet.php': html`
<div
class="content-['src/snippet.php']"
></div>
`,
'project-a/src/comp-a.custom': html`
<div
class="content-['src/comp-a.custom']"
></div>
`,
'project-a/src/comp-b.custom': html`
<div
class="content-['src/comp-b.custom']"
></div>
`,
'project-a/src/lazy.tsx': jsx`
export default function Lazy() {
return <div className="content-['src/lazy.tsx']" />
}
`,
'project-a/src/unimported.css': css`
.unimported {
color: red;
}
`,
'project-a/src/index.css': css`
@import 'tailwindcss';
@source '../../project-b/**/*.php';
`,
'project-b/src/index.php': html`
<div
class="content-['project-b/src/index.php']"
></div>
`,
},
},
async ({ root, spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev', {
cwd: path.join(root, 'project-a'),
})
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['src/lazy.tsx']`)
expect(styles).toContain(candidate`content-['src/comp-b.custom']`)
expect(styles).toContain(candidate`content-['project-b/src/index.php']`)
})
// Load `main.ts`, `comp-a.custom`, and `snippet.php?raw` as real
// modules, like a browser visiting the page would
await fetch(`${url}/src/main.ts`)
await fetch(`${url}/src/comp-a.custom?import`)
await fetch(`${url}/src/snippet.php?raw`)
// Changing a scanned `.tsx` file that is not part of the module graph
// (e.g. a lazily-loaded route that hasn't been visited yet) should not
// trigger a full reload, but new classes should still apply
//
// https://github.com/tailwindlabs/tailwindcss/issues/20320
{
await fs.write(
'project-a/src/lazy.tsx',
jsx`
export default function Lazy() {
return <div className="content-['updated:src/lazy.tsx']" />
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/lazy.tsx']`)
})
}
// The same holds for a custom file type as long as some file of the
// same type was processed as a module before
{
await fs.write(
'project-a/src/comp-b.custom',
html`<div class="content-['updated:src/comp-b.custom']"></div>`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/comp-b.custom']`)
})
}
// Changing a scanned stylesheet that is not part of the module graph
// (e.g. a component stylesheet that a framework plugin compiles into
// the component) should not trigger a full reload either
//
// https://github.com/tailwindlabs/tailwindcss/issues/19903
{
let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length
await fs.write(
'project-a/src/unimported.css',
css`
.unimported {
color: blue;
}
`,
)
// Wait until the change was handled and an update was pushed
await retryAssertion(async () => {
let log = await fs.read('project-a/hmr.log')
expect(log.split('"type":"update"').length).toBeGreaterThan(updates)
})
}
// Changing an external file (e.g. a PHP template) hot-updates the
// generated CSS but does not trigger a full reload either. Reloading the
// page for external template changes is the responsibility of the backend
// integration (e.g. `laravel-vite-plugin`'s `refresh` option, or
// `vite-plugin-full-reload`).
//
// https://github.com/tailwindlabs/tailwindcss/issues/20411
{
let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length
await fs.write(
'project-b/src/index.php',
html`<div class="content-['updated:project-b/src/index.php']"></div>`,
)
await retryAssertion(async () => {
let log = await fs.read('project-a/hmr.log')
expect(log.split('"type":"update"').length).toBeGreaterThan(updates)
})
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:project-b/src/index.php']`)
}
},
)
test(
`source(none) disables looking at the module graph`,
{
@ -1145,7 +1487,7 @@ test(
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="hover:flex">Hello, world!</div>
<div class="focus:text-[black]">Hello, world!</div>
</body>
`,
'src/index.css': css`
@ -1157,16 +1499,16 @@ test(
async ({ exec, expect, fs }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
// Should not be minified when optimize is disabled
let content = await fs.read(filename)
expect(content).toContain('.hover\\:flex {')
expect(content).toContain('&:hover {')
expect(content).toContain('@media (hover: hover) {')
expect(content).toContain('display: flex;')
expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'))
.toMatchInlineSnapshot(`
"
--- dist/assets/index-<hash>.css ---
.focus\\:text-\\[black\\]:focus {
color: black;
}
"
`)
},
)
@ -1200,7 +1542,7 @@ test(
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="hover:flex">Hello, world!</div>
<div class="hover:text-[black]">Hello, world!</div>
</body>
`,
'src/index.css': css`
@ -1212,15 +1554,17 @@ test(
async ({ exec, expect, fs }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
// Should be optimized but not minified
let content = await fs.read(filename)
expect(content).toContain('@media (hover: hover) {')
expect(content).toContain('.hover\\:flex:hover {')
expect(content).toContain('display: flex;')
expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'))
.toMatchInlineSnapshot(`
"
--- dist/assets/index-<hash>.css ---
@media (hover: hover) {
.hover\\:text-\\[black\\]:hover {
color: #000;
}
}
"
`)
},
)

View file

@ -0,0 +1,165 @@
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts, txt } from '../utils'
test(
'dev mode',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"preact": "^10"
},
"devDependencies": {
"@preact/preset-vite": "^2",
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^",
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import preact from '@preact/preset-vite'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
tailwindcss(),
preact(),
{
// Log all HMR payloads to a file so the test can assert on them
name: 'hmr-wiretap',
configureServer(server) {
let logFile = path.resolve('hmr.log')
fs.writeFileSync(logFile, '')
for (let environment of Object.values(server.environments)) {
let send = environment.hot.send.bind(environment.hot)
environment.hot.send = (payload) => {
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
return send(payload)
}
}
},
},
],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
`,
'src/main.tsx': ts`
import { render } from 'preact'
import { App } from './app'
render(<App />, document.getElementById('app')!)
`,
'src/app.tsx': ts`
import { useState } from 'preact/hooks'
export function App() {
const [count, setCount] = useState(0)
return (
<button className="underline" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
)
}
`,
'src/index.css': css`@import 'tailwindcss';`,
},
},
async ({ fs, spawn, expect }) => {
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`underline`)
})
// Load the component modules, like a browser visiting the page would
await fetch(`${url}/src/main.tsx`)
await fetch(`${url}/src/app.tsx`)
// Editing a component keeps HMR intact: new classes are delivered through
// a regular update, not a full page reload (which would lose all state)
{
await fs.write(
'src/app.tsx',
ts`
import { useState } from 'preact/hooks'
export function App() {
const [count, setCount] = useState(0)
return (
<button className="underline flex" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
)
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
})
expect(await fs.read('hmr.log')).toContain('"type":"update"')
expect(await fs.read('hmr.log')).not.toContain('full-reload')
}
// Changing a scanned file that is not part of the module graph (e.g.
// `package.json`, which package managers and other tooling write to while
// the dev server is running) should not trigger a full reload either —
// that would destroy client state. New candidates should still be picked
// up because the file is a watch dependency of the CSS root, so the CSS
// hot-updates through Vite's regular pipeline.
//
// https://github.com/tailwindlabs/tailwindcss/issues/20411
{
await fs.write(
'package.json',
txt`
{
"type": "module",
"description": "content-['package.json']",
"dependencies": {
"preact": "^10"
},
"devDependencies": {
"@preact/preset-vite": "^2",
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^",
"vite": "^8"
}
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`content-['package.json']`)
})
expect(await fs.read('hmr.log')).not.toContain('full-reload')
}
},
)

View file

@ -572,7 +572,7 @@ test(
await exec('pnpm vite build')
expect(
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
).toMatchInlineSnapshot(`
"
--- ./dist/assets/index-<hash>.css ---
@ -643,7 +643,7 @@ test(
await exec('pnpm vite build')
expect(
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
).toMatchInlineSnapshot(`
"
--- ./dist/assets/index-<hash>.css ---

View file

@ -1,5 +1,5 @@
import { stripVTControlCharacters } from 'node:util'
import { candidate, css, html, json, test, ts } from '../utils'
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'
test(
'production build',
@ -248,3 +248,133 @@ test(
}
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/20320
test(
'editing a scanned `.vue` file that is not loaded as a module does not trigger a full reload',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"vue": "^3.4.37",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6",
"@tailwindcss/vite": "workspace:^",
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
{
// Log update and full-reload HMR payloads to a file so the
// test can assert on them. Custom events are not logged
// because \`@vitejs/plugin-vue\` sends a \`file-changed\` event
// for every file change, including changes to the log file
// itself, which would cause an infinite feedback loop.
name: 'hmr-wiretap',
configureServer(server) {
let logFile = path.resolve('hmr.log')
fs.writeFileSync(logFile, '')
for (let environment of Object.values(server.environments)) {
let send = environment.hot.send.bind(environment.hot)
environment.hot.send = (payload) => {
if (payload.type === 'update' || payload.type === 'full-reload') {
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
}
return send(payload)
}
}
},
},
],
})
`,
'index.html': html`
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/index.css': css`@import 'tailwindcss';`,
'src/main.ts': ts`
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
`,
'src/App.vue': html`
<template>
<div class="content-['src/App.vue']">Hello Vue!</div>
</template>
`,
// This file is scanned by Tailwind but never imported, so it is not
// part of the loaded module graph (e.g. a lazy route that hasn't been
// visited yet)
'src/LazyRoute.vue': html`
<template>
<div class="content-['src/LazyRoute.vue']">Lazy</div>
</template>
`,
},
},
async ({ spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['src/App.vue']`)
expect(styles).toContain(candidate`content-['src/LazyRoute.vue']`)
})
// Load `main.ts` and `App.vue` as real modules, like a browser visiting
// the page would
await fetch(`${url}/src/main.ts`)
await fetch(`${url}/src/App.vue`)
// Changing the scanned but unloaded `.vue` file should not trigger a
// full reload, but new classes should still apply
await fs.write(
'src/LazyRoute.vue',
html`
<template>
<div class="content-['updated:src/LazyRoute.vue']">Lazy</div>
</template>
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/LazyRoute.vue']`)
})
expect(await fs.read('hmr.log')).not.toContain('full-reload')
},
)

View file

@ -48,7 +48,7 @@
},
"license": "MIT",
"devDependencies": {
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.62.1",
"@types/node": "catalog:",
"postcss": "catalog:",
"postcss-import": "^16.1.1",
@ -56,9 +56,9 @@
"prettier-plugin-embed": "^0.5.1",
"prettier-plugin-organize-imports": "^4.3.0",
"tsup": "^8.5.1",
"turbo": "^2.9.18",
"turbo": "^2.10.8",
"typescript": "^5.9.3",
"vitest": "^4.1.7"
"vitest": "^4.1.10"
},
"packageManager": "pnpm@11.9.0"
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/browser",
"version": "4.3.2",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"main": "./dist/index.global.js",
@ -31,7 +31,7 @@
},
"devDependencies": {
"h3": "^1.15.11",
"listhen": "^1.10.0",
"listhen": "^1.10.1",
"tailwindcss": "workspace:*"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/cli",
"version": "4.3.2",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": {
@ -29,7 +29,7 @@
"access": "public"
},
"dependencies": {
"@parcel/watcher": "2.5.1",
"@parcel/watcher": "catalog:",
"@tailwindcss/node": "workspace:*",
"@tailwindcss/oxide": "workspace:*",
"enhanced-resolve": "catalog:",

View file

@ -1,4 +1,3 @@
import watcher from '@parcel/watcher'
import {
compile,
env,
@ -21,11 +20,13 @@ import {
highlight,
println,
relative,
wordWrap,
} from '../../utils/renderer'
import { drainStdin, outputFile } from './utils'
const css = String.raw
const DEBUG = env.DEBUG
const DEFAULT_POLL_INTERVAL_MS = 250
export function options() {
return {
@ -47,6 +48,12 @@ export function options() {
alias: '-w',
values: ['always'],
},
'--poll': {
type: 'boolean | number',
description: 'Use polling instead of filesystem events when watching',
default: false,
values: ['ms'],
},
'--minify': {
type: 'boolean',
description: 'Optimize and minify the output',
@ -73,17 +80,44 @@ export function options() {
} satisfies Arg
}
function formatError(error: unknown): string {
let seen = new Set() // Track seen errors to prevent circular errors
function render(err: unknown, depth: number): string[] {
let indent = ' '.repeat(depth)
let width = (process.stderr.columns ?? Infinity) - 2 * (depth + 1)
let output = [
`${indent}${red(depth === 0 ? 'Error:' : 'Caused by:')}`,
`${indent}${dim('\u250C')}`,
]
for (let line of `${err}`.split('\n')) {
let wrapped = wordWrap(line, width)
if (wrapped.length === 0) wrapped = ['']
for (let chunk of wrapped) {
output.push(`${indent}${dim('\u2502')} ${chunk}`)
}
}
output.push(`${indent}${dim('\u2514')}`)
if (typeof err === 'object' && err !== null && 'cause' in err && err.cause != null) {
if (!seen.add(err).has(err.cause)) {
output.push(...render(err.cause, depth + 1))
}
}
return output
}
return render(error, 0).join('\n')
}
async function handleError<T>(fn: () => T): Promise<T> {
try {
return await fn()
} catch (err) {
eprintln(
[red('Error:'), dim('\u250C')]
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
.concat(dim('\u2514'))
.join('\n'),
)
eprintln(formatError(err))
process.exit(1)
}
}
@ -129,7 +163,17 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// If the user passes `{bin} build --map -` then this likely means they want to output the map inline
// this is the default behavior of `{bin build} --map` to inform the user of that
if (args['--map'] === '-') {
eprintln(`Use --map without a value to inline the source map`)
eprintln(`Use --map without a value to inline the source map.`)
process.exit(1)
}
if (args['--poll'] === undefined) {
eprintln(`Use --poll with a non-zero value in milliseconds.`)
process.exit(1)
}
let pollInterval = args['--poll'] === true ? DEFAULT_POLL_INTERVAL_MS : args['--poll']
if (pollInterval !== false && pollInterval <= 0) {
eprintln(`Specified polling interval must be a positive number.`)
process.exit(1)
}
@ -152,6 +196,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let previous = {
css: '',
optimizedCss: '',
output: null as string | null,
}
async function write(
@ -208,13 +253,18 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
}
}
let didChange = output !== previous.output
DEBUG && I.start('Write output')
if (args['--output'] && args['--output'] !== '-') {
await outputFile(args['--output'], output)
} else {
} else if (didChange) {
println(output)
}
DEBUG && I.end('Write output')
previous.output = output
return didChange
}
let inputFilePath =
@ -275,10 +325,14 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
}
let [compiler, scanner] = await handleError(() => createCompiler(input, I))
let cleanupWatchers: (() => Promise<void>)[] = []
// Watch for changes
if (args['--watch']) {
let cleanupWatchers: (() => Promise<void>)[] = []
if (args['--watch'] && pollInterval === false) {
// Ensure the file watcher can be loaded before setting up any watchers,
// such that we can present a helpful error message if needed.
await handleError(() => loadWatcher())
cleanupWatchers.push(
await createWatchers(await watchDirectories(scanner), async function handle(files) {
try {
@ -292,36 +346,15 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Re-compile the input
let start = process.hrtime.bigint()
let changedFiles: ChangedContent[] = []
let rebuildStrategy: 'incremental' | 'full' = 'incremental'
let resolvedFullRebuildPaths = fullRebuildPaths
for (let file of files) {
// If one of the changed files is related to the input CSS or JS
// config/plugin files, then we need to do a full rebuild because
// the theme might have changed.
if (resolvedFullRebuildPaths.includes(file)) {
rebuildStrategy = 'full'
// No need to check the rest of the events, because we already know we
// need to do a full rebuild.
break
}
// Track new and updated files for incremental rebuilds.
changedFiles.push({
file,
extension: path.extname(file).slice(1),
} satisfies ChangedContent)
}
let rebuildStrategy = getRebuildStrategy(files, resolvedFullRebuildPaths)
// Track the compiled CSS
let compiledCss = ''
let compiledMap: SourceMap | null = null
// Scan the entire `base` directory for full rebuilds.
if (rebuildStrategy === 'full') {
if (rebuildStrategy.kind === 'full') {
// Read the new `input`.
let input = args['--input']
? args['--input'] === '-'
@ -343,7 +376,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Create a new compiler, given the new `input`
;[compiler, scanner] = await createCompiler(input, I)
// Succesfully created a new compiler, so the `fullRebuildPaths`
// Successfully created a new compiler, so the `fullRebuildPaths`
// will be updated. If other errors occur, we should be able to
// restore the paths unconditionally.
backupRebuildPaths = fullRebuildPaths.slice()
@ -378,9 +411,9 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
}
// Scan changed files only for incremental rebuilds.
else if (rebuildStrategy === 'incremental') {
else if (rebuildStrategy.kind === 'incremental') {
DEBUG && I.start('Scan for candidates')
let newCandidates = scanner.scanFiles(changedFiles)
let newCandidates = scanner.scanFiles(rebuildStrategy.changedFiles)
DEBUG && I.end('Scan for candidates')
// No new candidates found which means we don't need to write to
@ -443,12 +476,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Catch any errors and print them to stderr, but don't exit the process
// and keep watching.
eprintln(
[red('Error:'), dim('\u250C')]
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
.concat(dim('\u2514'))
.join('\n'),
)
eprintln(formatError(err))
let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
@ -490,9 +518,184 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
// Watch for changes, using polling
if (args['--watch'] && pollInterval !== false) {
let cleanupPollingIndicator = () => {}
function logPollingMessage(message: string) {
if (!args['--silent']) {
process.stderr.write('\r\x1B[2K')
}
eprintln(message)
if (!args['--silent']) {
process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`)
}
}
async function fullRebuild(I: Instrumentation) {
clearRequireCache(fullRebuildPaths)
backupRebuildPaths = fullRebuildPaths.slice()
fullRebuildPaths = inputFilePath ? [inputFilePath] : []
let input = args['--input']
? args['--input'] === '-'
? await drainStdin()
: await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`
;[compiler, scanner] = await createCompiler(input, I)
backupRebuildPaths = fullRebuildPaths.slice()
DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan()
DEBUG && I.end('Scan for candidates')
DEBUG && I.start('Build CSS')
let output = compiler.build(candidates)
DEBUG && I.end('Build CSS')
let map: SourceMap | null = null
if (args['--map']) {
DEBUG && I.start('Build Source Map')
map = toSourceMap(compiler.buildSourceMap())
DEBUG && I.end('Build Source Map')
}
await write(output, map, args, I)
}
if (!args['--silent']) {
let restored = false
function restoreCursor() {
if (restored) return
restored = true
process.stderr.write('\r\x1B[2K\x1B[?25h')
process.off('exit', restoreCursor)
process.off('SIGINT', onSigint)
process.off('SIGTERM', onSigterm)
}
function onSigint() {
restoreCursor()
process.exit(130)
}
function onSigterm() {
restoreCursor()
process.exit(143)
}
process.stderr.write('\x1B[?25l')
process.on('exit', restoreCursor)
process.on('SIGINT', onSigint)
process.on('SIGTERM', onSigterm)
cleanupPollingIndicator = restoreCursor
cleanupWatchers.push(async () => cleanupPollingIndicator())
}
cleanupWatchers.push(
createPollingWatcher(async () => {
using I = new Instrumentation()
DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan()
DEBUG && I.end('Scan for candidates')
let files = scanner.scannedFiles.filter(
(file) => file !== args['--output'] && file !== args['--map'],
)
if (files.length <= 0) return
let start = process.hrtime.bigint()
let strategy = getRebuildStrategy(files, fullRebuildPaths)
if (strategy.kind === 'full') {
try {
await fullRebuild(I)
} catch (err) {
fullRebuildPaths = backupRebuildPaths
logPollingMessage(formatError(err))
}
if (!args['--silent']) {
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
}
return
}
// No candidates found which means we don't need to write to disk, and
// can return early.
if (candidates.length <= 0) {
if (!args['--silent']) {
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
}
return
}
DEBUG && I.start('Build CSS')
let output = compiler.build(candidates)
DEBUG && I.end('Build CSS')
let map: SourceMap | null = null
if (args['--map']) {
DEBUG && I.start('Build Source Map')
map = toSourceMap(compiler.buildSourceMap())
DEBUG && I.end('Build Source Map')
}
await write(output, map, args, I)
if (!args['--silent']) {
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
}
}, pollInterval),
)
if (args['--watch'] !== 'always') {
process.stdin.on('end', () => {
Promise.all(cleanupWatchers.map((fn) => fn())).then(
() => process.exit(0),
() => process.exit(1),
)
})
}
process.stdin.resume()
if (!args['--silent']) {
process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`)
}
}
}
// Load `@parcel/watcher` lazily so a missing or broken native binding only
// affects `--watch` (without `--poll`), instead of crashing one-off builds and
// polling mode as well.
async function loadWatcher(): Promise<typeof import('@parcel/watcher')> {
try {
return (await import('@parcel/watcher')).default
} catch (err) {
throw new Error(
`Failed to load the file watcher. Your platform may not be supported by \`@parcel/watcher\`. As a workaround, you can use polling instead by passing the \`--watch --poll\` flags.`,
{ cause: err },
)
}
}
async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
let watcher = await loadWatcher()
// Remove any directories that are children of an already watched directory.
// If we don't we may not get notified of certain filesystem events regardless
// of whether or not they are for the directory that is duplicated.
@ -594,6 +797,56 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
}
}
function getRebuildStrategy(
files: string[],
fullRebuildPaths: string[],
): { kind: 'incremental'; changedFiles: ChangedContent[] } | { kind: 'full' } {
let changedFiles: ChangedContent[] = []
for (let file of files) {
// If one of the changed files is related to the input CSS or JS
// config/plugin files, then we need to do a full rebuild because
// the theme might have changed.
if (fullRebuildPaths.includes(file)) {
return { kind: 'full' }
}
// Track new and updated files for incremental rebuilds.
changedFiles.push({
file,
extension: path.extname(file).slice(1),
} satisfies ChangedContent)
}
return { kind: 'incremental', changedFiles }
}
function createPollingWatcher(cb: () => Promise<void>, pollInterval: number) {
let disposed = false
let timer: ReturnType<typeof setTimeout> | null = null
async function poll() {
if (disposed) return
try {
await cb()
} catch (err) {
console.error(err)
} finally {
if (!disposed) {
timer = setTimeout(poll, pollInterval)
}
}
}
timer = setTimeout(poll, pollInterval)
return async () => {
disposed = true
if (timer) clearTimeout(timer)
}
}
async function watchDirectories(scanner: Scanner) {
let directories = (
await Promise.all(

View file

@ -11,7 +11,7 @@ const sharedOptions = {
} satisfies Arg
function buildUsage(command = 'tailwindcss') {
return `${command} [--input input.css] [--output output.css] [--watch] [options…]`
return `${command} [--input input.css] [--output output.css] [--watch] [--poll=ms] [options…]`
}
function rootHelp({ invalid }: { invalid?: string } = {}) {

View file

@ -153,3 +153,34 @@ it('should be possible to provide multiple types, and convert the value to that
}
`)
})
it('should be possible to provide boolean or number arguments', () => {
let options = {
'--poll': {
type: 'boolean | number',
description: 'Use polling instead of filesystem events when watching',
default: false,
},
} satisfies Arg
expect(args(options, ['--poll'])).toMatchInlineSnapshot(`
{
"--poll": true,
"_": [],
}
`)
expect(args(options, ['--poll=50'])).toMatchInlineSnapshot(`
{
"--poll": 50,
"_": [],
}
`)
expect(args(options, ['--poll=foo'])).toMatchInlineSnapshot(`
{
"--poll": undefined,
"_": [],
}
`)
})

View file

@ -49,7 +49,9 @@ export function wordWrap(text: string, width: number) {
for (let word of words) {
let wordLength = stripVTControlCharacters(word).length
if (lineLength + wordLength + 1 > width) {
// A word longer than `width` is kept on its own line rather than
// introducing an empty line before it.
if (lineLength > 0 && lineLength + wordLength + 1 > width) {
lines.push(line)
line = ''
lineLength = 0

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/node",
"version": "4.3.2",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": {
@ -57,7 +57,7 @@
"enhanced-resolve": "catalog:",
"jiti": "^2.7.0",
"lightningcss": "catalog:",
"magic-string": "^0.30.21",
"magic-string": "^1.1.0",
"source-map-js": "^1.2.1",
"tailwindcss": "workspace:*"
}

View file

@ -61,17 +61,14 @@ export function optimize(
map = result.map?.toString()
result.warnings = result.warnings.filter((warning) => {
// Ignore warnings about unknown pseudo-classes as they are likely caused
// by the use of `:deep()`, `:slotted()`, and `:global()` which are not
// standard CSS but are commonly used in frameworks like Vue.
if (/'(deep|slotted|global)' is not recognized as a valid pseudo-/.test(warning.message)) {
return false
}
// Ignore warnings about unknown at-rules.
//
// TODO: drop `@position-try` once https://github.com/parcel-bundler/lightningcss/pull/1238 lands
if (/Unknown at rule: @position-try/.test(warning.message)) {
// Ignore warnings about unknown pseudo-classes that are used in frameworks
// such as Vue or Angular and are handled by their own compilers, before
// reaching the browser.
if (
/'(deep|slotted|global|ng-deep|host-context)' is not recognized as a valid pseudo-/.test(
warning.message,
)
) {
return false
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/postcss",
"version": "4.3.2",
"version": "4.3.3",
"description": "PostCSS plugin for Tailwind CSS, a utility-first CSS framework for rapidly building custom user interfaces",
"license": "MIT",
"repository": {

View file

@ -12,8 +12,8 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = `
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
--color-black: #000;
@ -44,7 +44,7 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = `
-webkit-text-size-adjust: 100%;
tab-size: 4;
line-height: 1.5;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
@ -110,7 +110,7 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = `
border-collapse: collapse;
}
:-moz-focusring {
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}

View file

@ -20,6 +20,16 @@ function inputCssFilePath() {
const css = dedent
async function run(plugin: any, from: string, input: string): Promise<string> {
let ast = postcss.parse(input)
for (let runner of plugin.plugins) {
if (runner.Once) {
await runner.Once(ast, { postcss, result: { opts: { from }, messages: [] } })
}
}
return ast.toString()
}
test("`@import 'tailwindcss'` is replaced with the generated CSS", async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
@ -422,17 +432,7 @@ describe('concurrent builds', () => {
let plugin = tailwindcss({ optimize: { minify: false } })
async function run(input: string): Promise<string> {
let ast = postcss.parse(input)
for (let runner of (plugin as any).plugins) {
if (runner.Once) {
await runner.Once(ast, { postcss, result: { opts: { from }, messages: [] } })
}
}
return ast.toString()
}
let result = await run(input)
let result = await run(plugin, from, input)
expect(result).toContain('.underline')
@ -448,14 +448,51 @@ describe('concurrent builds', () => {
`,
)
let promise1 = run(input)
let promise2 = run(input)
let promise1 = run(plugin, from, input)
let promise2 = run(plugin, from, input)
expect(await promise1).toContain('.red')
expect(await promise2).toContain('.red')
})
})
test('rebuilds when the input CSS changes even if the `from` file on disk did not', async () => {
let dir = await mkdtemp(path.join(tmpdir(), 'tw-postcss'))
// The `from` file exists on disk with a stable mtime. The input CSS is handed
// to the plugin directly (as a preprocessor like Sass would produce it), so it
// can change without `from`'s mtime ever changing.
await writeFile(path.join(dir, 'index.css'), '')
let from = path.join(dir, 'index.css')
let plugin = tailwindcss({ base: dir, optimize: { minify: false } })
let first = await run(
plugin,
from,
css`
@tailwind utilities;
.first {
@apply underline;
}
`,
)
let second = await run(
plugin,
from,
css`
@tailwind utilities;
.second {
@apply line-through;
}
`,
)
expect(first).toContain('.first')
expect(second).toContain('.second')
await rm(dir, { recursive: true, force: true })
})
test('does not register the input file as a dependency, even if it is passed in as relative path', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),

View file

@ -20,6 +20,7 @@ const DEBUG = env.DEBUG
interface CacheEntry {
mtimes: Map<string, number>
inputCss: string
compiler: null | ReturnType<typeof compileAst>
scanner: null | Scanner
tailwindCssAst: AstNode[]
@ -34,6 +35,7 @@ function getContextFromCache(postcss: Postcss, inputFile: string, opts: PluginOp
if (cache.has(key)) return cache.get(key)!
let entry = {
mtimes: new Map<string, number>(),
inputCss: '',
compiler: null,
scanner: null,
@ -194,6 +196,16 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
rebuildStrategy = 'full'
context.mtimes.set(file, changedTime)
}
// The mtimes above only track files on disk. When the input CSS is
// generated by another tool (e.g. Sass) and passed to `process()`,
// it can change without any tracked file's mtime changing, so also
// rebuild when the input CSS itself differs from the last build.
let inputCss = root.source?.input.css ?? root.toString()
if (context.inputCss !== inputCss) {
rebuildStrategy = 'full'
context.inputCss = inputCss
}
}
DEBUG && I.end('Register full rebuild paths')

View file

@ -56,6 +56,7 @@ describe('fixRelativePathsPlugin', () => {
@plugin './plugin-in-sibling.ts';
@plugin '../plugin-in-sibling.ts';
@plugin 'plugin-in-sibling';
@plugin './plugin-in-root.ts';
@plugin '../plugin-in-root.ts';
@plugin 'plugin-in-root';

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/standalone",
"version": "4.3.2",
"version": "4.3.3",
"private": true,
"description": "Standalone CLI for Tailwind CSS",
"license": "MIT",
@ -35,13 +35,13 @@
},
"__notes": "These binary packages must be included so Bun can build the CLI for all supported platforms. We also rely on Lightning CSS and Parcel being patched so Bun can statically analyze the executables.",
"devDependencies": {
"@parcel/watcher-darwin-arm64": "^2.5.6",
"@parcel/watcher-darwin-x64": "^2.5.6",
"@parcel/watcher-linux-arm64-glibc": "^2.5.6",
"@parcel/watcher-linux-arm64-musl": "^2.5.6",
"@parcel/watcher-linux-x64-glibc": "^2.5.6",
"@parcel/watcher-linux-x64-musl": "^2.5.6",
"@parcel/watcher-win32-x64": "^2.5.6",
"@parcel/watcher-darwin-arm64": "catalog:",
"@parcel/watcher-darwin-x64": "catalog:",
"@parcel/watcher-linux-arm64-glibc": "catalog:",
"@parcel/watcher-linux-arm64-musl": "catalog:",
"@parcel/watcher-linux-x64-glibc": "catalog:",
"@parcel/watcher-linux-x64-musl": "catalog:",
"@parcel/watcher-win32-x64": "catalog:",
"@types/bun": "^1.3.14",
"bun": "^1.3.14",
"lightningcss-darwin-arm64": "catalog:",

View file

@ -6,6 +6,5 @@ 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>)
undefined | ((path: string, encoding: BufferEncoding) => Promise<string | undefined>)
declare var __tw_load: undefined | ((path: string) => Promise<object | undefined>)

View file

@ -0,0 +1,99 @@
<p align="center">
<a href="https://tailwindcss.com" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tailwindlabs/tailwindcss/HEAD/.github/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/tailwindlabs/tailwindcss/HEAD/.github/logo-light.svg">
<img alt="Tailwind CSS" src="https://raw.githubusercontent.com/tailwindlabs/tailwindcss/HEAD/.github/logo-light.svg" width="350" height="70" style="max-width: 100%;">
</picture>
</a>
</p>
<p align="center">
A utility-first CSS framework for rapidly building custom user interfaces.
</p>
<p align="center">
<a href="https://github.com/tailwindlabs/tailwindcss/actions"><img src="https://img.shields.io/github/actions/workflow/status/tailwindlabs/tailwindcss/ci.yml?branch=main" alt="Build Status"></a>
<a href="https://www.npmjs.com/package/tailwindcss"><img src="https://img.shields.io/npm/dt/tailwindcss.svg" alt="Total Downloads"></a>
<a href="https://github.com/tailwindlabs/tailwindcss/releases"><img src="https://img.shields.io/npm/v/tailwindcss.svg" alt="Latest Release"></a>
<a href="https://github.com/tailwindlabs/tailwindcss/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/tailwindcss.svg" alt="License"></a>
</p>
---
## Documentation
For full documentation, visit [tailwindcss.com](https://tailwindcss.com).
## Community
For help, discussion about best practices, or feature ideas:
[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions)
## Contributing
If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**.
---
## @tailwindcss/turbopack
A Turbopack loader for Tailwind CSS v4.
## Installation
```sh
npm install @tailwindcss/turbopack
```
### Usage
```javascript
// next.config.js
module.exports = {
turbopack: {
rules: {
'*.css': {
loaders: ['@tailwindcss/turbopack'],
as: '*.css',
},
},
},
}
```
Then create a CSS file that imports Tailwind:
```css
/* src/index.css */
@import 'tailwindcss';
```
### Options
#### `base`
The base directory to scan for class candidates. Defaults to the current working directory.
```javascript
{
loader: '@tailwindcss/turbopack',
options: {
base: process.cwd(),
},
}
```
#### `optimize`
Whether to optimize and minify the output CSS. Defaults to `true` in production mode.
```javascript
{
loader: '@tailwindcss/turbopack',
options: {
optimize: true, // or { minify: true }
},
}
```

View file

@ -0,0 +1,43 @@
{
"name": "@tailwindcss/turbopack",
"version": "4.3.3",
"description": "A Turbopack loader for Tailwind CSS v4.",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tailwindlabs/tailwindcss.git",
"directory": "packages/@tailwindcss-turbopack"
},
"bugs": "https://github.com/tailwindlabs/tailwindcss/issues",
"homepage": "https://tailwindcss.com",
"scripts": {
"build": "tsup-node",
"dev": "pnpm run build -- --watch"
},
"files": [
"dist/"
],
"publishConfig": {
"provenance": true,
"access": "public"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "workspace:*",
"@tailwindcss/oxide": "workspace:*",
"tailwindcss": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"webpack": "catalog:"
}
}

View file

@ -0,0 +1,5 @@
import tailwindLoader from './index.ts'
// CommonJS export for webpack loaders - must be the function directly
// @ts-ignore
export = tailwindLoader

View file

@ -0,0 +1,287 @@
import QuickLRU from '@alloc/quick-lru'
import {
compile,
env,
Features,
Instrumentation,
normalizePath,
optimize,
Polyfills,
} from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import fs from 'node:fs'
import path from 'node:path'
import type { LoaderContext } from 'webpack'
const DEBUG = env.DEBUG
export interface LoaderOptions {
/**
* The base directory to scan for class candidates.
*
* Defaults to the current working directory.
*/
base?: string
/**
* Optimize and minify the output CSS.
*/
optimize?: boolean | { minify?: boolean }
}
interface CacheEntry {
mtimes: Map<string, number>
compiler: null | Awaited<ReturnType<typeof compile>>
scanner: null | Scanner
candidates: Set<string>
fullRebuildPaths: string[]
}
const cache = new QuickLRU<string, CacheEntry>({ maxSize: 50 })
function getCacheKey(resourceId: string, opts: LoaderOptions): string {
return `${resourceId}:${opts.base ?? ''}:${JSON.stringify(opts.optimize)}`
}
function getContextFromCache(resourceId: string, opts: LoaderOptions): CacheEntry {
let key = getCacheKey(resourceId, opts)
if (cache.has(key)) return cache.get(key)!
let entry: CacheEntry = {
mtimes: new Map<string, number>(),
compiler: null,
scanner: null,
candidates: new Set<string>(),
fullRebuildPaths: [],
}
cache.set(key, entry)
return entry
}
export default async function tailwindLoader(
this: LoaderContext<LoaderOptions>,
source: string,
): Promise<void> {
let callback = this.async()
let options = this.getOptions() ?? {}
let inputFile = this.resourcePath
let resourceId = this.resource
let base = options.base ?? process.cwd()
let shouldOptimize = options.optimize ?? process.env.NODE_ENV === 'production'
let isCSSModuleFile = inputFile.endsWith('.module.css')
using I = new Instrumentation()
DEBUG && I.start(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`)
// Bail out early if this is guaranteed to be a non-Tailwind CSS file.
{
DEBUG && I.start('Quick bail check')
let canBail = !/@(import|reference|theme|variant|config|plugin|apply|tailwind)\b/.test(source)
if (canBail) {
DEBUG && I.end('Quick bail check')
DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`)
callback(null, source)
return
}
DEBUG && I.end('Quick bail check')
}
try {
let context = getContextFromCache(resourceId, options)
let inputBasePath = path.dirname(path.resolve(inputFile))
// Whether this is the first build or not
let isInitialBuild = context.compiler === null
async function createCompiler() {
DEBUG && I.start('Setup compiler')
if (context.fullRebuildPaths.length > 0 && !isInitialBuild) {
clearRequireCache(context.fullRebuildPaths)
}
context.fullRebuildPaths = []
DEBUG && I.start('Create compiler')
let compiler = await compile(source, {
from: inputFile,
base: inputBasePath,
shouldRewriteUrls: true,
onDependency: (depPath) => context.fullRebuildPaths.push(depPath),
// In CSS Module files, we have to disable the `@property` polyfill since these will
// emit global `*` rules which are considered to be non-pure and will cause builds
// to fail.
polyfills: isCSSModuleFile ? Polyfills.All ^ Polyfills.AtProperty : Polyfills.All,
})
DEBUG && I.end('Create compiler')
DEBUG && I.end('Setup compiler')
return compiler
}
// Setup the compiler if it doesn't exist yet
context.compiler ??= await createCompiler()
// Early exit if no Tailwind features are used
if (context.compiler.features === Features.None) {
DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`)
callback(null, source)
return
}
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
// Track file modification times to CSS files
DEBUG && I.start('Register full rebuild paths')
{
// Report dependencies for config files, plugins, etc.
for (let file of context.fullRebuildPaths) {
this.addDependency(path.resolve(file))
}
let files = [...context.fullRebuildPaths, inputFile]
for (let file of files) {
let changedTime: number | null = null
try {
changedTime = fs.statSync(file)?.mtimeMs ?? null
} catch {
// File might not exist
}
if (changedTime === null) {
if (file === inputFile) {
rebuildStrategy = 'full'
}
continue
}
let prevTime = context.mtimes.get(file)
if (prevTime === changedTime) continue
rebuildStrategy = 'full'
context.mtimes.set(file, changedTime)
}
}
DEBUG && I.end('Register full rebuild paths')
if (rebuildStrategy === 'full' && !isInitialBuild) {
context.compiler = await createCompiler()
}
let compiler = context.compiler
// Check if we need to process this file at all
if (
!(
compiler.features &
(Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities)
)
) {
DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`)
callback(null, source)
return
}
// Setup or update scanner if needed
if (context.scanner === null || rebuildStrategy === 'full') {
DEBUG && I.start('Setup scanner')
let sources = (() => {
// Disable auto source detection
if (compiler.root === 'none') {
return []
}
// No root specified, use the base directory
if (compiler.root === null) {
return [{ base, pattern: '**/*', negated: false }]
}
// Use the specified root
return [{ ...compiler.root, negated: false }]
})().concat(compiler.sources)
context.scanner = new Scanner({ sources })
DEBUG && I.end('Setup scanner')
}
// Scan for candidates if utilities are used
if (compiler.features & Features.Utilities) {
DEBUG && I.start('Scan for candidates')
for (let candidate of context.scanner.scan()) {
context.candidates.add(candidate)
}
DEBUG && I.end('Scan for candidates')
DEBUG && I.start('Register dependency messages')
// Add all found files as direct dependencies
let resolvedInputFile = path.resolve(base, inputFile)
for (let file of context.scanner.files) {
let absolutePath = path.resolve(file)
// The CSS file cannot be a dependency of itself
if (absolutePath === resolvedInputFile) {
continue
}
this.addDependency(absolutePath)
}
// Register context dependencies for glob patterns
for (let glob of context.scanner.globs) {
// Skip negated patterns
if (glob.pattern[0] === '!') continue
// Avoid adding a dependency on the base directory itself
if (glob.pattern === '*' && base === glob.base) {
continue
}
this.addContextDependency(path.resolve(glob.base))
}
// Validate that source(...) paths are directories
let root = compiler.root
if (root !== 'none' && root !== null) {
let basePath = normalizePath(path.resolve(root.base, root.pattern))
try {
let stats = fs.statSync(basePath)
if (!stats.isDirectory()) {
throw new Error(
`The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`,
)
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err
}
// Directory doesn't exist yet, which is fine
}
}
DEBUG && I.end('Register dependency messages')
}
DEBUG && I.start('Build utilities')
let css = compiler.build([...context.candidates])
DEBUG && I.end('Build utilities')
// Optionally optimize the output
let result = css
if (shouldOptimize) {
DEBUG && I.start('Optimization')
let optimized = optimize(css, {
minify: typeof shouldOptimize === 'object' ? shouldOptimize.minify : true,
})
result = optimized.code
DEBUG && I.end('Optimization')
}
DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`)
callback(null, result)
} catch (error) {
// Clear the cache entry on error to force a full rebuild next time
let key = getCacheKey(resourceId, options)
cache.delete(key)
DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`)
callback(error as Error)
}
}

View file

@ -0,0 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"verbatimModuleSyntax": false,
},
}

View file

@ -0,0 +1,19 @@
import { defineConfig } from 'tsup'
export default defineConfig([
{
format: ['esm'],
clean: true,
minify: true,
cjsInterop: true,
dts: true,
entry: ['src/index.ts'],
},
{
format: ['cjs'],
minify: true,
cjsInterop: true,
dts: true,
entry: ['src/index.cts'],
},
])

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/upgrade",
"version": "4.3.2",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": {
@ -31,7 +31,7 @@
"@tailwindcss/oxide": "workspace:*",
"dedent": "catalog:",
"enhanced-resolve": "catalog:",
"globby": "^16.2.0",
"globby": "^16.2.2",
"jiti": "^2.7.0",
"mri": "^1.2.0",
"picocolors": "^1.1.1",
@ -39,14 +39,14 @@
"postcss-import": "^16.1.1",
"postcss-selector-parser": "^7.1.4",
"prettier": "catalog:",
"semver": "^7.8.4",
"semver": "^7.8.5",
"tailwindcss": "workspace:*",
"tree-sitter": "^0.22.4",
"tree-sitter": "^0.25.1",
"tree-sitter-typescript": "^0.23.2"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/postcss-import": "^14.0.3",
"@types/semver": "^7.7.1"
"@types/semver": "^7.8.0"
}
}

View file

@ -4,11 +4,14 @@ import postcss, { type Result } from 'postcss'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { Stylesheet, type StylesheetConnection } from '../../stylesheet'
import { gitRoot } from '../../utils/git'
import { error, highlight, relative } from '../../utils/renderer'
import { resolveCssId } from '../../utils/resolve'
export async function analyze(stylesheets: Stylesheet[]) {
let isIgnored = await isGitIgnored()
export async function analyze(stylesheets: Stylesheet[], { base }: { base: string }) {
// Use the project's git root, instead of the `base` to ensure all
// `.gitignore` rules are being used.
let isIgnored = await isGitIgnored({ cwd: gitRoot(base) ?? base })
let processingQueue: (() => Promise<Result>)[] = []
let stylesheetsByFile = new DefaultMap<string, Stylesheet | null>((file) => {
// We don't want to process ignored files (like node_modules)

View file

@ -769,6 +769,7 @@ describe('comments', () => {
).toMatchInlineSnapshot(`
"
/* Start of utilities: */
@utility foo {
/* Utility #1 */
/* Declarations: */
@ -793,6 +794,7 @@ describe('comments', () => {
).toMatchInlineSnapshot(`
"
/* Start of utilities: */
@utility foo {
/* Foo & Bar */
& .bar {
@ -800,6 +802,7 @@ describe('comments', () => {
color: red;
}
}
@utility bar {
/* Foo & Bar */
.foo & {
@ -829,6 +832,7 @@ describe('comments', () => {
).toMatchInlineSnapshot(`
"
/* Start of utilities: */
@utility foo {
/* Mobile only */
@media (width <= 640px) {
@ -878,6 +882,7 @@ describe('comments', () => {
).toMatchInlineSnapshot(`
"
/* Tailwind Utilities: */
@utility no-scrollbar {
/* Chrome, Safari and Opera */
/* Second comment */

View file

@ -16,8 +16,8 @@ export function migrateArbitraryVariants(
rawCandidate: string,
): string {
let designSystem = prepareDesignSystemStorage(baseDesignSystem)
let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY]
let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY]
let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY].get(null)
let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY].get(null)
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
// We are only interested in the variants
@ -35,7 +35,11 @@ export function migrateArbitraryVariants(
if (typeof targetSignature !== 'string') continue
let foundVariants = variants.get(targetSignature)
if (foundVariants.length !== 1) continue
if (foundVariants.length === 0) continue
// The variant is already in a canonical form, e.g.: `min-lg` and `lg`
// produce the same CSS, but both are valid so we keep them as-is.
if (foundVariants.includes(targetString)) continue
let foundVariant = foundVariants[0]
let parsedVariant = designSystem.parseVariant(foundVariant)

View file

@ -19,7 +19,7 @@ import { prepareConfig } from './codemods/template/prepare-config'
import { help } from './commands/help'
import { Stylesheet } from './stylesheet'
import { args, type Arg } from './utils/args'
import { isRepoDirty } from './utils/git'
import { gitRoot, isRepoDirty } from './utils/git'
import { pkg } from './utils/packages'
import { eprintln, error, header, highlight, info, relative, success } from './utils/renderer'
import * as version from './utils/version'
@ -43,7 +43,9 @@ if (flags['--help']) {
async function run() {
let base = process.cwd()
let isIgnored = await isGitIgnored({ cwd: base })
// Use the project's git root, instead of the `base` to ensure all
// `.gitignore` rules are being used.
let isIgnored = await isGitIgnored({ cwd: gitRoot(base) ?? base })
eprintln(header())
eprintln()
@ -125,7 +127,7 @@ async function run() {
// Analyze the stylesheets
try {
await analyzeStylesheets(stylesheets)
await analyzeStylesheets(stylesheets, { base })
} catch (e: any) {
error(`${e?.message ?? e}`, { prefix: '↳ ' })
}

View file

@ -1,5 +1,17 @@
import { execSync } from 'node:child_process'
export function gitRoot(cwd?: string): string | null {
try {
return execSync('git rev-parse --show-toplevel', {
encoding: 'utf-8',
cwd,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
} catch {
return null
}
}
export function isRepoDirty(cwd?: string) {
try {
let stdout = execSync('git status --porcelain', { encoding: 'utf-8', cwd })

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/vite",
"version": "4.3.2",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": {

View file

@ -9,16 +9,9 @@ import {
} from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import { realpathSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import type {
Environment,
InternalResolveOptions,
Plugin,
ResolvedConfig,
ViteDevServer,
} from 'vite'
import type { Environment, InternalResolveOptions, Plugin, ResolvedConfig } from 'vite'
import * as vite from 'vite'
const DEBUG = env.DEBUG
@ -72,7 +65,6 @@ function createCustomResolver(
}
export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
let servers: ViteDevServer[] = []
let config: ResolvedConfig | null = null
let rootsByEnv = new DefaultMap<string, Map<string, Root>>((env: string) => new Map())
@ -188,10 +180,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
name: '@tailwindcss/vite:scan',
enforce: 'pre',
configureServer(server) {
servers.push(server)
},
async configResolved(_config) {
config = _config
isSSR = config.build.ssr !== false && config.build.ssr !== undefined
@ -248,85 +236,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
return result
},
},
hotUpdate({ file, modules, timestamp, server }) {
// Ensure full-reloads are triggered for files that are being watched by
// Tailwind but aren't part of the module graph (like PHP or HTML
// files). If we don't do this, then changes to those files won't
// trigger a reload at all since Vite doesn't know about them.
{
// It's a little bit confusing, because due to the `addWatchFile`
// calls, it _is_ part of the module graph but nothing is really
// handling those files. These modules typically have an id of
// undefined and/or have a type of 'asset'.
//
// If we call `addWatchFile` on a file that is part of the actual
// module graph, then we will see a module for it with a type of `js`
// and a type of `asset`. We are only interested if _all_ of them are
// missing an id and/or have a type of 'asset', which is a strong
// signal that the changed file is not being handled by Vite or any of
// the plugins.
//
// Note: in Vite v7.0.6 the modules here will have a type of `js`, not
// 'asset'. But it will also have a `HARD_INVALIDATED` state and will
// do a full page reload already.
//
// Empty modules can be skipped since it means it's not `addWatchFile`d and thus irrelevant to Tailwind.
let isExternalFile =
modules.length > 0 &&
modules.every((mod) => mod.type === 'asset' || mod.id === undefined)
if (!isExternalFile) return
// Skip if the module exists in other environments. SSR framework has
// its own server side hmr/reload mechanism when handling server
// only modules. See https://v6.vite.dev/guide/migration.html
// > Updates to an SSR-only module no longer triggers a full page reload in the client. ...
for (let environment of Object.values(server.environments)) {
if (environment.name === this.environment.name) continue
let modules = environment.moduleGraph.getModulesByFile(file)
if (modules) {
for (let module of modules) {
if (module.type !== 'asset') {
return
}
}
}
}
for (let env of new Set([this.environment.name, 'client'])) {
let roots = rootsByEnv.get(env)
if (roots.size === 0) continue
// If the file is not being watched by any of the roots, then we can
// skip the reload since it's not relevant to Tailwind CSS.
if (!isScannedFile(file, modules, roots)) {
continue
}
// https://vite.dev/changes/hotupdate-hook#migration-guide
let invalidatedModules = new Set<vite.EnvironmentModuleNode>()
for (let mod of modules) {
this.environment.moduleGraph.invalidateModule(
mod,
invalidatedModules,
timestamp,
true,
)
}
if (env === this.environment.name) {
this.environment.hot.send({ type: 'full-reload' })
} else if (server.hot.send) {
server.hot.send({ type: 'full-reload' })
} else if (server.ws.send) {
server.ws.send({ type: 'full-reload' })
}
return []
}
}
},
},
{
@ -447,10 +356,6 @@ class Root {
private customJsResolver: (id: string, base: string) => Promise<string | false | undefined>,
) {}
get scannedFiles() {
return this.scanner?.files ?? []
}
// Generate the CSS for the root file. This can return false if the file is
// not considered a Tailwind root. When this happened, the root can be GCed.
public async generate(
@ -636,54 +541,3 @@ class Root {
return false
}
}
function isScannedFile(
file: string,
modules: vite.EnvironmentModuleNode[],
roots: Map<string, Root>,
) {
let seen = new Set()
let q = [...modules]
let checks = {
file,
get realpath() {
try {
let realpath = realpathSync(file)
Object.defineProperty(checks, 'realpath', { value: realpath })
return realpath
} catch {
return null
}
},
}
while (q.length > 0) {
let module = q.shift()!
if (seen.has(module)) continue
seen.add(module)
if (module.id) {
let root = roots.get(module.id)
if (root) {
// If the file is part of the scanned files for this root, then we know
// for sure that it's being watched by any of the Tailwind CSS roots. It
// doesn't matter which root it is since it's only used to know whether
// we should trigger a full reload or not.
if (
root.scannedFiles.includes(checks.file) ||
(checks.realpath && root.scannedFiles.includes(checks.realpath))
) {
return true
}
}
}
// Keep walking up the tree until we find a root.
for (let importer of module.importers) {
q.push(importer)
}
}
return false
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/webpack",
"version": "4.3.2",
"version": "4.3.3",
"description": "A webpack loader for Tailwind CSS v4.",
"license": "MIT",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "tailwindcss",
"version": "4.3.2",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": {
@ -132,7 +132,7 @@
"@types/node": "catalog:",
"dedent": "catalog:",
"lightningcss": "catalog:",
"magic-string": "^0.30.21",
"magic-string": "^1.1.0",
"source-map-js": "^1.2.1"
}
}

View file

@ -32,8 +32,13 @@ html,
tab-size: 4; /* 3 */
font-family: --theme(
--default-font-family,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
'Noto Sans',
Arial,
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
@ -170,7 +175,7 @@ table {
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}

View file

@ -132,8 +132,8 @@ exports[`compiling CSS > prefix all CSS variables inside preflight 1`] = `
"
@layer theme {
:root, :host {
--tw-font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--tw-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--tw-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
--tw-default-font-family: var(--tw-font-sans);
@ -160,7 +160,7 @@ exports[`compiling CSS > prefix all CSS variables inside preflight 1`] = `
-webkit-text-size-adjust: 100%;
tab-size: 4;
line-height: 1.5;
font-family: var(--tw-default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-family: var(--tw-default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--tw-default-font-feature-settings, normal);
font-variation-settings: var(--tw-default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
@ -226,7 +226,7 @@ exports[`compiling CSS > prefix all CSS variables inside preflight 1`] = `
border-collapse: collapse;
}
:-moz-focusring {
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,11 @@ describe('parse', () => {
'[ data-foo ]',
{ attribute: 'data-foo', operator: null, quote: null, value: null, sensitivity: null },
],
[
// Non-ASCII code points are valid in a CSS ident
'[data-état]',
{ attribute: 'data-état', operator: null, quote: null, value: null, sensitivity: null },
],
[
'[data-state=expanded]',
{ attribute: 'data-state', operator: '=', quote: null, value: 'expanded', sensitivity: null },

View file

@ -23,6 +23,7 @@ const ZERO = 48
const NINE = 57
const DASH = 45
const UNDERSCORE = 95
const NON_ASCII = 128
interface AttributeSelector {
attribute: string
@ -61,6 +62,8 @@ export function parse(input: string): AttributeSelector | null {
if (currentChar >= LOWER_A && currentChar <= LOWER_Z) continue
if (currentChar >= ZERO && currentChar <= NINE) continue
if (currentChar === DASH || currentChar === UNDERSCORE) continue
// Non-ASCII code points are valid in a CSS ident, e.g.: `[data-état]`
if (currentChar >= NON_ASCII) continue
break
}

View file

@ -516,6 +516,8 @@ it('should not parse functional utilities with multiple modifiers', () => {
utilities.functional('bg', () => [])
expect(run('bg-red-1/2/3', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-red-500/50/', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-red-500/50//foo', { utilities })).toMatchInlineSnapshot(`[]`)
})
it('should parse a utility with an arbitrary value', () => {
@ -1416,6 +1418,19 @@ it('should parse a functional variant with a modifier', () => {
`)
})
it('should not parse variants with multiple modifiers', () => {
let utilities = new Utilities()
utilities.static('flex', () => [])
let variants = new Variants()
variants.static('hover', () => {})
variants.compound('group', Compounds.StyleRules, () => {})
expect(run('group-hover/foo:flex', { utilities, variants })).toHaveLength(1)
expect(run('group-hover/foo/:flex', { utilities, variants })).toEqual([])
expect(run('group-hover/foo//bar:flex', { utilities, variants })).toEqual([])
})
it('should parse a functional variant starting with @', () => {
let utilities = new Utilities()
utilities.static('flex', () => [])

View file

@ -372,6 +372,14 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
}
}
// If there's more than one modifier, the utility is invalid.
//
// E.g.:
//
// - `bg-red-500/50/50`
let parts = segment(base, '/')
if (parts.length > 2) return
// Figure out the new base and the modifier segment if present.
//
// E.g.:
@ -381,14 +389,7 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
// ^^^^^^^^^^ -> Base without modifier
// ^^ -> Modifier segment
// ```
let [baseWithoutModifier, modifierSegment = null, additionalModifier] = segment(base, '/')
// If there's more than one modifier, the utility is invalid.
//
// E.g.:
//
// - `bg-red-500/50/50`
if (additionalModifier) return
let [baseWithoutModifier, modifierSegment = null] = parts
let parsedModifier = modifierSegment === null ? null : parseModifier(modifierSegment)
@ -707,17 +708,18 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
// Static, functional and compound variants
{
// group-hover/group-name
// ^^^^^^^^^^^ -> Variant without modifier
// ^^^^^^^^^^ -> Modifier
let [variantWithoutModifier, modifier = null, additionalModifier] = segment(variant, '/')
// If there's more than one modifier, the variant is invalid.
//
// E.g.:
//
// - `group-hover/foo/bar`
if (additionalModifier) return null
let parts = segment(variant, '/')
if (parts.length > 2) return null
// group-hover/group-name
// ^^^^^^^^^^^ -> Variant without modifier
// ^^^^^^^^^^ -> Modifier
let [variantWithoutModifier, modifier = null] = parts
let roots = findRoots(variantWithoutModifier, (root) => {
return designSystem.variants.has(root)
@ -1096,7 +1098,7 @@ const printArbitraryValueCache = new DefaultMap<string, string>((input) => {
node.value = ','
}
// Wrap custom functions starting with `--`, in parentheses if preceeded by
// Wrap custom functions starting with `--`, in parentheses if preceded by
// a symbol. E.g.: `calc(100%---spacing(2))` → `calc(100%-(--spacing(2)))`
else if (node.kind === 'function' && node.value.startsWith('--')) {
let idx = ctx.index

View file

@ -316,6 +316,10 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
['[color:var(--color-red-500)]', 'text-red-500'],
['[background-color:var(--color-red-500)]', 'bg-red-500'],
// Arbitrary property to named utility (case insensitive)
['[color:#fff]', 'text-white'],
['[color:#FFF]', 'text-white'],
// Arbitrary property with modifier to named functional utility with modifier
['[color:var(--color-red-500)]/25', 'text-red-500/25'],
@ -354,6 +358,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
--*: initial;
--spacing: 0.25rem;
--color-red-500: red;
--color-white: #fff;
/* Equivalent of blue-500/50 */
--color-primary: color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent);
@ -394,6 +399,10 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
['bg-[theme(colors.red.500/75%)]', 'bg-red-500/75'],
['bg-[theme(colors.red.500/12.34%)]', 'bg-red-500/[12.34%]'],
// Arbitrary value with different casing
['bg-[#fff]', 'bg-white'],
['bg-[#FFF]', 'bg-white'],
// Values that don't contain only `theme(…)` calls should not be converted to
// use a modifier since the color is not the whole value.
[
@ -405,9 +414,11 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
// This should still migrate the `theme(…)` syntax to the modern syntax.
['bg-[theme(colors.red.500/50%)]/50', 'bg-[--theme(--color-red-500/50%)]/50'],
// Variants, we can't use `var(…)` especially inside of `@media(…)`. We can
// still upgrade the `theme(…)` to the modern syntax.
['max-[theme(screens.lg)]:flex', 'max-[--theme(--breakpoint-lg)]:flex'],
// Variants, we can't use `var(…)` especially inside of `@media(…)`. But
// since the value matches the `lg` breakpoint exactly, we can migrate to
// the `max-lg` variant.
['max-[theme(screens.lg)]:flex', 'max-lg:flex'],
['min-[theme(screens.lg)]:flex', 'lg:flex'], // We could also map to `min-lg:flex`
// There are no variables for `--spacing` multiples, so we can't convert this
['max-[theme(spacing.4)]:flex', 'max-[theme(spacing.4)]:flex'],
@ -478,6 +489,9 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
])(testName, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
@theme {
--color-white: #fff;
}
`
await expectCanonicalization(input, candidate, expected)
@ -707,6 +721,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
@import 'tailwindcss';
@theme {
--*: initial;
--breakpoint-lg: 64rem;
}
`
@ -723,6 +738,11 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
['[@media(scripting:_none)]:flex', 'noscript:flex'],
['[@media_(scripting:_none)]:flex', 'noscript:flex'],
// Arbitrary variants with media queries cannot use `var(…)`, but if an
// equivalent variant exists we can use that one instead.
['[@media(width>=theme(screens.lg))]:flex', 'lg:flex'], // `min-lg:flex` would also be valid
['[@media(width<theme(screens.lg))]:flex', 'max-lg:flex'],
// With compound variants
['has-[&:focus]:flex', 'has-focus:flex'],
['not-[&:focus]:flex', 'not-focus:flex'],
@ -939,6 +959,67 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
})
})
// https://github.com/tailwindlabs/tailwindcss/issues/20365
describe('media query and container query variants', () => {
test.each([
// Breakpoints (`--breakpoint-lg: 64rem`, `--breakpoint-md: 48rem`)
['min-[64rem]:flex', 'lg:flex'],
['max-[64rem]:flex', 'max-lg:flex'],
['min-[48rem]:flex', 'md:flex'],
['max-[48rem]:flex', 'max-md:flex'],
// With the `rem` option set, equivalent `px` values should also be
// canonicalized to the breakpoint variants.
['min-[1024px]:flex', 'lg:flex'],
['max-[1024px]:flex', 'max-lg:flex'],
// Container queries (`--container-md: 28rem`)
['@min-[28rem]:flex', '@md:flex'],
['@max-[28rem]:flex', '@max-md:flex'],
['@min-[448px]:flex', '@md:flex'],
['@max-[448px]:flex', '@max-md:flex'],
// Values that don't map to a known breakpoint or container size should
// stay as-is.
['min-[123px]:flex', 'min-[123px]:flex'],
['max-[123px]:flex', 'max-[123px]:flex'],
['@min-[123px]:flex', '@min-[123px]:flex'],
['@max-[123px]:flex', '@max-[123px]:flex'],
// Named variants that produce the same CSS should be kept as-is
['min-lg:flex', 'min-lg:flex'],
['@min-md:flex', '@min-md:flex'],
])(testName, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
`
await expectCanonicalization(input, candidate, expected)
})
test.each([
// Without the `rem` option, `px` values cannot be safely converted to
// `rem` based breakpoints.
['min-[1024px]:flex', 'min-[1024px]:flex'],
['@min-[448px]:flex', '@min-[448px]:flex'],
// Same unit still works without the `rem` option
['min-[64rem]:flex', 'lg:flex'],
['max-[64rem]:flex', 'max-lg:flex'],
['@min-[28rem]:flex', '@md:flex'],
['@max-[28rem]:flex', '@max-md:flex'],
])(`${testName} (no rem option)`, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
`
await expectCanonicalization(input, candidate, expected, {
collapse: true,
logicalToPhysical: true,
})
})
})
describe('modernize arbitrary variants', () => {
test.each([
// Arbitrary variants
@ -1508,4 +1589,75 @@ describe('regressions', () => {
expect(designSystem.canonicalizeCandidates(['px-[calc(1rem+0px)]'], options)).toEqual(['px-4'])
})
// https://github.com/tailwindlabs/tailwindcss/issues/20295
test('canonicalizations work when casing of arbitrary values is different', async () => {
let designSystem = await designSystems.get(__dirname).get(css`
@import 'tailwindcss';
@theme {
--color-brand-purple: #3f3cbb;
}
`)
let options: CanonicalizeOptions = {
collapse: true,
logicalToPhysical: true,
rem: 16,
}
expect(designSystem.canonicalizeCandidates(['bg-[#3F3cbb]'], options)).toEqual([
'bg-brand-purple',
])
expect(designSystem.canonicalizeCandidates(['bg-[#3F3Cbb]'], options)).toEqual([
'bg-brand-purple',
])
expect(designSystem.canonicalizeCandidates(['bg-[#3F3CBB]'], options)).toEqual([
'bg-brand-purple',
])
})
// https://github.com/tailwindlabs/tailwindcss/issues/20365
test('canonicalizes min/max media query and container query variants', async () => {
let designSystem = await __unstable__loadDesignSystem(
css`
@tailwind utilities;
@theme {
--breakpoint-lg: 64rem;
--container-lg: 32rem;
--spacing: 0.25rem;
}
`,
{ base: __dirname },
)
expect(designSystem.canonicalizeCandidates(['min-[64rem]:flex'])).toEqual(['lg:flex'])
expect(designSystem.canonicalizeCandidates(['max-[64rem]:flex'])).toEqual(['max-lg:flex'])
expect(designSystem.canonicalizeCandidates(['@min-[32rem]:flex'])).toEqual(['@lg:flex'])
expect(designSystem.canonicalizeCandidates(['@max-[32rem]:flex'])).toEqual(['@max-lg:flex'])
expect(designSystem.canonicalizeCandidates(['min-[1024px]:flex'], { rem: 16 })).toEqual([
'lg:flex',
])
})
// https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1610
test('does not merge utilities whose theme variables resolve to CSS-wide keywords', async () => {
let designSystem = await __unstable__loadDesignSystem(
css`
@tailwind utilities;
@theme {
--foreground: unset;
--default: unset;
}
@theme inline {
--color-foreground: var(--foreground);
--color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent);
}
`,
{ base: __dirname },
)
expect(
designSystem.canonicalizeCandidates(['text-foreground/60', 'text-default-soft-hover']),
).toEqual(['text-foreground/60', 'text-default-soft-hover'])
})
})

View file

@ -72,12 +72,11 @@ interface InternalCanonicalizeOptions {
signatureOptions: SignatureOptions
}
type RemValue = number | null
interface DesignSystem extends BaseDesignSystem {
storage: {
[SIGNATURE_OPTIONS_KEY]: DefaultMap<
number | null, // Rem value
DefaultMap<SignatureFeatures, SignatureOptions>
>
[SIGNATURE_OPTIONS_KEY]: DefaultMap<RemValue, DefaultMap<SignatureFeatures, SignatureOptions>>
[COMPARE_CANDIDATES_KEY]: DefaultMap<
SignatureOptions,
(a: Candidate | string, b: Candidate | string) => boolean
@ -107,8 +106,8 @@ interface DesignSystem extends BaseDesignSystem {
DefaultMap<string, DefaultMap<string, Set<string>>>
>
[PRE_COMPUTED_UTILITIES_KEY]: DefaultMap<SignatureOptions, DefaultMap<string, string[]>>
[VARIANT_SIGNATURE_KEY]: DefaultMap<string, string | symbol>
[PRE_COMPUTED_VARIANTS_KEY]: DefaultMap<string, string[]>
[VARIANT_SIGNATURE_KEY]: DefaultMap<RemValue, DefaultMap<string, string | symbol>>
[PRE_COMPUTED_VARIANTS_KEY]: DefaultMap<RemValue, DefaultMap<string, string[]>>
}
}
@ -1537,8 +1536,8 @@ function arbitraryVariants(
options: InternalCanonicalizeOptions,
): Variant | Variant[] {
let designSystem = options.designSystem
let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY]
let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY]
let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY].get(options.signatureOptions.rem)
let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY].get(options.signatureOptions.rem)
let iterator = walkVariants(variant)
for (let [variant] of iterator) {
@ -1549,7 +1548,11 @@ function arbitraryVariants(
if (typeof targetSignature !== 'string') continue
let foundVariants = variants.get(targetSignature)
if (foundVariants.length !== 1) continue
if (foundVariants.length === 0) continue
// The variant is already in a canonical form, e.g.: `min-lg` and `lg`
// produce the same CSS, but both are valid so we keep them as-is.
if (foundVariants.includes(targetString)) continue
let foundVariant = foundVariants[0]
let parsedVariant = designSystem.parseVariant(foundVariant)
@ -1769,7 +1772,7 @@ function modernizeArbitraryValuesVariant(
): Variant | Variant[] {
let result = [variant]
let designSystem = options.designSystem
let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY]
let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY].get(options.signatureOptions.rem)
let iterator = walkVariants(variant)
for (let [variant, parent] of iterator) {
@ -2425,6 +2428,12 @@ function createUtilitySignatureCache(
})
}
// #RGB
// #RGBA
// #RRGGBB
// #RRGGBBAA
const HEX_REGEX = /#(?:[a-f0-9]{8}|[a-f0-9]{6}|[a-f0-9]{4}|[a-f0-9]{3})/gi
// Optimize the CSS AST to make it suitable for signature comparison. We want to
// expand declarations, ignore comments, sort declarations etc...
function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: SignatureOptions) {
@ -2536,12 +2545,43 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
return a.property.localeCompare(b.property)
})
}
//
else if (node.kind === 'declaration' && node.value) {
// Leave CSS variables alone
if (node.property[0] === '-' && node.property[1] === '-') return
// Ensure hex colors are always lowercased
{
HEX_REGEX.lastIndex = 0
node.value = node.value.replace(HEX_REGEX, (color) => color.toLowerCase())
}
}
},
})
return ast
}
// Variables whose theme value is a CSS-wide keyword (e.g.: `unset`) are never
// inlined. These are typically registered as a placeholder to be re-assigned at
// runtime, so two variables that share such a value are not interchangeable.
//
// E.g.:
//
// ```css
// @theme {
// --foreground: unset;
// --background: unset;
// }
// ```
//
// Inlining would make `text-(--foreground)` and `text-(--background)` produce
// the same signature `color: unset`, even though they are different at runtime.
//
// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/Data_types#css-wide_keywords
const CSS_WIDE_KEYWORDS = ['initial', 'inherit', 'revert', 'revert-layer', 'revert-rule', 'unset']
// Resolve theme values to their inlined value.
//
// E.g.:
@ -2557,8 +2597,8 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
// }
// ```
//
// Which conveniently will be equivalent to: `text-red-500` when we inline
// the value.
// Which conveniently will be equivalent to: `text-red-500` when we inline the
// value.
//
// Without inlining:
// ```css
@ -2574,13 +2614,13 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
// }
// ```
//
// Recently we made sure that utilities like `text-red-500` also generate
// the fallback value for usage in `@reference` mode.
// Recently we made sure that utilities like `text-red-500` also generate the
// fallback value for usage in `@reference` mode.
//
// The second assumption is that if you use `var(--key, fallback)` that
// happens to match a known variable _and_ its inlined value. Then we can
// replace it with the inlined variable. This allows us to handle custom
// `@theme` and `@theme inline` definitions.
// The second assumption is that if you use `var(--key, fallback)` that happens
// to match a known variable _and_ its inlined value. Then we can replace it
// with the inlined variable. This allows us to handle custom `@theme` and
// `@theme inline` definitions.
function resolveVariablesInValue(value: string, designSystem: DesignSystem): string {
let changed = false
let valueAst = ValueParser.parse(value)
@ -2609,6 +2649,9 @@ function resolveVariablesInValue(value: string, designSystem: DesignSystem): str
seen.add(variable)
if (variableValue === undefined) return // Couldn't resolve the variable
// CSS-wide keywords are never inlined
if (CSS_WIDE_KEYWORDS.includes(variableValue.toLowerCase())) return
// Inject variable fallbacks when no fallback is present yet.
//
// A fallback could consist of multiple values.
@ -2767,100 +2810,110 @@ export const VARIANT_SIGNATURE_KEY = Symbol()
function createVariantSignatureCache(
designSystem: DesignSystem,
): DesignSystem['storage'][typeof VARIANT_SIGNATURE_KEY] {
return new DefaultMap<string, string | symbol>((variant) => {
try {
// Ensure the prefix is added to the utility if it is not already present.
variant =
designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix)
? `${designSystem.theme.prefix}:${variant}`
: variant
return new DefaultMap((rem: number | null) => {
return new DefaultMap<string, string | symbol>((variant) => {
try {
// Ensure the prefix is added to the utility if it is not already present.
variant =
designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix)
? `${designSystem.theme.prefix}:${variant}`
: variant
// Use `@apply` to normalize the selector to `.x`
let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])]
substituteAtApply(ast, designSystem)
// Use `@apply` to normalize the selector to `.x`
let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])]
substituteAtApply(ast, designSystem)
// Canonicalize selectors to their minimal form
walk(ast, (node) => {
// At-rules
if (node.kind === 'at-rule' && node.params.includes(' ')) {
node.params = node.params.replaceAll(' ', '')
}
// Canonicalize selectors to their minimal form
walk(ast, (node) => {
// At-rules
if (node.kind === 'at-rule') {
// Normalize dimensions such that equivalent values in different
// units are considered the same. E.g.: with a root font size of
// `16px`, `@media (width >= 64rem)` and `@media (width >= 1024px)`
// are equivalent.
node.params = constantFoldDeclaration(node.params, rem)
// Style rules
else if (node.kind === 'rule') {
let selectorAst = SelectorParser.parse(node.selector)
let changed = false
walk(selectorAst, (node) => {
// Assumption: when we have a list of selectors: `.foo, .bar` we
// want to mark this as changed because this will be re-printed as
// `.foo,.bar`.
//
// Similarly, when we receive a combinator like `.foo + .bar`, this
// will be printed as `.foo+.bar`.
//
// It could be that this was already optimal, but then this would be
// a no-op situation.
if (node.kind === 'list' || node.kind === 'combinator') {
changed = true
if (node.params.includes(' ')) {
node.params = node.params.replaceAll(' ', '')
}
}
// Remove unnecessary `:is(…)` selectors
else if (node.kind === 'function' && node.value === ':is') {
// A single selector inside of `:is(…)` can be replaced with the
// selector itself.
// Style rules
else if (node.kind === 'rule') {
let selectorAst = SelectorParser.parse(node.selector)
let changed = false
walk(selectorAst, (node) => {
// Assumption: when we have a list of selectors: `.foo, .bar` we
// want to mark this as changed because this will be re-printed as
// `.foo,.bar`.
//
// E.g.: `:is(.foo)` → `.foo`
if (node.nodes.length === 1) {
// Similarly, when we receive a combinator like `.foo + .bar`, this
// will be printed as `.foo+.bar`.
//
// It could be that this was already optimal, but then this would be
// a no-op situation.
if (node.kind === 'list' || node.kind === 'combinator') {
changed = true
return WalkAction.Replace(node.nodes)
}
// A selector with the universal selector `*` followed by a pseudo
// class, can be replaced with the pseudo class itself.
// Remove unnecessary `:is(…)` selectors
else if (node.kind === 'function' && node.value === ':is') {
// A single selector inside of `:is(…)` can be replaced with the
// selector itself.
//
// E.g.: `:is(.foo)` → `.foo`
if (node.nodes.length === 1) {
changed = true
return WalkAction.Replace(node.nodes)
}
// A selector with the universal selector `*` followed by a pseudo
// class, can be replaced with the pseudo class itself.
else if (
node.nodes.length === 2 &&
node.nodes[0].kind === 'selector' &&
node.nodes[0].value === '*' &&
node.nodes[1].kind === 'selector' &&
node.nodes[1].value[0] === ':'
) {
changed = true
return WalkAction.Replace(node.nodes[1])
}
}
// Ensure `*` exists before pseudo selectors inside of `:not(…)`,
// `:where(…)`, …
//
// E.g.:
//
// `:not(:first-child)` → `:not(*:first-child)`
//
else if (
node.nodes.length === 2 &&
node.nodes[0].kind === 'selector' &&
node.nodes[0].value === '*' &&
node.nodes[1].kind === 'selector' &&
node.nodes[1].value[0] === ':'
node.kind === 'function' &&
node.value[0] === ':' &&
node.nodes[0]?.kind === 'selector' &&
node.nodes[0]?.value[0] === ':'
) {
changed = true
return WalkAction.Replace(node.nodes[1])
node.nodes.unshift({ kind: 'selector', value: '*' })
}
}
})
// Ensure `*` exists before pseudo selectors inside of `:not(…)`,
// `:where(…)`, …
//
// E.g.:
//
// `:not(:first-child)` → `:not(*:first-child)`
//
else if (
node.kind === 'function' &&
node.value[0] === ':' &&
node.nodes[0]?.kind === 'selector' &&
node.nodes[0]?.value[0] === ':'
) {
changed = true
node.nodes.unshift({ kind: 'selector', value: '*' })
if (changed) {
node.selector = SelectorParser.toCss(selectorAst, true)
}
})
if (changed) {
node.selector = SelectorParser.toCss(selectorAst, true)
}
}
})
})
// Compute the final signature, by generating the CSS for the variant
let signature = toCss(ast)
return signature
} catch {
// A unique symbol is returned to ensure that 2 signatures resulting in
// `null` are not considered equal.
return Symbol()
}
// Compute the final signature, by generating the CSS for the variant
let signature = toCss(ast)
return signature
} catch {
// A unique symbol is returned to ensure that 2 signatures resulting in
// `null` are not considered equal.
return Symbol()
}
})
})
}
@ -2868,19 +2921,31 @@ export const PRE_COMPUTED_VARIANTS_KEY = Symbol()
function createPreComputedVariantsCache(
designSystem: DesignSystem,
): DesignSystem['storage'][typeof PRE_COMPUTED_VARIANTS_KEY] {
let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY]
let lookup = new DefaultMap<string, string[]>(() => [])
return new DefaultMap((rem: number | null) => {
let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY].get(rem)
let lookup = new DefaultMap<string, string[]>(() => [])
// Actual static variants
for (let [root, variant] of designSystem.variants.entries()) {
if (variant.kind === 'static') {
let signature = signatures.get(root)
if (typeof signature !== 'string') continue
lookup.get(signature).push(root)
for (let [root, variant] of designSystem.variants.entries()) {
// Actual static variants
if (variant.kind === 'static') {
let signature = signatures.get(root)
if (typeof signature !== 'string') continue
lookup.get(signature).push(root)
}
// Functional variants with known values, e.g.: `max-lg`, `@min-md`, …
else if (variant.kind === 'functional') {
for (let value of designSystem.variants.getCompletions(root)) {
let name = root === '@' ? `@${value}` : `${root}-${value}`
let signature = signatures.get(name)
if (typeof signature !== 'string') continue
lookup.get(signature).push(name)
}
}
}
}
return lookup
return lookup
})
}
function temporarilyDisableThemeInline<T>(designSystem: DesignSystem, cb: () => T): T {

View file

@ -309,6 +309,19 @@ function upgradeToFullPluginSupport({
let resolvedValue = sharedPluginApi.theme(path, undefined)
// If we're dealing with an object that has the `DEFAULT` key, unwrap it to
// the default value first. This happens for namespace root lookups where
// the CSS theme defines a bare value, e.g. `--shadow` resolved via
// `theme('shadow')`.
if (
typeof resolvedValue === 'object' &&
resolvedValue !== null &&
!Array.isArray(resolvedValue) &&
'DEFAULT' in resolvedValue
) {
resolvedValue = resolvedValue.DEFAULT
}
// When a tuple is returned, return the first element
if (Array.isArray(resolvedValue) && resolvedValue.length === 2) {
return resolvedValue[0]
@ -319,16 +332,6 @@ function upgradeToFullPluginSupport({
return resolvedValue.join(', ')
}
// If we're dealing with an object that has the `DEFAULT` key, return the
// default value
else if (
typeof resolvedValue === 'object' &&
resolvedValue !== null &&
'DEFAULT' in resolvedValue
) {
return resolvedValue.DEFAULT
}
// Otherwise only allow string values here, objects (and namespace maps)
// are treated as non-resolved values for the CSS `theme()` function.
else if (typeof resolvedValue === 'string') {

Some files were not shown because too many files have changed in this diff Show more