Compare commits

...

189 commits
v4.2.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
Robin Malfait
056a155072
4.3.2 (#20281) 2026-06-29 10:10:00 -04:00
Robin Malfait
15b4a8c9af
Use version range for PostCSS (#20289)
This PR fixes an issue where new PostCSS release could lead to type
related issues if newer versions change the types.

Right now `@tailwindcss/postcss` uses a hardcoded PostCSS version. Let's
loosen this up and use a semver range instead.

Fixes: #20288

## Test plan

1. All tests still pass
2026-06-29 15:43:46 +02:00
Robin Malfait
c8b081d963
Add suggestions for named opacity modifiers (#20287)
This PR re-enables suggestions for named opacity modifiers that can be
configured via the `--opacity-*` namespace.

Before we launched v4, we got rid of this feature:
https://github.com/tailwindlabs/tailwindcss/pull/14278 &
https://github.com/tailwindlabs/tailwindcss/pull/14339
But later we re-added the feature, without updating intellisense
suggestions: https://github.com/tailwindlabs/tailwindcss/pull/15009

This PR re-adds suggestions for utilities that read from the
`--opacity-*` namespace for named modifiers. A lot of utilities use some
internal abstraction that get this for free once we use the
`colorUtility` setup, but some utilities don't use these, and I had to
add the `--opacity` modifier theme key manually.

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

## Test plan

1. Update intellisense related tests now that we read from the
`--opacity-*` namespace
2. All tests pass

Before:
<img width="1122" height="1376"
alt="file-5462b830c5ebbd6046494713344ff5ff"
src="https://github.com/user-attachments/assets/ab0771c1-da8d-4179-b19d-183c2c567606"
/>


After:

<img width="1122" height="1376"
alt="file-9247e05165f72e8bb974673ee17bd35a"
src="https://github.com/user-attachments/assets/2afdd50f-6579-4a4c-bf9b-8d402fcf1184"
/>

Notice that the `/foo` suggestion is available
2026-06-29 15:35:45 +02:00
Robin Malfait
ba4c23eaff
update changelog 2026-06-26 13:32:56 +02:00
Robin Malfait
0c3554499e
Improve release (#20280)
This PR improves the release script:

- The `node-linker=hoisted` was missing for the `wasm32-wasi` package
(this is because of the pnpm v11 changes where it migrated the
noide-linker setup from `.npmrc` to a flag during `pack`)
- Notify discord in case the release fails

## Test plan

1. All tests should pass because we didn't change anything related to
code
2. Unfortunately, the only way to properly testing this is to merge it
2026-06-26 13:25:38 +02:00
Robin Malfait
94f7907b55
add margin of error to the test
Sometimes this is flaky on CI, even though it doesn't make any sense
because we use `setTimeout(_, 500)` which should _at least_ result in
500ms...

But sometimes this results into:
```
AssertionError: expected 499.76 to be greater than or equal to 500
```
2026-06-25 19:23:36 +02:00
Robin Malfait
38652a4638
migrate to pnpm v11 (#20273)
This PR bumps the repo to pnpm v11 (from v9). 

I kept running into weird Windows specific issues for the integration
tests due to some shims but they all pass right now.

Bumping to pnpm v11 also meant that everything is driven by a
`pnpm-workspace.yaml` file, and changes applied via the `pnpm` field in
the `package.json` don't work anymore.

This also moved the `node-linker` setup that was defined in the `.npmrc`
file for the wasm oxide build into the `pnpm-workspace.yaml` file.
Ideally this is scoped to just this package, but I couldn't get that to
work, so it's applied to all packages right now.

[ci-all]
2026-06-25 19:14:10 +02:00
Robin Malfait
7ff413bad4
Ignore unknown at-rule warnings for @position-try (#20277)
This PR ignores Lightning CSS warnings for the unknow at-rule
`@position-try`.

This is a temporary fix/workaround until support for `@position-try`
lands in Lightning CSS:
https://github.com/parcel-bundler/lightningcss/pull/1238

Fixes: #20275

## Test plan

Ran a test based on the issue:

Before:
```shellsession
❯ tw -i x.css -o out.css --optimize
≈ tailwindcss v4.3.1

Found 1 warning while optimizing generated CSS:

│   position-try-fallbacks: --flip-above;
│ }
│ @position-try --flip-above {
┆              ^-- Unknown at rule: @position-try
┆
│   top: auto;
│   bottom: 0;

Done in 29ms
```

After:
```shellsession
❯ tw -i x.css -o out.css --optimize
≈ tailwindcss v4.3.1

Done in 14ms
```
2026-06-25 13:21:12 +02:00
Robin Malfait
bb6a10937c
use .ts instead of .css
In newer Vite versions this extension matters, without the
vite/resolvers integration tests won't work.

Since this is still a placeholder fake file where Vite will run
`dirname` on, the actual file doesn't really matter as long as it's
relative to this file.
2026-06-25 13:03:10 +02:00
Robin Malfait
d5ca0aeac9
Handle template toolkit %]…[% syntax (#20269)
This PR handles template toolkit syntax as a pre-processor step such
that `%]` and `[%` are seen as valid boundary characters.

This is handled for the `.tt`, `.tt2` and `.tx` file extensions. It's
not handled if this syntax is used in `.html` files because then
everybody pays a pre processor cost even if you don't need this syntax
in most cases.

This now ensures that a `template.tx` like this:
```html
<div class="[% IF $is_open %]bg-white/40[% ELSE %]bg-white/10[% END %]"></div>
<!--                         ^^^^^^^^^^^          ^^^^^^^^^^^              -->
```
Extracts the classes in between those conditions correctly.

This also fixes a small issue related to Maud, a template engine for
Rust where conditionals like `p.text-black[condition]` caused the
`text-black` class not to be extracted. This is fixed as part of this PR
because it was commented on the linked issue.

Fixes: #20233 

## Test plan

1. Added a new extractor
2. Added regression tests
3. All existing tests pass
2026-06-22 15:40:58 +02:00
Robin Malfait
0fee7b55f9
Restrict walking sibling folders when using a pattern (#20263)
This PR fixes an issue where a `@source` pointing to a concrete file
could result in scanning the entire parent folder instead of only
looking for the file we are actually interested in.

When we optimize a `@source`, we move all the static parts of the
pattern to the `base`. This means that a `@source` like this:

```css
@source "../../app.config.ts";
```

Resolves to:

```rs
SourceEntry::Pattern { base: "/Users", pattern: "/app.config.ts" }
```

When walking the `base`, we would only emit an `!app.config.ts` rule.
This means that _everything_ in the `/Users` folder is still walked, and
the result is then thrown away. If you take a look at the `gitignore`
equivalent (which is what we build behind the scenes), then we would
essentially create the following:
```gitignore
!app.config.ts
```

But if you know how `gitignore` files work, then you know that this does
force `app.config.ts` to _not_ be ignored, but it doesn't say anything
about all the other files/folders.

The fix is to restrict the `base` so that we ignore everything in the
folder, and then explicitly re-include only the pattern we care about.
Fixing this would result in the following:
```gitignore
*
!foo.ts
```

In the reproduction from #20255 this takes the build from appearing to
hang (~22s) down to ~20ms on my machine.

There are a few edge cases we have to be careful about:

**Multiple patterns for the same base.** If we have multiple `@source`
directives for the same folder:

```css
@source "./src/foo.ts";
@source "./src/bar.ts";
```

Then blindly emitting `*` for each one would result in this `.gitignore`
equivalent:

```gitignore
*
!foo.ts
*
!bar.ts
```

Notice that the second `*` would end up ignoring `foo.ts` again. To
avoid this, we only emit the `*` rule once per `base`.

**Dynamic parts in intermediate folders.** If the pattern still contains
a `*` in one of its folders:

```css
@source "./src/ba*/*.html";
```

This resolves to:

```rs
SourceEntry::Pattern { base: "/src", pattern: "/ba*/*.html" }
```

If we now inject the `*` rule for `/src`, then we would never walk the
`ba*` folders (e.g. `bar` or `baz`). To fix this, we add inverse rules
for each parent segment of the pattern:

```gitignore
*                     ← ignore everything
!/ba*/                ← except for the `ba*/` folders, so we walk into them
!/ba*/*.html          ← then scan the `*.html` files in them
```

**Bases already covered by a broader source.** If the `base` is already
included (or nested) under an unrestricted source (an `Auto`/`External`
source, or a `Pattern` containing `**`), then we leave it alone.
Restricting it would incorrectly hide siblings that the broader source
is supposed to pick up. For example:

```css
@source "**/*";
@source "./src/components/button.html";
```

Here the `**/*` source should keep auto-detecting every file, so we must
_not_ restrict `src/components` just because there's a more specific
`@source` pointing to it.

Source order is preserved throughout, so later `@source not …` rules can
still override an earlier restricted source.

Fixes: #20255

## Test plan

1. Added unit tests realted to this `sources` logic
2. Added integration like tests for the scanner itself
3. All other tests should pass as-s
4. Should work on each OS [ci-all]
2026-06-19 22:39:57 +02:00
depfu[bot]
c33b85d012
Update all pnpm dependencies (2026-06-19) (#20262)
This is your weekly update of **all** pnpm dependencies. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?

✳️ postcss-selector-parser (7.1.1 → 7.1.4, patch) ·
[Repo](https://github.com/postcss/postcss-selector-parser) ·
[Changelog](https://github.com/postcss/postcss-selector-parser/blob/master/CHANGELOG.md)
·
[Release](https://github.com/postcss/postcss-selector-parser/releases/tag/7.1.4)
·
[Diff](cf6637ed6a...4a7e4e3685)

✳️ semver (7.8.1 → 7.8.4, patch) ·
[Repo](https://github.com/npm/node-semver) ·
[Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md) ·
[Release](https://github.com/npm/node-semver/releases/tag/v7.8.4) ·
[Diff](76416081a8...8640bd68f1)

✳️ turbo (2.9.14 → 2.9.18, patch) ·
[Repo](https://github.com/vercel/turborepo) ·
[Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md) ·
[Release](https://github.com/vercel/turborepo/releases/tag/v2.9.18) ·
[Diff](fc62fe0d9c...3bdce3277d)




---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will only send you the next scheduled PR once
you merge or close this one.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu refresh</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-06-19 13:11:16 +02:00
Robin Malfait
c46f654fa0
Ensure --alpha(…) is seen as a color, and --spacing(…) is seen as a length (#20260)
This PR fixes an issue where intellisense recommends this
canonicalization:

```diff
- text-[calc(var(--spacing)*4)]
+ text-[--spacing(4)]
```

Which is correct, but the issue is that the result is different due to
ambiguity of the `text-*` utilities.

```css
.text-\[calc\(var\(--spacing\)\*4\)\] {
  font-size: calc(var(--spacing) * 4);
}
.text-\[--spacing\(4\)\] {
  color: calc(var(--spacing, 0.25rem) * 4);
}
```

Notice that we're using `color` all of a sudden? This is because that's
the default and we infer the data type based on the arbitrary value. The
`calc(…)` infers that the type is `length`, but we don't know what the
type of `--spacing(…)` is so we fallback to the default type which would
be `color`.

This PR makes sure that built in functions like `--alpha(…)` and
`--spacing(…)` are resolved as `color` and `length` respectively.

With this fix in place, this is the result:
```css
.text-\[calc\(var\(--spacing\)\*4\)\] {
  font-size: calc(var(--spacing) * 4);
}
.text-\[--spacing\(4\)\] {
  font-size: calc(var(--spacing, 0.25rem) * 4);
}
```


Fixes: #20256
Fixes: #20258

## Test plan

1. Added a regression test to make sure this doesn't happen anymore
2026-06-18 15:02:18 +02:00
Robin Malfait
34c2b04a35
Deleting files should not crash Vite HMR (#20259)
This PR fixes an issue where Vite crashes when a file is deleted.

This is because we don't cleanup files/folders in the scanner when they
are being deleted. This results in an issue where we would use Vite's
`addWatchFile` API with deleted files.

This PR fixes that by making sure that when we re-scan the filesystem
that we clear out files/folders (and recompute globs).

Note: `candidates` themselve are still append-only until a fresh
compiler instance is created since the TypeScript based compiler relies
on that behavior.

Fixes: #17532 
Closes: #20111

## Test plan

1. Added an integration test based on the issue
2. Tested it directly in the reproduction repo

Before deleting:
<img width="988" height="1376" alt="image"
src="https://github.com/user-attachments/assets/0f104ff3-0f88-415a-8311-1cf54ab75d21"
/>


Before:
<img width="1389" height="1376"
alt="file-1b446568bfad50e6a8105d7be33f15f0"
src="https://github.com/user-attachments/assets/d2fbf03e-ca22-4bc4-9bb4-f3de10b90b99"
/>
Which results in:
<img width="988" height="1376" alt="image"
src="https://github.com/user-attachments/assets/8320d61c-4210-4aa8-aa8d-98c5327e9604"
/>

After:
<img width="1389" height="1376"
alt="file-c36ab0265856da5bb7c7a1e77427b821"
src="https://github.com/user-attachments/assets/2ec4c4ea-aa79-4a51-900a-e247dcdb976c"
/>
Which results in:
<img width="988" height="1376" alt="image"
src="https://github.com/user-attachments/assets/8e8dec0d-40eb-4e54-b5fd-8d22abd55973"
/>


[ci-all]
2026-06-18 14:29:04 +02:00
Robin Malfait
5e9f66e428
Ensure @variant can be used in JS based APIs (#20252)
This PR doesn't fix any issues, but it does add an integration test (as
a regression test) to make sure that `@variant` with default variants
and custom variants can be used inside of JS based plugin APIs.

In Tailwind CSS v4.3.1 we introduced a PR that handles `@variant` in the
`addBase` Plugin API
(https://github.com/tailwindlabs/tailwindcss/pull/19480). This was a bit
of an older PR, but the tests made sense, so it was merged.

However, by introducing that PR, we introduced a bug that the `@variant`
was handled too early. If you added custom variants later _and_ used it
in the `addBase`, then you would get an error since the variant isn't
available (yet).

That issue was fixed by
https://github.com/tailwindlabs/tailwindcss/pull/20247

Now the question remains, why did we even have the original PR when it
already worked?

The use case we had was using `@variant` as part of the
`@tailwindcss/typography` plugin configuration for one of our templates.
I was indeed able to reproduce the issue where `@variant lg` was seen in
the output CSS file.

Turns out that this template was using `@tailwindcss/typography` +
`@variant` in the configuration, but it was also using Tailwind CSS
v4.1.15.

Upgrading to the latest version automagically fixed the issue we had.
This is also the behavior you can see in the integration test.

The correct behavior was introduced in an even older PR
https://github.com/tailwindlabs/tailwindcss/pull/19263

All that said, everything should work in the next release related to
`@variant` usages inside JS based APIs.


**Tiny improvement**

While debugging what's going on, I noticed that we looped over the AST
to get some nodes out and we did that twice. This PR also improves that
by re-using the same list of nodes instead of computing it twice. This
won't have a huge impact, but it happened while compiling every single
utility which is not ideal.

## Test plan

1. All tests should pass
2. I can't see `@variant` in the output CSS file

Input:
<img width="655" height="323" alt="image"
src="https://github.com/user-attachments/assets/20c8d524-3575-488c-b0c0-5c4669f37dc7"
/>

Before:
<img width="477" height="175" alt="image"
src="https://github.com/user-attachments/assets/045e2086-1d4b-487c-96ff-676351412935"
/>


After:
<img width="484" height="175" alt="image"
src="https://github.com/user-attachments/assets/24d950b1-7c28-40f8-96bc-81b38be0e7a3"
/>
2026-06-17 15:23:20 +02:00
Robin Malfait
707c23b955
Ensure custom variants can be used via @variant in addBase (#20247)
This PR fixes an issue where `@variant` inside `addBase` is being used
with a custom variant.

The issue is that we substitute the `@variant` calls immediately when we
call the `addBase` function. That means that variants that aren't
processed yet will error out.

This is a regression, because this used to work in Tailwind CSS v4.3.0
and started failing in Tailwind CSS v4.3.1.

## Test plan

1. Added a regression test
2026-06-16 23:05:50 +02:00
Robin Malfait
cc3b634fe0
Ensure @tailwindcss/cli watches the input file (#20246)
This PR fixes an issue where if the `--input` file (when using
`@tailwindcss/cli`) lives in an ignored folder, then changes to the
input file won't trigger a rebuild.

This happened in #17632 where the input file lives in the `assets/`
folder which is ignored by default.

However, if you make a change to the input file, then the CLI doesn't
restart which means that you can't update the `@source` directives
without quitting the program and re-starting it manually.

This PR solves that by automatically injecting an `@source` with the
path to the input file. This way the CLI will see changes, even if the
input file is in an ignored directory.

Fixes: #17632

## Test plan

1. Added an integration test
2. Existing tests pass
3. Reproduced it on the 


Before:
<img width="1122" height="1376"
alt="file-295ce4696b6b3199a3915c63f8bb50cd"
src="https://github.com/user-attachments/assets/09e5e965-38d1-474d-bfc6-3b5e9bca73b6"
/>

After:
<img width="1122" height="1376"
alt="file-301b2f2e06aee045957355014ae5de87"
src="https://github.com/user-attachments/assets/2b6d4cba-f456-4138-98bd-2259876fe70a"
/>
2026-06-16 18:38:29 +02:00
Robin Malfait
51f0760d29
Fix crash when using @tailwindcss/vite using deno v2.8.x (#20245)
This PR might fix an issue where resolvers don't work on deno v2.8.x
(they do work on deno v2.7.x) when using `@tailwindcss/vite`.

This happens when the `context.parentURL` is not actually a URL at all,
and therefore the `new URL(…)` just crashes. This PR will fallback to
the incoming result for files that are not passed proper URLs.

Fixes: #20232

## Test plan

1. Existing tests pass
2. Can't seem to figure out how to test this in the reproduction issue
without publishing first. So going to merge this and test the insiders
version instead.
2026-06-16 16:03:27 +02:00
Robin Malfait
127d17033a
Add bare value support for auto-rows-* and auto-cols-* (#20229)
This PR adds bare value support for `auto-rows-*` and `auto-cols-*`.

We first introduced `auto-rows-auto`, `auto-rows-min`, `auto-rows-max`
and `auto-rows-fr` (same for `auto-cols-*`) back in Tailwind CSS v1.9
(https://v1.tailwindcss.com/docs/grid-auto-rows#app) but we haven't
touched it since.

This PR now adds support for bare values that use the spacing scale.
That means that you can now use `auto-rows-12` or `auto-cols-12` which
will result in the following CSS:

```css
.auto-cols-12 {
  grid-auto-columns: calc(var(--spacing) * 12);
}
.auto-rows-12 {
  grid-auto-rows: calc(var(--spacing) * 12);
}
```

We could also add support for percentage based values. The only question
is what the syntax should be. This can either be `auto-rows-3/4` or
`auto-rows-75%`.

For the fraction case, we already have `w-3/4` and `aspect-3/4` even
though they both have a different value as a result:
```css
.aspect-3\/4 {
  aspect-ratio: 3/4;
}
.w-3\/4 {
  width: calc(3 / 4 * 100%);
}
```

We also have some precedence for the `%` value as well, e.g. `via-10%`
for gradient color stops.

I think I would personally towards the `auto-rows-75%` value instead of
`auto-rows-3/4`. The fraction syntax works great for aspect ratio
because that's literally what it is (`aspect-16/9`). The fraction also
works great for widths, because you typically have 2 elements next to
each other:
```html
<div>
  <div class="w-1/3"></div>
  <div class="w-2/3"></div>
</div>
```

Since you only use the `auto-rows-*` once on a parent element, I think
the `auto-rows-75%` makes a bit more sense than `auto-rows-3/4` since
there is no _other_ element (at least not that I can think of).


## TODO

- [ ] Support percentages?
   - [ ] Syntax A: `auto-rows-3/4`
   - [ ] Syntax B: `auto-rows-75%`
   - [ ] Syntax C: support both, but that seems silly

Percentage support isn't a blocker for this PR, since you can always use
`auto-rows-[75%]` if you want

## Test plan

1. Added a test to prove that this works
1. Existing tests still pass

Requested by:
https://github.com/tailwindlabs/tailwindcss/discussions/20225
2026-06-16 11:50:35 +02:00
Robin Malfait
abc4670a9a
Prevent crash when using @tailwindcss/cli using --watch on Windows (#20242)
This PR fixes an issue on Windows where the `@tailwindcss/cli` with the
`--watch` flag crashes when using a `@source` with a base path that
doesn't exist on disk.

This happens when setting up the `@parce/watcher` for directories that
don't exist. This PR essentially filters out these directories that
don't exist on disk to prevent the crash.

It might be that if you add the folder _later_, while the watcher is
already watching, that you have to restart the `@tailwindcss/cli` (or
save the `index.css` (the file that contains the `@source` directives),
this also recreates watchers from scratch).

If this issue causes problems for `@tailwindcss/postcss` and
`@tailwindcss/vite` in the future as well, then we can move this logic
back to Oxide. We do maintain the incoming `@source` files as best as
possible without resolving to absolute paths. Back when we did resolve
them, if that process error'd we just never returned the glob.

The root cause is still referencing folders that don't exist.

Fixes: #20231

## Test plan

1. Added a failing test, that did fail on Windows
2. Once fixed, all tests should pass

[ci-all]

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-15 17:20:31 +02:00
depfu[bot]
d1f9f53bf9
Update all pnpm dependencies (2026-06-15) (#20241)
This is your weekly update of **all** pnpm dependencies. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?

✳️ @emnapi/core (1.10.0 → 1.11.1, minor) ·
[Repo](https://github.com/toyobayashi/emnapi) ·
[Release](https://github.com/toyobayashi/emnapi/releases/tag/v1.11.1) ·
[Diff](ba84999164...6c3668c9af)

✳️ @emnapi/runtime (1.10.0 → 1.11.1, minor) ·
[Repo](https://github.com/toyobayashi/emnapi) ·
[Release](https://github.com/toyobayashi/emnapi/releases/tag/v1.11.1) ·
[Diff](ba84999164...6c3668c9af)

✳️ @tailwindcss/typography (0.5.19 → 0.5.20, minor) ·
[Repo](https://github.com/tailwindlabs/tailwindcss-typography) ·
[Changelog](https://github.com/tailwindlabs/tailwindcss-typography/blob/main/CHANGELOG.md)
·
[Release](https://github.com/tailwindlabs/tailwindcss-typography/releases/tag/v0.5.20)
·
[Diff](e002ab89ad...e3714a3fe5)

✳️ @emnapi/wasi-threads (1.2.1 → 1.2.2, patch) ·
[Repo](https://github.com/toyobayashi/emnapi)




---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will only send you the next scheduled PR once
you merge or close this one.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu refresh</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
</dl></blockquote>
</details>

---------

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-06-15 13:13:32 +00:00
Robin Malfait
8a14a71010
4.3.1 (#20226) 2026-06-12 19:33:35 +02:00
Truffle
522288ca08
Serve ESM type declarations to ESM importers of @tailwindcss/postcss (#20228)
## Summary

Fixes #20219.

The `@tailwindcss/postcss` exports map serves the CJS-shaped
`dist/index.d.ts` (`export = _default`) for both the `import` and
`require` conditions, while the correct ESM declaration file
`dist/index.d.mts` (`export { _default as default }`) is built and
published but never referenced. Type checkers that treat the import
condition as ESM reject the default import with TS1192 — concretely,
`deno check` on Deno 2.8.3+ (which ships TypeScript 6.0) fails on
`import tailwindcss from "@tailwindcss/postcss"`.

This splits the export entry into per-condition `types` blocks so ESM
importers resolve `index.d.mts` and CJS consumers keep `index.d.ts` —
the same shape `@tailwindcss/vite` already uses (`"types":
"./dist/index.d.mts"`).

Two notes on the issue as filed:

- Stock `tsc` with NodeNext does **not** reproduce the error
(declaration file format follows the file extension and package `type`,
not the matched condition), so I didn't take the suggested `export =` →
`export default` change in `index.d.ts` — that would break CJS
consumers, and the correct ESM declarations already ship.
- `@tailwindcss/node` has the same single-`types` exports shape; happy
to follow up there if you want parity.

## Test plan

Against `@tailwindcss/postcss@4.3.0` with the published exports map,
then again with only this `package.json` change applied to the installed
package:

```sh
# Deno 2.8.3 (TypeScript 6.0), nodeModulesDir: auto
deno check main.ts   # before: TS1192 "Module ... index.d.ts has no default export" — after: passes
```

```sh
# typescript@6.0.0-dev (NodeNext), one ESM importer + one .cts require importer
tsc --noEmit         # passes both before and after (no regression for npm consumers)
```

`@arethetypeswrong/cli`:

| | published 4.3.0 | with this change |
|---|---|---|
| node16 (from ESM) | 🎭 Masquerading as CJS | 🟢 (ESM) |
| node16 (from CJS) | 🟢 (CJS) | 🟢 (CJS) |
| bundler | 🟢 | 🟢 |
2026-06-12 14:33:16 +00:00
Robin Malfait
12833aa4b3
Fix canonicalization bug where we end up with a high precision number (#20221)
This PR fixes a bug where we suggest a canonicalization with a high
precision number.

E.g.:

`w-[calc(100%/3.5)]` → `w-[28.571428571428573%]`

While this is technically correct, it's also not as user friendly. This
PR solves this issue by checking whether the result has a precision of
`.xx` at most. If that's not the case, then we keep the original
expression.

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

## Test plan

1. Added an a regression test for this situation
2. Existing tests pass
2026-06-12 00:23:11 +02:00
Rohan Santhosh Kumar
97a5b3abfb
docs: fix double word 'to to' in test comment (#20216)
## Summary

Fixes a double word typo in a test comment: 'No need to to wrap' → 'No
need to wrap'.

## Test plan

This is a comment-only change in a test file. No functional changes.

Co-authored-by: Codex <codex@openai.com>
2026-06-11 12:07:00 +02:00
Robin Malfait
6b0eb5193e
Ensure @source globs ending in **/* preserve dynamic path segments to avoid scanning too many files (#20217)
This PR fixes an issue where we were over-scanning because we lost
pattern information when a `@source` ended in `**/*` and contained other
dynamic parts in the glob pattern.

Noticed this while debugging and working on #20214. Let's say you have
the following:
```css
@source './blog/*/foo/bar/baz/**/*';
```

We make sure that folders or patterns ending in `**/*` are converted to
"auto sources", meaning that auto content detection should be used in
these folders.

However, when doing so, we would only take the `base` path of the glob.
And since we have "dynamic" parts (the `*`) in the pattern, that should
not be the case.

So the `@source` from above, would be turned into:
```rs
SourceEntry::Auto { base = "/Users/projects/project/blog" }
```

Notice that we lose all the information related to `/*/foo/bar/baz/`.
While this would technically still work, it also means that we are
scanning **too many files and folders** because we're only interested in
folders in the `blog` folder that also contain `foo/bar/baz`.

If the `*` wasn't there, then this would be correct, because then we
would've moved the `/blog/foo/bar/baz` part to the `base` ahead of time,
and the pattern would just be `/**/*`. That would result in:
```rs
SourceEntry::Auto { base = "/Users/projects/project/blog/foo/bar/baz" }
```

This PR fixes that, by _not_ converting it to an auto-source, and
instead convert it to a pattern:
```rs
SourceEntry::Pattern {
  base: "/Users/projects/project/blog",
  pattern: "/*/foo/bar/baz/**/*"
}
```

Notice that the static part `blog` is still moved to the base path. But
the rest stays in the `pattern` part as expected.

## Test plan

1. Added a failing test to make sure this doesn't happen anymore
2. Existing tests pass 
3. Tested this on the tailwindcss.com codebase using `@source
"../blog/tailwindcss*/**/*";`

```diff
 diff --git a/./tailwindcss-55526.log b/./tailwindcss-55527.log
index ce79c5da..03e97598 100644
--- a/./tailwindcss-55526.log
+++ b/./tailwindcss-55527.log
@@ -2,50 +2,9 @@ INFO tailwindcss_oxide::scanner: Provided sources:
 INFO tailwindcss_oxide::scanner: Source: PublicSourceEntry { base: "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/app", pattern: "../blog/tailwindcss*/**/*", negated: false }
 INFO tailwindcss_oxide::scanner: Source: PublicSourceEntry { base: "/Users/robin/.bun/bin", pattern: "bun", negated: true }
 INFO tailwindcss_oxide::scanner: Optimized sources:
-INFO tailwindcss_oxide::scanner: Source: Auto { base: "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog" }
+INFO tailwindcss_oxide::scanner: Source: Pattern { base: "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog", pattern: "/tailwindcss*/**/*" }
 INFO tailwindcss_oxide::scanner: Source: Ignored { base: "/Users/robin/.bun/bin", pattern: "/bun" }
 INFO discover_sources: tailwindcss_oxide::scanner: enter
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2022-05-23-headless-ui-v1-6-tailwind-ui-team-management/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2022-06-23-tailwind-templates-and-all-access/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2022-08-17-tailwind-framer-motion-template-and-tailwind-jobs/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2022-09-09-new-personal-website-heroicons-2-headless-ui-v17/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2022-12-15-protocol-api-documentation-template/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2023-04-24-new-changelog-template-and-the-biggest-tailwind-ui-update-ever/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2023-07-18-tailwind-connect-2023-recap/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2023-08-07-meet-studio-our-new-agency-template/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2024-05-24-catalyst-application-layouts/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2024-05-30-prettier-plugin-collapse-whitespace/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2024-06-21-headless-ui-v2-1/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2024-09-12-radiant-a-beautiful-new-marketing-site-template/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/2025-05-14-compass-course-starter-kit/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/automatic-class-sorting-with-prettier/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/building-react-and-vue-support-for-tailwind-ui/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/building-the-tailwind-blog/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/designing-tailwind-ui-ecommerce/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/from-900-to-1-how-we-hired-robin-malfait/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/headless-ui-unstyled-accessible-ui-components/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/headless-ui-v1-4/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/headless-ui-v1-5/demo.tsx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/headless-ui-v1-5/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/headless-ui-v1/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/headless-ui-v2/examples/HeadlessUIV2Examples.tsx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/headless-ui-v2/examples/StateAttributesExample.tsx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/headless-ui-v2/examples/anchor-positioning.tsx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/headless-ui-v2/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/heroicons-micro/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/heroicons-v1/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/hiring-a-design-engineer-and-staff-engineer/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/introducing-catalyst/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/introducing-heroicons/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/introducing-linting-for-tailwindcss-intellisense/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/introducing-tailwind-play/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/just-in-time-the-next-generation-of-tailwind-css/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/multi-line-truncation-with-tailwindcss-line-clamp/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/simon-vrachliotis-joins-tailwind-labs/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/standalone-cli/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/tailwind-plus/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/tailwind-ui-ecommerce/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/tailwind-ui-now-with-react-and-vue-support/index.mdx"
 INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/tailwindcss-1-5/index.mdx"
 INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/tailwindcss-1-6/index.mdx"
 INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/tailwindcss-1-7/index.mdx"
@@ -68,10 +27,4 @@ INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/t
 INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/tailwindcss-v4-beta/index.mdx"
 INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/tailwindcss-v4/color-palette.tsx"
 INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/tailwindcss-v4/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/utility-friendly-transitions-with-tailwindui-react/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/vanilla-js-support-for-tailwind-plus/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/welcoming-brad-cornes-to-the-tailwind-team/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/welcoming-david-luhr-to-tailwind-labs/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/welcoming-james-mcdonald-to-tailwind-labs/index.mdx"
-INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/tailwindlabs/tailwindcss.com/src/blog/whats-new-in-tailwindcss-on-youtube/index.mdx"
 INFO discover_sources: tailwindcss_oxide::scanner: exit
```

Notice now that a lot of files are skipped as expected since we-re not
accidentally over-scanning now.

[ci-all]
2026-06-11 11:59:58 +02:00
Robin Malfait
1bf4291e85
Fix @source with folders that are ignored (#20214)
This PR fixes an issue where a `@source` that's pointing to a folder
that is git ignored, is also ignored by the `@source` even if it's
explicitly added.

Internally, we convert `@source` directives from `PublicSourceEntry`s to
`SourceEntry`s where we have dedicated enum branches for `Auto`,
`Pattern`, `Ignored` and `External`.

The `Auto` one accepts a `base` path, and will be used for auto content
detection. However, these paths will make use of all the default auto
content detection rules, which includes git ignore rules.
We also have `External` where we link to something that's "external" to
the current repo. We can probably improve this name, but it's external
in the sense that it won't show up on GitHub for example, aka ignored.
We have some content dirs that we ignore by default, such as the
`node_modules` folder. When you do use `@source` with `node_modules` in
the path, then we will mark it as an `external` resource which does not
look at the `gitignore` related rules and allowing it to be included
this way.

The idea with this is that, even though the folder is ignored by
default, you can still include files from the folder by explicitly using
the `@source` directive.

The issue as seen in #19844 is using `vendor/` instead of
`node_modules/` which is _not_ ignored by default. While we can add
`vendor/` to this same ignored dirs list, it will result in a breaking
change because this folder is often used by the Laravel community to
store some resources in.

This PR fixes this problem by not only looking at the content dirs we
ignore by default, but also looking at the actual git ignore state of
this folder. If it turns out that this is ignored, then we promote the
`Auto` source to an `External` source.

Fixes: #19844
Closes: #20057


## Test plan

1. Added integration tests for this situation
2. Ran the fix on the reproduction from #19844. If we run the CLI with
the `DEBUG=*` environment variable, the log file produces these results:

```diff
diff --git a/./tailwindcss-29207.log b/./tailwindcss-30381.log
index bc3017c..921af0e 100644
--- a/./tailwindcss-29207.log
+++ b/./tailwindcss-30381.log
@@ -6,8 +6,9 @@ INFO tailwindcss_oxide::scanner: Source: PublicSourceEntry { base: "/Users/robin
 INFO tailwindcss_oxide::scanner: Optimized sources:
 INFO tailwindcss_oxide::scanner: Source: Pattern { base: "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/app/design/frontend/theme", pattern: "/**/*.phtml" }
 INFO tailwindcss_oxide::scanner: Source: Pattern { base: "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/app/design/frontend/theme", pattern: "/**/*.xml" }
-INFO tailwindcss_oxide::scanner: Source: Auto { base: "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/vendor/acme/theme" }
+INFO tailwindcss_oxide::scanner: Source: External { base: "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/vendor/acme/theme" }
 INFO tailwindcss_oxide::scanner: Source: Ignored { base: "/Users/robin/.fnm/node-versions/v26.1.0/installation/bin", pattern: "/node" }
 INFO discover_sources: tailwindcss_oxide::scanner: enter
-INFO discover_sources: tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/app/design/frontend/theme/index.phtml"
+INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/app/design/frontend/theme/index.phtml"
+INFO tailwindcss_oxide::scanner: Reading "/Users/robin/github.com/GrimLink/tailwind-gitignore-bug/vendor/acme/theme/module/templates/component.phtml"
 INFO discover_sources: tailwindcss_oxide::scanner: exit
```

We're checking some `.gitignore` related files, so let's check on each
OS [ci-all]
2026-06-10 18:44:14 +02:00
depfu[bot]
1d5e15e9d8
Update all of react 19.2.6 → 19.2.7 (patch) (#20206)
<!--depfu-start-->
> 👉 **This PR is queued up to get rebased by Depfu**
<!--depfu-end-->





Here is everything you need to know about this upgrade. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ react (19.2.6 → 19.2.7) ·
[Repo](https://github.com/facebook/react) ·
[Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)



<details>
<summary>Release Notes</summary>
<h4><a
href="https://github.com/facebook/react/releases/tag/v19.2.7">19.2.7</a></h4>

<blockquote><h2 dir="auto">React Server Components</h2>
<ul dir="auto">
<li>Fixed missing <code class="notranslate">FormData</code> entries in
Server Actions which regressed in 19.2.6<br>
(<a
href="https://bounce.depfu.com/github.com/facebook/react/pull/36566">#36566</a>
by <a
href="https://bounce.depfu.com/github.com/unstubbable">@unstubbable</a>)</li>
</ul></blockquote>
<p><em>Does any of this look wrong? <a
href="feedback">Please let us
know.</a></em></p>
</details>

<details>
<summary>Commits</summary>
<p><a
href="eaf3e95ca9...6117d7cca4">See
the full diff on Github</a>. The new version differs by 5 commits:</p>
<ul>
<li><a
href="6117d7cca4"><code>Version
19.2.7 (#36591)</code></a></li>
<li><a
href="53a07de1a7"><code>[19.2.x]
Unified release process (#36550)</code></a></li>
<li><a
href="02c444c3f0"><code>[19.2.x][FlightReply]
Don&#39;t drop FormData entries in `decodeReplyFromBusboy`
(#36566)</code></a></li>
<li><a
href="750848e565"><code>[19.2.x][ci]
Create artifact attestations for builds on backport branches
(#36559)</code></a></li>
<li><a
href="e1cfa7b6de"><code>[19.2.x]
Enable CI (#36552)</code></a></li>
</ul>
</details>












---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-06-09 10:44:01 +00:00
depfu[bot]
e7d64e7648
Update next 16.2.6 → 16.2.7 (patch) (#20207)
Here is everything you need to know about this upgrade. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ next (16.2.6 → 16.2.7) ·
[Repo](https://github.com/vercel/next.js)



<details>
<summary>Release Notes</summary>
<h4><a
href="https://github.com/vercel/next.js/releases/tag/v16.2.7">16.2.7</a></h4>

<blockquote><div class="markdown-alert markdown-alert-note" dir="auto">
<p class="markdown-alert-title" dir="auto"><svg data-component="Octicon"
class="octicon octicon-info mr-2" viewbox="0 0 16 16" version="1.1"
width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8
8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5
7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0
1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1
1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</p>
<p dir="auto">This release is backporting bug fixes. It does
<strong>not</strong> include all pending features/changes on canary.</p>
</div>
<h3 dir="auto">Core Changes</h3>
<ul dir="auto">
<li>Backport documentation fixes for v16.2 (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/93804">#93804</a>)</li>
<li>[backport] Patch <code class="notranslate">playwright-core</code> to
resolve <code class="notranslate">_finishedPromise</code> on <code
class="notranslate">requestFailed</code> (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/93920">#93920</a>)</li>
<li>[backport] Fix dev mode hydration failure when page is served from
HTTP cache (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/93492">#93492</a>)</li>
<li>[backport] Fix catch-all <code
class="notranslate">router.query</code> corruption with <code
class="notranslate">basePath</code> + <code
class="notranslate">rewrites</code> (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/93917">#93917</a>)</li>
<li>[backport] Encode non-ASCII characters in cache tags at construction
(<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/93918">#93918</a>)</li>
<li>[backport] Fix server action forwarding loop with middleware
rewrites (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/93919">#93919</a>)</li>
<li>[backport] Turbopack: switch from base40 to base38 hash encoding (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/93932">#93932</a>)</li>
<li>[ci] Disable hanging node 24 typescript tests on 16.2 backport
branch (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/94164">#94164</a>)</li>
<li>[backport] Fix "type: module" in project dir when using standalone
or adapters (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/94050">#94050</a>)</li>
<li>[backport] Propagate adapter preferred regions (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/94200">#94200</a>)</li>
<li>[16.2.x] Don't drop <code class="notranslate">FormData</code>
entries (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/94240">#94240</a>)</li>
<li>[backport] feat(turbopack): add LocalPathOrProjectPath PostCSS
config resolution (<a
href="https://bounce.depfu.com/github.com/vercel/next.js/pull/94284">#94284</a>)</li>
</ul>
<h3 dir="auto">Credits</h3>
<p dir="auto">Huge thanks to <a
href="https://bounce.depfu.com/github.com/eps1lon">@eps1lon</a>, <a
href="https://bounce.depfu.com/github.com/icyJoseph">@icyJoseph</a>, <a
href="https://bounce.depfu.com/github.com/unstubbable">@unstubbable</a>,
<a href="https://bounce.depfu.com/github.com/mischnic">@mischnic</a>, <a
href="https://bounce.depfu.com/github.com/bgw">@bgw</a>, <a
href="https://bounce.depfu.com/github.com/timneutkens">@timneutkens</a>,
and <a
href="https://bounce.depfu.com/github.com/lukesandberg">@lukesandberg</a>
for helping!</p></blockquote>
<p><em>Does any of this look wrong? <a
href="feedback">Please let us
know.</a></em></p>
</details>

<details>
<summary>Commits</summary>
<p><a
href="ee6e79b179...9bd3c26a73">See
the full diff on Github</a>. The new version differs by 13 commits:</p>
<ul>
<li><a
href="9bd3c26a73"><code>v16.2.7</code></a></li>
<li><a
href="c63224f3d8"><code>[backport]
feat(turbopack): add LocalPathOrProjectPath PostCSS config resolution
(#94284)</code></a></li>
<li><a
href="63115c7987"><code>[16.2.x]
Don&#39;t drop `FormData` entries (#94240)</code></a></li>
<li><a
href="aef22fdc82"><code>[backport]
Propagate adapter preferred regions (#94200)</code></a></li>
<li><a
href="f126e72271"><code>[backport]
Fix &quot;type: module&quot; in project dir when using standalone or
adapters (#94050)</code></a></li>
<li><a
href="bda3e2aabe"><code>[ci]
Disable hanging node 24 typescript tests on 16.2 backport branch
(#94164)</code></a></li>
<li><a
href="7e16e07c02"><code>[backport]
Turbopack: switch from base40 to base38 hash encoding
(#93932)</code></a></li>
<li><a
href="6139f4b885"><code>[backport]
Fix server action forwarding loop with middleware rewrites
(#93919)</code></a></li>
<li><a
href="c021d10fe9"><code>[backport]
Encode non-ASCII characters in cache tags at construction
(#93918)</code></a></li>
<li><a
href="9184ddb1ae"><code>[backport]
Fix catch-all `router.query` corruption with `basePath` + `rewrites`
(#93917)</code></a></li>
<li><a
href="3279f01607"><code>[backport]
Fix dev mode hydration failure when page is served from HTTP cache
(#93492)</code></a></li>
<li><a
href="ffc4f29682"><code>[backport]
Patch `playwright-core` to resolve `_finishedPromise` on `requestFailed`
(#93920)</code></a></li>
<li><a
href="bc1791dd7e"><code>Backport
documentation fixes for v16.2 (#93804)</code></a></li>
</ul>
</details>












---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-06-09 12:38:33 +02:00
Robin Malfait
d01e103cc4
Add missing inset keyword for inset-shadow-none (#20208)
This PR fixes an issue where the `inset-shadow-none` class did not use
the `inset` keyword. This PR fixes that by introducing the `inset`
keyword:

```diff
  .inset-shadow-none {
-   --tw-inset-shadow: 0 0 #0000;
+   --tw-inset-shadow: inset 0 0 #0000;
    box-shadow: var(--tw-inset-shadow), var
  }
```

While the end effect is the same (there will be no shadow), it also
means that we switch between the types of shadows. This results in the
fact that things like transitions don't behave the way they should.

A minimal reproduction looks like this:
https://play.tailwindcss.com/iOrnKWP49a (depending on when you visit
this link, it might be fixed)

## Test plan

1. Updated tests to reflect
2. Verified in the Vite playground that this is the case. In the video
you will notice that the transition is smooth in the fixed case, but
there is no transition in the current state:



https://github.com/user-attachments/assets/15d8c78e-6be5-4c6c-8df9-1044c14e8f39
2026-06-08 23:08:52 +02:00
Robin Malfait
68fcdf1f05
Do not crash when migrating empty classes (#20205)
This PR fixes a crash while using `npx @tailwindcss/upgrade` when
migrating classes with no body inside of an `@layer utilities`.

When running `npx @tailwindcss/upgrade`, one thing we do is migrate the
CSS from:
```css
@layer utilities {
  .foo {
    color: red;
  }
}
```
To:
```css
@utility foo {
  color: red;
}
```

We already have some logic that leaves non-classe (IDs, attribute
selectors, ...) alone. But if we are migrating a class that has no body,
then we will migrate that as well:
```css
@layer utilities {
  .empty {
  }
}
```
Is turned into:
```css
@utility empty {
}
```

But later in the migration process this will result in an error because
a `@utility` has to have at least _some_ nodes.

Ideally, you don't even have CSS that has empty rules since it doesn't
have any effect in the browser (except of making your CSS file bigger),
but it could be that you don't have control over this file, so a fix is
still valid.

This PR solves that by leaving those rules alone, and keep them in an
`@layer utilities`.

Fixes: #20204

## Test plan

1. Added a dedicated test for this usecase
2026-06-07 21:50:35 +02:00
Robin Malfait
3f58e52e36
Ensure @source globs with symlinks are preserved (#20203)
This PR fixes an issue when working with `@source` and `@source not`
that involves symlinks.

Internally our sources are mapped to a source entry where we have a
`base` path and a `pattern`. You can think about this where we insert a
`.gitignore` file in the `base` path for the given pattern.

However, we optimize these entries to move as many "static" parts into
the base path. For example:
```css
@source "./some/folder/here/*.html";
```

Is mapped to something like:
```ts
{ base: "/projects/my-project", pattern: "./some/folder/here/*.html" }
```
We then optimize it by turning it into:
```ts
{ base: "/projects/my-project/some/folder/here", pattern: "*.html" }
```

While doing this, we also use `dunce::canonicalize` to resolve the
actual paths on disk. This means that a symlink is resolved to their
real paths. This can cause issues because the "real" path is not what
you wrote in the `@source` directives.

So before, it could be that you have this:
```css
@source "./some/symlinked-folder/here/*.html";
```
Which was mapped to this on the Rust side:
```ts
{ base: "/projects/my-project", pattern: "./some/symlinked-folder/here/*.html" }
```
But was then optimized to:
```ts
{ base: "/projects/my-project/some/actual-folder/here", pattern: "*.html" }
```

...and we lost the `symlinked-folder` information. This causes issues as
seen in #17985.

With this PR, we keep the symlinked information in those globs since
that's what you wrote in those `@source` directives.

While setting up integration tests, I stumbled upon an issue because I
wanted to test that ignoring a symlinked folder, but including a single
particular file of that ignored folder resulted in that file being
ignored as well. Let's look at an example:

```css
@source     '../lib';
@source not '../lib/ignored';
@source     '../lib/ignored/except.html';
```

Earlier I mentioned that we create `.gitignore` files based on these
`@source` directives. In this case, when we're dealing with a folder, we
use `**/*` as the contents.

Looking at the example above, we should essentially have something like
this:
```gitignore
# lib/.gitignore
# @source '../lib'
!**/*

# lib/ignored/.gitignore
# @source '../lib/ignored'
**/*

# @source '../lib/ignored/except.html'
!except.html
```

Since it's a `.gitignore` file, we have to invert the globs. But the bug
I noticed is that in reality the result of those gitignores didn't look
like the above, it looked like:
```gitignore
# lib/.gitignore
# @source '../lib'
!**/*

# lib/ignored/.gitignore
# @source '../lib/ignored/except.html'
!except.html

# @source '../lib/ignored'
**/*
```

Notice how the `!except.html` and `**/*` are flipped. When dealing with
`.gitignore` files, the order is important.

This was caused because internally we kept a `BTreeMap` of `BTreeSet`s
where the map was the base path and a set of patterns. The patterns were
sorted because of the `BTreeSet`... which is not what we want.

Fixes: #17985
Closes: #20091

## Test plan

1. Added new tests in the scanner tests (on the Rust side)
2. Added integration tests with a symlink to another folder, outside of
the current folder
2. Added integration tests with a symlink to another folder, inside of
the current folder
2. Added integration tests to ensure that the order of `@source` files
with a folder + file is sorted correctly.
3. Since we're dealing with symlinks in these tests, let's test all OSes
[ci-all]
2026-06-07 20:44:22 +02:00
Jordan Pittman
ad6693906a
Allow @variant to be used inside addBase (#19480)
This seems like an unintentional omission

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-06-04 10:46:40 +00:00
Chris Hasiński
d42b34abbf
fix: handle invalid UTF-8 in Ruby and Vue preprocessors (#19588)
## Summary

This PR fixes a panic that occurs when the Ruby or Vue preprocessors
encounter files with invalid UTF-8 bytes.

**The issue:**
- `ruby.rs:37` and `vue.rs:18` used
`std::str::from_utf8(content).unwrap()`
- This panics when processing files containing invalid UTF-8 bytes

**Error message:**
```
thread panicked at crates/oxide/src/extractor/pre_processors/ruby.rs:37:59:
called `Result::unwrap()` on an `Err` value: Utf8Error { valid_up_to: 45, error_len: Some(1) }
```

**The fix:**
- Wrap UTF-8 conversion in `if let Ok(...)` to gracefully handle invalid
UTF-8
- Skip regex-based template extraction when UTF-8 conversion fails
- Allow byte-level processing to continue (in Ruby's case)

This can happen in Rails projects when:
- Binary files are inadvertently scanned
- Files contain non-UTF-8 encodings  
- Files are truncated at multi-byte character boundaries during parallel
processing

## Test plan

- [x] Added `test_invalid_utf8_does_not_panic` test for Ruby
preprocessor
- [x] Added `test_valid_utf8_with_multibyte_chars` test for Ruby
preprocessor
- [x] Added `test_invalid_utf8_does_not_panic` test for Vue preprocessor
- [x] All existing tests pass (`cargo test pre_processors` - 43 tests)

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-06-04 10:35:47 +00:00
Robin Malfait
0612ddc7dc
Add Twig pre-processor (#20198)
This PR fixes an issue where `addClass(opacity-50)` and
`removeClass(opacity-50)` in Twig templates isn't extracted properly.

This fixes that by adding a pre-processor for `.twig` files. This is a
simple initial implementation where we drop the `(` and `)` from the
`addClass` and `removeClass` functions. We don't do any special handling
around escaped characters or parenthesis inside of strings. We will add
them when there is a use case for it. Until then, we'll keep it simple.

If the real API would've been `addClass('opacity-50')` then it would've
worked out of the box.

A workaround you can use today is by using spaces `addClass( opacity-50
)` that works as well.

Fixes: #19458
Closes: #20110

## Test plan

1. Added new tests for `twig` extraction
2. Added tests with nested `(` and `)` e.g. `addClass(p-(--value))`
which should properly extarct `p-(--value)`
2026-06-04 11:44:13 +02:00
depfu[bot]
0b58dd69fd
Update @napi-rs/cli 3.6.2 → 3.7.0 (minor) (#20197)
Here is everything you need to know about this upgrade. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ @​napi-rs/cli (3.6.2 → 3.7.0) ·
[Repo](https://github.com/napi-rs/napi-rs)





Sorry, we couldn't find anything useful about this release.











---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-06-04 10:31:08 +02:00
Aarni Koskela
efae52c3af
Simplify CSS when using utilities that use a *-0 or *-1 value (#20196)
## Why?


While inspecting a Tailwind-powered site, I noticed selectors like

```css
.inset-0{inset:calc(var(--spacing) * 0)}
.inset-x-0{inset-inline:calc(var(--spacing) * 0)}
.top-0{top:calc(var(--spacing) * 0)}
```

which seem a bit silly, not to mention more complex for the end-user
device parsing the CSS.

## Summary

This PR adjusts helpers to not generate `calc(... * 0)` expressions.

https://github.com/tailwindlabs/tailwindcss/pull/19095 does a similar
thing on CSS AST, but that doesn't run during the build proper.

## Test plan

Ran `pnpm build && pnpm test && pnpm test:integration`. (Some unrelated
tests failed on my machine, hopefully less in CI.)

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-06-03 16:44:09 +00:00
Robin Malfait
0c5f8bfd88
Ignore the standalone CLI (#20139)
This PR fixes an issue where if you use the standalone CLI, and you move
the standalone CLI into the current project, then we would scan that
standalone CLI as-if it contains Tailwind CSS classes. Since the CLI
contains actual Tailwind CSS classes, and is in fact readable text, this
binary would've been used as a source.

There are a few ways of fixing this, we could hardcode all the known
names, but that would result in an issue if you rename the CLI. We could
check whether it's a binary format and look for magic numbers at the
top. We could also check for a shebang at the top of the file and skip
it that way.

While some of these solutions might still be useful for the future. For
now I fixed it by essentially always ignoring `process.execPath`. That
way we never ever scan the actual executable regardless of whether you
renamed it or not.

Fixes: #20134

## Test plan

- Added an integration tests
- Works on every OS [ci-all]
2026-06-02 12:49:53 +02:00
Robin Malfait
44818a6cf6
Ensure @tailwindcss/cli recovers from a deleted transitive dependency (#20137)
This PR fixes an issue where the `@tailwindcss/cli` can get into a
non-recoverable state when any of the transitive dependencies break.

Tailwind CSS has 2 kinds of dependencies:

1. All your templates
2. All dependencies that contribute to your configuration such as the
`input.css`, any plugins, any `tailwind.config.js` files and so on.

When a template changes, we just have to scan for new Tailwind CSS
classes and emit a new CSS file. But when the `input.css` file, or any
of its dependencies changes, then we want to perform a full rebuild.

The idea is that your `@theme` might have changed, or new plugins have
been added, or old plugins have been removed.

If you have an `input.css` file:
```css
@import "tailwindcss";
@config "./tailwind.config.js";
```

That relies on a custom config: `tailwind.config.js`:
```js
const theme = require('./my-custom-theme.js');
module.exports = {
  theme
}
```

If that file relies on yet another file: `./my-custom-theme.js`, then
changes there should also trigger a full rebuild.

Since we're dealing with JavaScript here, we want to clear the require
cache and rebuild the dependency tree such that another change to any of
these files triggers a full fresh build.

However, if any of those (transitive) dependencies are deleted, then we
will end up in an invalid state. Creating a new compiler will result in
a build error. The compiler won't be able to figure out the entire
dependency tree, and we're stuck.

Once the user fixes the potentially missing dependency, the watchers
will not be watching any of those files because we created a fresh
compiler.

With this PR, we fix that by keeping track of old paths and using those
while we are still in an invalid state. The moment everything is fixed,
a fresh dependency tree is created and everything starts working again
without you having to restart the `@tailwindcss/cli` command.

Fixes: #20113
Closes: #20114
Closes: #20133

## Test plan

- Added an integration test that removes the transitive dependency.
Re-adding that file later will recover the CLI state.
2026-06-01 19:15:40 +02:00
Robin Malfait
6b43b6400a
Canonicalization: limit arbitrary to bare values conversion (#20130)
This PR improves the canonicalization process by limiting the bare
values to a certain amount.

Before this PR, whenever we have an arbitrary value, e.g. `left-[6px]`,
then we prefer to use a bare value instead e.g. `left-1.5`. In most
cases, this makes sense.

However, there are places where this doesn't really make sense
(https://x.com/kettanaito/status/2059987396050268589)

- `left-[99999px]` → `left-24999.75 `

The hard part is to figure out _why_ this feels wrong. The `.75` could
feel wrong, but in the `left-[6px]` → `left-1.5`, the `.5` makes sense.
If we reduce that big number to `left-[99996px]` → `left-24999`, then
there is no floating point but it still feels wrong.

One possibility I can think of is to analyze the incoming value and see
if we find certain patterns. All repeating numbers, fun numbers like
`1337`, common numbers most programmers know such as `720px`, `1280px`,
etc.

But instead of that, I think it's more reasonable to limit the bare
value such that the `px` based value doesn't exceed a big number. We can
improve the logic if there are other cases that don't really make sense.

The biggest value we have in our default theme is `--breakpoint-2xl:
96rem`, which is equivalent to `1536px`.

So I think any bare value that results in a value `<= 1536px` should
probably be fine.

In this case, `left-[99999px]` would stay as `left-[99999px]`, but
`left-[6px]` is still converted to `left-1.5`.

Note: this is only happening for arbitrary values being converted to
bare values _if_ they use the `--spacing` variable internally.

Values such as `z-[99999999999]` will still be converted to
`z-99999999999`, since the intent is still clear.

## Test plan

1. Added new tests for these limitations
2. Other existing tests still pass
2026-05-29 21:31:47 +02:00
Robin Malfait
d4f24c5f16
Fix invalid canonicalization where 0<unit> was migrated to 0 (#20127)
This PR fixes a bug in the canonicalization process when we simplify /
fold declarations that contain `0<unit>` values.

The reason we even try to fold these in the first place is to simplify
values such as `m-[0rem]` to `m-0`. The more values we can
fold/canonicalize, the better we can suggest replacements _if_ they are
the same.

One thing we know in CSS is that if you have a `<length>` type, and that
value is `0<unit>`, then we can safely change that to just `0`.
```css
width: 0rem;
width: 0;    /* `0` is a <length> */
```
However, if this was part of a `calc(…)` (or another CSS math function),
then this could make the calc expression invalid:
- `calc(1rem + 0px)` → `calc(1rem + 0)` — this goes from _valid_ to
_invalid_
At runtime the `1rem` can be converted to a `px` based valued, then
`16px + 0px` makes sense. Adding `0` without unit does not.
- `calc(1rem * 0px)` → `calc(1rem * 0)` — this goes from _invalid_ to
_valid_
At runtime the `1rem` can be converted to a `px` based value, but `16px
* 0px` would result in `0px^2` which doesn't make sense either.

We will still normalize values such as `-0.0rem` to just `0rem`, but not
`0` if we know it's unsafe to do so.

If we end up with top-level `calc(…)` expressions that can be folded,
then we will try to do that:
- `calc(0px * -1)` → `0`
- `calc(calc(0px * -1) + 1rem)` → `calc(0px + 1rem)`
Notice that the inner `calc(…)` was folded to `0px` not `0` because that
would make the `calc(0 + 1rem)` invalid.
Additionally, we could potentially fold the `calc(0px + 1rem)` to just
`1rem`, but we have to make sure that we don't introduce valid values
from invalid values `calc(0s + 1rem)` would be invalid, but folding it
to `1rem` would make it valid which is not good.

Fixes:
https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1579
2026-05-29 14:43:26 +02:00
Robin Malfait
829cdc930a
duration must be at least 500ms 2026-05-26 23:34:51 +02:00
Robin Malfait
2666194d5c
Improve internal instrumentation module (#20119)
This PR improves some of the internal instrumentation tooling we have.

While working on another branch, I updated the instrumentation tooling
to have a few different ways of measuring what's going on.

Until now, we had an `I.start(label)` and corresponding `I.end(label)`.
While this works, it also means that you have to make sure that you call
`I.end(label)` before every `return` to track things properly.

With this PR, I added a `I.span(label, () => /* some callback*/{})` API
that essentially does that in one go. It also handles promises and
resturns the value that was returned from the callback. This can be
useful in situations where you have a one-liner:
```ts
let css = I.span('toCss(…)', () => toCss(ast))
```

If your callback is longer, then you end up in a situation where you
have to indent your code, and if you want to stop measuring you have to
drop code in 2 places and re-indent:
```diff
- I.span('label', () => {
    …
- })
```

For this situation, I also added a `using _ = I.track(label)` API
instead. This can also be used in any block and automatically inserts
the `I.end(label)` on every exit point. This relies on the new `using`
keyword, but we already relied on that for the instrumentation module.

Last but not least, the constructor accepts a `shouldReport` which
defaults to the `env.DEBUG`. The reason for this change is so that it's
easier to report / not report during development instead of swapping out
an environment variable. Again, this is internal so there is no public
API change happening here.

## Test plan

All tests should still pass.
2026-05-26 23:20:39 +02:00
Robin Malfait
cdfae994ed
Remove unused test (#20117)
This PR cleans up some old stale test that has been skipped from the
beginning.

While this test wants to prove that migrating from Tailwind CSS v3 to
Tailwind CSS v4 can handle the `#{!important}` SCSS notation, it doesn't
prove that we can migrate an entire SCSS project. SCSS has much more
special syntax and we never supported that.

Let's get rid of this test that's doing nothing at the moment.
Especially since we don't want to migrate SCSS projects. Enabling this
might result in the false sense that we _do_ support SCSS migrations
which is not the case.

Closes: #20106
2026-05-26 19:45:00 +02:00
Robin Malfait
749c45ef26
Expose index and siblings on walk context (#20109)
This PR is a small improvement of the current `walk` implementation
where we will expose the `index` and the `siblings` on the current
context.

During a walk, we walk over objects that contain a `nodes: []` field.
The `ctx.parent` that already exists is a reference to the parent node,
but `ctx.siblings` is a reference to the `ctx.parent.nodes`.

The `ctx.index` is the index of the current node we are walking in the
`siblings` array. This way we can prevent the awkward
`ctx.parent?.nodes.indexOf(node)` which is a bit silly because we
already know the nodes we're walking and its index...

The `ctx.parent` can be `null`, but the `ctx.siblings` will never be
`null`, this can be seen in a situation like this:

```ts
let ast: AstNode = [nodeA, nodeB]

walk(ast, (node, ctx) => {
  if (node === nodeA) {
    ctx.parent === null; // Because there is no parent
    ctx.siblings === ast; // Because that's the current list we're looping over

    // Before this PR, we would have to do something like:
    let siblings = ctx.parent?.nodes ?? ast
  }
})
```

In the above example, the `ast` is a separately variable, but if this
was inlined, we would run into some issues:
```ts
walk([nodeA, nodeB], (node, ctx) => {
  if (node === nodeA) {
    ctx.parent === null; // Because there is no parent
    ctx.siblings === ast; // Because that's the current list we're looping over

    // At this point, there is no way to get to the `[nodeA, nodeB]` list
    // without moving it to a variable first.
  }
})
```

So, this PR doesn't change much, the additional information we track is
already known information that is now exposed to the caller of the
`walk` function.

In this PR we did update some usages and got rid of some awkward
`ctx.parent?.nodes ?? []` and `ctx.parent.nodes.indexOf(…)` usages.

## Test plan

- All tests still pass as expected
2026-05-24 22:32:05 +02:00
Jiahan Chen
982e920df8
Add Rspack optional peer dependency for @tailwindcss/webpack (#20027)
## Summary

This PR makes Rspack support explicit for `@tailwindcss/webpack` by
adding `@rspack/core` as an optional peer dependency alongside
`webpack`.

The loader already works through Rspack's webpack-compatible loader API,
as shown in this runnable example:
https://github.com/rstackjs/rstack-examples/tree/main/rspack/tailwindcss.
The README and package description are updated to document that usage.

## Test plan

No Rspack-specific tests were added. The loader implementation is
unchanged, and the existing webpack loader tests exercise the same
loader API path that Rspack uses, which should cover the relevant
behavior.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-05-23 11:14:48 +02:00
Robin Malfait
73983e1cf5
Fix 'Sourcemap is likely to be incorrect' warnings when using @tailwindcss/vite (#20103)
This PR fixes an issue where a bunch of warnings would be shown related
to sourcemaps.

This happens when we are dealing with CSS files that are _not_ Tailwind
CSS roots. In that case, in the `transform` step, we return the `src` of
that module as-is because we didn't modify anything. However, when
nothing changed, you have to return a `NullValue` such as `undefined`.

So this is a stupid little fix, but it should get rid of a bunch of
annoying warnings.

Fixes: #19930

## Test plan

- Added an integration test to mimic the problem
- Other tests still pass
- Tested it against the reproduction provided in #19930


Before:
<img width="1887" height="1763" alt="8oQNG5Lqr2B"
src="https://github.com/user-attachments/assets/2d8af456-4176-4f18-92a3-5327e395ac6b"
/>


After:
<img width="1885" height="1404" alt="8oQND5kWSb4"
src="https://github.com/user-attachments/assets/7dd98ea4-126b-45d4-9411-0afbacd2c797"
/>
2026-05-22 17:57:25 +02:00
Robin Malfait
e0a0c46c85
Cleanup lockfile / dependencies (#20102)
This PR reduces the installed dependencies by cleaning up the
`pnpm-lock.yaml` file.

This also pins `@parcel/watcher` such that the lockfile is generated
properly becauase of the patched dependencies.

This is a follow-up of #19499, but up to date with the latest state of
the repo.

## Test plan

- Lockfile is simpler. Most dependencies stayed the same, and were
published _months_ ago. There are a few cases where we have more recent
published dependencies. There are 7 dependencies that were published in
the last ~24 hours: `node-releases@2.0.46` (10 hours ago),
`electron-to-chromium@1.5.361` (12 hours ago), `semver@7.8.1` (20 hours
ago), `terser@5.48.0` (20 hours ago), `webpack-sources@3.5.0` (5 hours
ago), `vite@8.0.14` (yesterday). All of these but the `terser` version
used OIDC.
- Socket.dev didn't report any issues with the changed dependencies
- All tests still pass

---------

Co-authored-by: James Garbutt <43081j@users.noreply.github.com>
2026-05-22 16:57:46 +02:00
Jordan Brough
a480043a8a
Add --silent option to suppress output in @tailwindcss/cli (#20100)
Edited by: @RobinMalfait 

This PR adds a new `--silent` option to the `@tailwindcss/cli` to
suppress output (except for errors).

## Test plan

- Existing tests pass
- A new integration test for the `--silent` option was added

---

Original:

## Summary

Small change to add a `--quiet` option.

@adamwathan previously said this type of thing [sounded like a good
idea](https://github.com/tailwindlabs/tailwindcss/issues/8050#issuecomment-1100164351):

> [I] have made a note about the --silent option idea which I think
definitely has value 👍🏻

This is helpful to keep logs high signal in some scenarios. For example,
when I want AI to sift through my foreman logs, where "Done in X"
becomes the dominant log message over time when running tailwind
alongside other things.

## Test plan

I've added an integration test.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-05-22 13:03:32 +00:00
Robin Malfait
9f9b7c5c6d
Remove pnpm-lock.yaml from wasm32-wasi (#20101)
This PR removes the `pnpm-lock.yaml` file from one of the sub-packages.
This is unnecessary and causes confusion. Since this is a monorepo, we
can make use of the `pnpm-lock.yaml` file in the root of the project
instead.

Closes #20097
2026-05-22 14:33:17 +02:00
Robin Malfait
8dcdb66e8a
Bump dependencies (#20095)
This PR bumps some of our dependencies, common dependencies were moved
to pnpm's `catalog` feature.

Closes #20092
Closes #20085
Closes #20075
Closes #20066
Closes #20062


## Test plan

- All tests still pass
- Each dependency was published some time ago. Webpack has an even newer
version that was published <15min ago. Will update that one later.

[ci-all]
2026-05-21 17:58:55 +02:00
Robin Malfait
161569a541
Improve GitHub Actions (#20093)
This PR improves GitHub workflows by making sure that:

- Uesed actions are pinned
- We don't persist credentials
- Remove caches from release workflows
- Prevent template expansion when using `env.…` in `run` blocks
- Use the `gh release` instead of `softprops/action-gh-release`
2026-05-21 12:03:39 +02:00
Robin Malfait
d03edefd19
Fix parsing bug in SelectorParser (#20090)
While working on another feature, I noticed that a selector such as
`.foo::before` was parsed as:

```ts
[
  {
    kind: 'compound',
    nodes: [
      { kind: 'selector', value: '.foo' },
      { kind: 'selector', value: ':' },
      { kind: 'selector', value: '::before' },
    ],
  },
]
```

Instead of:
```ts
[
  {
    kind: 'compound',
    nodes: [
      { kind: 'selector', value: '.foo' },
      { kind: 'selector', value: '::before' },
    ],
  },
]
```

So far this hasn't been a real issue in practice, but it is in a
follow-up PR that I'm working on. To keep things separated, I wanted to
fix this behavior in a dedicated PR instead.

## Test plan

1. Added a test case for this situation
2. Other tests still pass
2026-05-20 15:54:07 +02:00
Robin Malfait
2c4726d3d5
Simplify selector combinators (#20089)
This PR is a follow-up of #20088 to further improve selectors, in
particular the `combinator`.

This PR explicitly types the `combinator` as:
```ts
type Combinator =
  | ' ' // Descendant combinator
  | '>' // Child combinator
  | '+' // Next-sibling combinator
  | '~' // Subsequent-sibling combinator
```

This allows us to explicitly test for this pattern in various places,
without us having to call `.trim()` first to know what the actual
combinator was.

In the selector parser itself, we already did a `.trim()` to know
whether we were dealing with a descendant combinator or not. With this
PR, we further ensure that there is no whitespace involved aroudn these
combinators.

This introduces a small problem because we need to be able to re-print a
selector's AST. So if we don't track whitespace, we have to re-introduce
it. But there are situations where we don't want it at all (during
canonicalization).

To solve this, we introduced a `minify = false` option in the
`SelectorParser.toCss`. If it's false (the default), then we introduce
whitespace, otherwise we remove all whitespace.

## Test plan

1. All existing tests pass
2. Manual cleanup/trimming of descendants is no longer necessary

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-05-20 15:30:55 +02:00
Robin Malfait
fc17df05f2
Add list, compound, and complex Selector nodes (#20088)
This PR introduces a few more nodes in the `SelectorParser`:

- A `list` node
- A `complex` node
- A `compound` node

These names are closer to the CSS Selector AST names
(https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Selectors/Selector_structure),
and are also used in other libraries.

The problem today is that there are situations where we parse a selector
like: `#a.b > .c, .d` as:
```ts
[
  { kind: 'selector', value: '#a' },
  { kind: 'selector', value: '.b' },
  { kind: 'combinator', value: ' > ' },
  { kind: 'selector', value: '.c' },
  { kind: 'separator', value: ', ' },
  { kind: 'selector', value: '.d' }
]
```

Which is a very simple structure, but this contains a flaw that is
annoying to deal with in practice: In order to determine that we are
dealing with multiple selectors, we have to loop through the nodes and
see if a separator occurs somewhere.

The other fun thing is that we already know the difference between
selectors, combinators and separators. So if we tweak this structure a
little bit during parsing, then we can answer the question from above in
a much simpler way:

With this PR, we will parse the selector as:
```ts
[
  {
    kind: 'list',
    nodes: [
      {
        kind: 'complex',
        nodes: [
          {
            kind: 'compound',
            nodes: [
              { kind: 'selector', value: '#a' },
              { kind: 'selector', value: '.b' }
            ]
          },
          { kind: 'combinator', value: '>' },
          { kind: 'selector', value: '.c' }
        ]
      },
      { kind: 'selector', value: '.d' }
    ]
  }
]
```

It definitely looks more complex, but now that we have a `list` node, we
already know that we are dealing with multiple selectors.

If you squint your eyes, in the inner part there is a `compound`
selector. This is essentially a node where each sub-node can be squished
together with no spaces whatsoever.

The `complex` selector is there just to group everything together. In
other tools, a complex selector is often represented as:
```ts
{
  kind: 'complex',
  combinator: '>',
  lhs: { … },
  rhs: { … },
}
```
While I want to have the concept of a `complex` node, I didn't go with
this syntax just because I want to keep the concept of `nodes` which
means that we don't need any special handling when using `walk` (which
loops over `.nodes` internally).

The reason this complex node exists is because otherwise you would end
up with this structure:
```ts
[
  {
    kind: 'list',
    nodes: [
      {
        kind: 'compound',
        nodes: [
          { kind: 'selector', value: '#a' },
          { kind: 'selector', value: '.b' }
        ]
      },
      { kind: 'combinator', value: '>' },
      { kind: 'selector', value: '.c' }
      { kind: 'selector', value: '.d' }
    ]
  }
]
```
But if you look at the `list` node now, it's not clear that we are
dealing with `2` selectors since there are 4 nodes. We could solve this
by re-introducing the separator node (`,`). The fact that the `list`
exists tells us that we're dealing with `n` selectors. But to know which
selectors we're dealing with, then we have to look for that `,` node
again, which introduces the original problem.

This is just an internal refactor to make future changes easier.

## Test plan

1. Everything still works as expected (all tests pass)
2. No public API breaking changes, this parser was never exposed
2026-05-20 15:04:05 +02:00
Robin Malfait
cea8c9721b
Improve parsing of shadow values (#20080)
This PR fixes an issue where a `calc(…)` value used in a shadow value
was incorrectly marked as the color of that shadow.

We have this feature where we can have colored shadows, this requires us
to replace the color in a value with a `var(--tw-shadow-color,
<original-value-here>)` such that we can swap out the color.
For this, we have to parse the value and figure out what the color part.

This PR uses the `ValueParser` to parse values, then figure out what the
color part is. The biggest reason for this is that we know what a
"function" is and what a normal "word" is, which allows us to do more
fine-grained checks on a per-type basis.
We tracked a few more functions that we _know_ produce color values, and
a few cases that we know produce length values so therefore can't be the
color.

Some examples:

- `calc(…)`, `min(…)`, `max(…)`, `clamp(…)`, `--spacing(…)` — these all
produce length-values.
- `color(…)`, `color-mix(…)`, `rgba?(…)`, `--alpha(…)` … — these all
produce color values.

Notice that we added `--spacing(…)` and `--alpha(…)` as well, these are
custom functions that Tailwind CSS provides, but we know what it will
eventually map to internally.

Last but not least, we also detect named colors and hex-based colors.

Fixes: #20065 
Closes: #20074

## Test plan

1. Added an integration test, before the fix the test would've looked
lik this:

  ```diff
    .drop-shadow-calc {
- --tw-drop-shadow-size: drop-shadow(0 0 calc(1 * var(--spacing))
var(--tw-drop-shadow-color, black));
+ --tw-drop-shadow-size: drop-shadow(0 0 var(--tw-drop-shadow-color,
calc(1 * var(--spacing))) black);
      --tw-drop-shadow: drop-shadow(var(--drop-shadow-calc));
filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, )
var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, )
var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, );
    }
  ```

2. Added more tests related to the shadow replacement logic itself where
we try to infer the color (or the length-values for x, y, blur, spread)
3. All other existing tests pass
2026-05-19 13:47:50 +02:00
Robin Malfait
36417cbd12
Properly negate custom variants with @container when used with not-* (#20059)
This PR fixes an issue where a custom variant declared with a
`@container` wasn't properly negated when using it in combination with
the `not-*` variant.

Given this CSS:
```css
@custom-variant has-a {
  @container style(--a) {
    @slot;
  }
}
```

If you then used `not-has-a:flex`, then the following CSS was produced:
```css
.not-has-a\:flex {
  @container style(--a) not {
    display: flex;
  }
}
```
But we expect the `not` to be in the correct location:
```css
.not-has-a\:flex {
  @container not style(--a) {
    display: flex;
  }
}
```

The issue was that we did some string related checks, and we assumed
that the `query` part of the `@container` (`style(--a)`) had to start
with a `(` character.

To fix this, we now parse the value to an AST, and verify the AST shape
before manipulating it. This now checks whether the `query` part is a
function (both `(…)` and `style(…)` are considered functions).

Also added some additional tests that were already handled, these cases
look like:

- `@container {query}`
- `@container not {query}`
- `@container {name} not {query}`
- `@container {name} {query}`


Fixes: #20058

## Test plan

1. Added a failing test for the use case of the linked issue.
2. Added a few more additional tests to explicitly track some use cases
we handled already but didn't track via tests.
3. All other tests still pass as expected.
2026-05-15 00:08:10 +02:00
Jordan Pittman
460a008120
v4: Allow @apply to be used with CSS mixins (#19427)
The CSS Custom Functions and Mixins spec [is using `@apply` with dashed
idents](https://drafts.csswg.org/css-mixins-1/#apply-rule) for native
mixin support. We shouldn't attempt to treat these as utilities and try
to compile them since they'll fail.

There one intentional limitation with regards to mixins and our use of
`@apply`: We do not allow users to mix utilities and mixins in the same
at-rule. In other words, all of the following `@apply` rules are
invalid:

```css
.foo {
  /* Invalid because the rules contain both mixins and utilities */
  @apply --my-mixin underline;
  @apply --my-mixin() underline;
  @apply underline --my-mixin;
  @apply underline --my-mixin();
}
```

Aside: Lightning CSS does not yet support this syntax so the results of
a production build won't produce the correct code but we'll at least
handle these correctly in Tailwind CSS itself.

Fixes #19422

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-05-14 14:22:26 +00:00
Robin Malfait
0db226a222
Add Vue integration test with 1000 components (#20055)
This PR adds an integration test with Vue where we use a 1000 components
and where each component references a CSS file via `@reference`. Each
component has a unique class that uses `@apply`.

There are some discussions in
https://github.com/tailwindlabs/tailwindcss/discussions/16429 that
mention that this causes OOM issues. Right now I can't reproduce that,
and even with a 1000 components, it produces CSS in a reasonable time:
```
vite v7.3.3 building client environment for production...
✓ 2011 modules transformed.
dist/index.html                   0.23 kB │ gzip:  0.18 kB
dist/assets/index-DNVNFkYQ.css  106.65 kB │ gzip: 10.79 kB
dist/assets/index-B8v7EbAN.js   223.84 kB │ gzip: 49.81 kB
✓ built in 3.17s
```

I also started a Vite server and triggered file changes to see if the
memory would grow forever, which it didn't. After a 1000 changes,
everything still behaves smoothly:
<img width="1694" height="1856" alt="image"
src="https://github.com/user-attachments/assets/b16800ae-4dce-4f0d-9d97-25f4cab21c1c"
/>

Making changes manually to a single component, result in proper HMR
request that update the browser:


https://github.com/user-attachments/assets/5c79ffc6-2329-4341-9d25-82b000093e31

This test is here to make sure that it keeps working in the future.

---

If I remove all `@reference` references, and usages of `@apply`, then
the build time is indeed faster:
```
vite v7.3.3 building client environment for production...
✓ 2011 modules transformed.
dist/index.html                   0.23 kB │ gzip:  0.18 kB
dist/assets/index-CcxXccJ1.css  106.61 kB │ gzip: 10.76 kB
dist/assets/index-M92YFF0G.js   223.84 kB │ gzip: 49.81 kB
✓ built in 1.97s
```

So we go from `1.97s` → `3.17s`, which is a `1.2s` increase when you use
`@reference` with `@apply` in 1000 files for a fresh build.

I also saw some comments about the CSS growing whenever `@reference` was
used, but as you can see in the snippets above they are at a stable
size.

## Test plan

1. All tests still pass

[ci-all] For testing on Windows / macOS

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-05-14 15:38:51 +02:00
Robin Malfait
559f92bbf8
Harden workflow security (#20054)
This PR improves the workflows a bit more by:

1. Making sure that we always use `pnpm install` with a frozen lockfile
2. Cleanup permissions
3. By not caching `~/.cargo/bin/`

## Test plan

1. Every test should still pass

[ci-all]
2026-05-14 13:00:37 +02:00
depfu[bot]
6ee13978e6
Update all of react 19.2.5 → 19.2.6 (patch) (#20050)
Here is everything you need to know about this upgrade. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ react (19.2.5 → 19.2.6) ·
[Repo](https://github.com/facebook/react) ·
[Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)



<details>
<summary>Release Notes</summary>
<h4><a
href="https://github.com/facebook/react/releases/tag/v19.2.6">19.2.6</a></h4>

<blockquote><h2 dir="auto">React Server Components</h2>
<ul dir="auto">
<li>Type hardening and performance improvements<br>
(<a
href="https://bounce.depfu.com/github.com/facebook/react/pull/36425">#36425</a>
by <a href="https://bounce.depfu.com/github.com/eps1lon">@eps1lon</a>
and <a
href="https://bounce.depfu.com/github.com/unstubbable">@unstubbable</a>)</li>
</ul></blockquote>
<p><em>Does any of this look wrong? <a
href="feedback">Please let us
know.</a></em></p>
</details>

<details>
<summary>Commits</summary>
<p><a
href="23f4f9f30d...eaf3e95ca9">See
the full diff on Github</a>. The new version differs by 2 commits:</p>
<ul>
<li><a
href="eaf3e95ca9"><code>Version
19.2.6</code></a></li>
<li><a
href="795203e75e"><code>[FlightReply]
Type hardening and performance improvements</code></a></li>
</ul>
</details>












---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-05-14 12:41:48 +02:00
Serge Yudin
a2b89a7012
Fix canonicalize crash with plugin component values (#20052)
## Summary

Fixes #20051.

The collapse canonicalization pass speculatively checks compatible
functional utility roots. When one of those roots comes from a plugin
registered with `matchComponents`/`matchUtilities`, the speculative
candidate can call the plugin callback with an arbitrary value that is
not present in the configured `values` map. Plugins such as the Phoenix
Heroicons helper expect the mapped value shape and can throw while
canonicalize is only probing possible replacements.

This change skips speculative replacement utilities whose property
lookup throws, matching the best-effort behavior already used by utility
signature generation. The original candidates are preserved instead of
crashing canonicalization.

## Test plan

- `source ~/.nvm/nvm.sh && nvm use 22.14.0 && pnpm vitest run
packages/tailwindcss/src/canonicalize-candidates.test.ts -t "does not
crash when plugin matchComponents rejects speculative values during
collapse"`
- `source ~/.nvm/nvm.sh && nvm use 22.14.0 && pnpm vitest run
packages/tailwindcss/src/canonicalize-candidates.test.ts`
- `source ~/.nvm/nvm.sh && nvm use 22.14.0 && pnpm prettier --check
packages/tailwindcss/src/canonicalize-candidates.ts
packages/tailwindcss/src/canonicalize-candidates.test.ts`

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-05-14 09:59:19 +00:00
Robin Malfait
eefe64593a
Cleanup tests (#20053)
This PR does some cleanup work in the tests such that every test is
using the custom test helpers instead of creating the compiler itself
and using other test helpers such as `optimizeCss` or `pretty`.

For tests we have some helpers that introduce a small layer of
indirection. While you typically want to avoid indirection, this one
allows us to keep the tests the same whenever we make changes to the
internals.

It also allows us to run through the full core flow where we have to
setup a compiler, pass in the CSS, compile the candidates, optimize the
CSS and pretty print it for snapshot purposes.

Our tests often look like this when you are testing some candidates:
```ts
test('…', async () => {
  expect(await run(['flex', 'hover:flex'])).toMatchInlineSnapshot(`
    "
    .flex {
      display: flex;
    }

    @media (hover: hover) {
      .hover\\:flex:hover {
        display: flex;
      }
    }
    "
  `)
})
```

If you need to compile some CSS, we used the following:
```ts
test('…', async () => {
  expect(
    await compileCss(css`
      @tailwind utilities;
      .foo {
        @apply flex;
      }
    `),
  ).toMatchInlineSnapshot(`
    "
    .foo {
      display: flex;
    }
    "
  `)
})
```

With this PR, I normalized the tests by changing the signatures of those
tests slightly:
```ts
export async function run(
  // A list of candidates
  candidates: string[],

  // Optionally, custom CSS
  input = css`
    @tailwind utilities;
  `,

  // Optionally, custom compile options
  options: Parameters<typeof compile>[1] = {},
) {
  let { build } = await compile(input, options)
  return pretty(optimize(build(candidates)).code)
}

// Same as above, but without the candidates
export async function compileCss(css: string, options: Parameters<typeof compile>[1] = {}) {
  return run([], css, options)
}
```

They are very similar, but if we migrate _everything_ to `run`, then
there will be situations where you have to use:
```ts
test('…', async () => {
  expect(
    await run(
      [],
      css`
        @tailwind utilities;
        .foo {
          @apply flex;
        }
      `,
    ),
  ).toMatchInlineSnapshot(`
    "
    .foo {
      display: flex;
    }
    "
  `)
})
```

Which is a little bit confusing because what does `[]` even mean?

The next big change is that a lot of the tests were manually setting up
the compiler, passing in the CSS and compiler options, then building the
candidates, then manually optimizing the CSS and then manually pretty
printing the resulting CSS for snapshot purposes.

Other tests, didn't use all those steps and skipped the optimization
step for example. This means that not all tests were testing the
end-to-end core workflow.

This PR uses the tests helpers wherever we could. This now also means
that our tests look the same as much as possible.

The biggest goal of this was to get everything in a similar state.
Future PRs that add additional optimizations will now see those
optimizations reflected in the actual test output.

## Test plan

1. All tests still pass
- Some test _output_ has changed because the `optimizeCss` will now kick
in. But this reflects production better anyway.
2. No source code was changed
2026-05-14 11:43:34 +02:00
depfu[bot]
ece4824f0d
Update jiti 2.6.1 → 2.7.0 (minor) (#20044)
Here is everything you need to know about this update. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ jiti (2.6.1 → 2.7.0) · [Repo](https://github.com/unjs/jiti) ·
[Changelog](https://github.com/unjs/jiti/blob/main/CHANGELOG.md)



<details>
<summary>Release Notes</summary>
<h4><a
href="https://github.com/unjs/jiti/releases/tag/v2.7.0">2.7.0</a></h4>

<blockquote><p dir="auto"><a
href="https://bounce.depfu.com/github.com/unjs/jiti/compare/v2.6.1...v2.7.0">compare
changes</a></p>
<h3 dir="auto">🚀 Enhancements</h3>
<ul dir="auto">
<li>Add explicit resource management (<code
class="notranslate">using</code>/<code class="notranslate">await
using</code>) support (<a
href="https://bounce.depfu.com/github.com/unjs/jiti/pull/422">#422</a>)</li>
<li>Support opt-in <code class="notranslate">tsconfigPaths</code> (<a
href="https://bounce.depfu.com/github.com/unjs/jiti/pull/427">#427</a>)</li>
<li>Support virtual modules (<a
href="https://bounce.depfu.com/github.com/unjs/jiti/pull/428">#428</a>)</li>
<li>Add <code class="notranslate">jiti/static</code> subpath (<a
href="https://bounce.depfu.com/github.com/unjs/jiti/pull/430">#430</a>)</li>
</ul>
<h3 dir="auto">🔥 Performance</h3>
<ul dir="auto">
<li>
<strong>interopDefault:</strong> Add caching to reduce proxy overhead by
~2x (<a
href="https://bounce.depfu.com/github.com/unjs/jiti/pull/421">#421</a>)</li>
</ul>
<h3 dir="auto">🩹 Fixes</h3>
<ul dir="auto">
<li>
<strong>require:</strong> Passthrough resolve options (<a
href="https://bounce.depfu.com/github.com/unjs/jiti/pull/412">#412</a>)</li>
<li>
<strong>require:</strong> Fallback to transpilation when <code
class="notranslate">tryNative</code> fails (<a
href="https://bounce.depfu.com/github.com/unjs/jiti/pull/413">#413</a>)</li>
<li>Fallback for <code class="notranslate">ENAMETOOLONG</code> when
evaluating esm (<a
href="https://bounce.depfu.com/github.com/unjs/jiti/pull/429">#429</a>)</li>
</ul>
<h3 dir="auto">📦 Build</h3>
<ul dir="auto">
<li>Upgrade rspack to v2 (<a
href="55194fb">55194fb</a>)</li>
<li>Experimental rolldown config (<a
href="8c0243f">8c0243f</a>)</li>
</ul>
<h3 dir="auto">✅ Tests</h3>
<ul dir="auto">
<li>Ignore jsx test for bun/cjs (<a
href="3a744ca">3a744ca</a>)</li>
</ul>
<h3 dir="auto">❤️ Contributors</h3>
<ul dir="auto">
<li>Pooya Parsa (<a
href="https://bounce.depfu.com/github.com/pi0">@pi0</a>)</li>
<li>Kricsleo (<a
href="https://bounce.depfu.com/github.com/kricsleo">@kricsleo</a>)</li>
<li>Espen Hovlandsdal (<a
href="https://bounce.depfu.com/github.com/rexxars">@rexxars</a>)</li>
<li>Rintaro Itokawa (<a
href="https://bounce.depfu.com/github.com/re-taro">@re-taro</a>)</li>
<li>Matteo Collina (<a
href="https://bounce.depfu.com/github.com/mcollina">@mcollina</a>)</li>
<li>Mario Zechner (<a
href="https://bounce.depfu.com/github.com/badlogic">@badlogic</a>)</li>
</ul></blockquote>
<p><em>Does any of this look wrong? <a
href="feedback">Please let us
know.</a></em></p>
</details>

<details>
<summary>Commits</summary>
<p><a
href="aedcdee7fe...fd3bb289b7">See
the full diff on Github</a>. The new version differs by 29 commits:</p>
<ul>
<li><a
href="fd3bb289b7"><code>chore(release):
v2.7.0</code></a></li>
<li><a
href="27fe3f2a49"><code>chore:
update release script</code></a></li>
<li><a
href="4fcd2f23aa"><code>fix:
fallback for `ENAMETOOLONG` when evaluating esm (#429)</code></a></li>
<li><a
href="8c0243f14e"><code>build:
experimental rolldown config</code></a></li>
<li><a
href="55194fbb97"><code>build:
upgrade rspack</code></a></li>
<li><a
href="0abda72c11"><code>ci:
update node test matrix</code></a></li>
<li><a
href="8c7822ef2f"><code>chore:
update tsconfig</code></a></li>
<li><a
href="08fc868c92"><code>chore:
update deps</code></a></li>
<li><a
href="5d552e3beb"><code>feat:
add `jiti/static` export (#430)</code></a></li>
<li><a
href="ae790b0214"><code>feat:
support virtual modules option (#428)</code></a></li>
<li><a
href="a3e705dbdc"><code>fix(require):
fallback to transpilation when `tryNative` fails (#413)</code></a></li>
<li><a
href="4deba16283"><code>chore:
update agents.md</code></a></li>
<li><a
href="f85b0e523d"><code>lint</code></a></li>
<li><a
href="3ce242653f"><code>feat:
support opt-in `tsconfigPaths` (#427)</code></a></li>
<li><a
href="c49c54e42e"><code>chore:
init agents.md</code></a></li>
<li><a
href="b66bd233c2"><code>feat:
add explicit resource management (using/await using) support
(#422)</code></a></li>
<li><a
href="a467d31ffc"><code>perf(interopDefault):
add caching to reduce proxy overhead by ~2x (#421)</code></a></li>
<li><a
href="fe264b49f1"><code>fix(ci):
skip `--coverage` flag for node 18</code></a></li>
<li><a
href="058d91a338"><code>chore:
lint</code></a></li>
<li><a
href="650bc48a76"><code>chore:
add missing prettier dep</code></a></li>
<li><a
href="e28b5e9645"><code>chore(deps):
update autofix-ci/action digest to 7a166d7 (#424)</code></a></li>
<li><a
href="31096aa9e2"><code>chore(deps):
update actions/checkout action to v6 (#419)</code></a></li>
<li><a
href="498e8d73a4"><code>chore:
update deps</code></a></li>
<li><a
href="9ee314fd0f"><code>test:
update</code></a></li>
<li><a
href="3a744ca271"><code>test:
ignore jsx test for bun/cjs</code></a></li>
<li><a
href="e88ac44079"><code>chore:
update deps</code></a></li>
<li><a
href="4045c7a2a2"><code>chore:
fix lint issues</code></a></li>
<li><a
href="39e86de57c"><code>chore(deps):
update actions/setup-node action to v6 (#411)</code></a></li>
<li><a
href="ddb4683d37"><code>fix(require):
passthrough resolve options (#412)</code></a></li>
</ul>
</details>












---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-05-13 12:02:13 +02:00
depfu[bot]
57521613ef
Update postcss 8.5.10 → 8.5.14 (patch) (#20040)
Here is everything you need to know about this upgrade. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ postcss (8.5.10 → 8.5.14) ·
[Repo](https://github.com/postcss/postcss) ·
[Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)



<details>
<summary>Release Notes</summary>
<h4><a
href="https://github.com/postcss/postcss/releases/tag/8.5.14">8.5.14</a></h4>

<blockquote><ul dir="auto">
<li>Fixed custom syntax regression (by <a
href="https://bounce.depfu.com/github.com/43081j">@43081j</a>).</li>
</ul></blockquote>
<h4><a
href="https://github.com/postcss/postcss/releases/tag/8.5.13">8.5.13</a></h4>

<blockquote><ul dir="auto">
<li>Fixed <code class="notranslate">postcss-scss</code> commend
regression.</li>
</ul></blockquote>
<h4><a
href="https://github.com/postcss/postcss/releases/tag/8.5.12">8.5.12</a></h4>

<blockquote><ul dir="auto">
<li>Fixed reading any file via user-generated CSS.</li>
<li>Added <code class="notranslate">opts.unsafeMap</code> to disable
checks.</li>
</ul></blockquote>
<h4><a
href="https://github.com/postcss/postcss/releases/tag/8.5.11">8.5.11</a></h4>

<blockquote><ul dir="auto">
<li>Fixed nested brackets parsing performance (by <a
href="https://bounce.depfu.com/github.com/offset">@offset</a>).</li>
</ul></blockquote>
<p><em>Does any of this look wrong? <a
href="feedback">Please let us
know.</a></em></p>
</details>

<details>
<summary>Commits</summary>
<p><a
href="33b9790263...3ec13948ae">See
the full diff on Github</a>. The new version differs by 21 commits:</p>
<ul>
<li><a
href="3ec13948ae"><code>Release
8.5.14 version</code></a></li>
<li><a
href="f2bb827b20"><code>Update
dependencies</code></a></li>
<li><a
href="d75953d608"><code>Merge
pull request #2084 from 43081j/raw-raws-rawing</code></a></li>
<li><a
href="68bd2139b5"><code>fix:
always call `raw` to retrieve raw values</code></a></li>
<li><a
href="af58cf1b7a"><code>Release
8.5.13 version</code></a></li>
<li><a
href="f227dbd0e9"><code>Temporary
ignore pnpm 11 config</code></a></li>
<li><a
href="d3abd40d72"><code>Update
dependencies</code></a></li>
<li><a
href="dd06c3e113"><code>Revert
stringifier changes because of the conflict with
postcss-scss</code></a></li>
<li><a
href="ae889c815f"><code>Try
to fix CI</code></a></li>
<li><a
href="e0093e49bc"><code>Move
to pnpm 11</code></a></li>
<li><a
href="9bc81c48f0"><code>Release
8.5.12 version</code></a></li>
<li><a
href="85c4d7dab8"><code>Another
try to fix coverage</code></a></li>
<li><a
href="94484cae6d"><code>Try
to fix coverage</code></a></li>
<li><a
href="c64b7488d2"><code>Load
only .map source maps</code></a></li>
<li><a
href="aaec7b78b3"><code>Avoid
throwing JSON parsing errors for non-JSON source maps</code></a></li>
<li><a
href="233fb264ea"><code>Mention
original author of the solution</code></a></li>
<li><a
href="2502f75030"><code>Release
8.5.11 version</code></a></li>
<li><a
href="5ca1901949"><code>Speed
up parsing many nested brackets</code></a></li>
<li><a
href="42b5337dd7"><code>Update
dependencies</code></a></li>
<li><a
href="7e36e153d0"><code>Cache
node.raws locally in Stringifier hot methods</code></a></li>
<li><a
href="8ec62b157b"><code>Bypass
MapGenerator for no-source-map stringify in LazyResult</code></a></li>
</ul>
</details>












---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-05-12 15:21:44 +00:00
depfu[bot]
bcd581c568
🚨 [security] Update next 16.2.4 → 16.2.6 (patch) (#20039)
<hr>

🚨 <b>Your current dependencies have known security vulnerabilities</b> 🚨

This dependency update fixes known security vulnerabilities. Please see
the details below and assess their impact carefully. We recommend to
merge and deploy this as soon as possible!
<hr>



Here is everything you need to know about this upgrade. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ next (16.2.4 → 16.2.6) ·
[Repo](https://github.com/vercel/next.js)


<details>

<summary>Security Advisories 🚨</summary>
<h4><a
href="https://bounce.depfu.com/github.com/facebook/react/security/advisories/GHSA-rv78-f8rc-xrxh">🚨
Next.js Vulnerable to Denial of Service with Server Components</a></h4>
<blockquote><p dir="auto">A vulnerability affects certain React Server
Components packages for versions 19.x and frameworks that use the
affected packages, including Next.js 13.x, 14.x, 15.x, and 16.x using
the App Router. The issue is tracked upstream as <a
href="https://bounce.depfu.com/github.com/facebook/react/security/advisories/GHSA-rv78-f8rc-xrxh">CVE-2026-23870</a>.</p>
<p dir="auto">A specially crafted HTTP request can be sent to any App
Router Server Function endpoint that, when deserialized, may trigger
excessive CPU usage. This can result in denial of service in unpatched
environments.</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-36qx-fr4f-26g5">🚨
Next.js has a Middleware / Proxy bypass in Pages Router applications
using i18n</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">Applications using the Pages Router with <code
class="notranslate">i18n</code> configured and middleware/proxy-based
authorization can allow unauthorized access to protected page data
through locale-less <code
class="notranslate">/_next/data/&lt;buildId&gt;/&lt;page&gt;.json</code>
requests. In affected configurations, middleware does not run for the
unprefixed data route, allowing an attacker to retrieve SSR JSON for
protected pages without passing the intended authorization checks.</p>
<h3 dir="auto">Fix</h3>
<p dir="auto">The matcher logic was updated to perform the same match as
it would on a non-i18n data route.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">If you cannot upgrade immediately, enforce authorization
in the page's server-side data path instead of relying solely on
middleware.</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-267c-6grr-h53f">🚨
Next.js has a Middleware / Proxy bypass in App Router applications via
segment-prefetch routes</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">App Router applications that rely on middleware or
proxy-based checks for authorization can allow unauthorized access
through transport-specific route variants used for segment prefetching.
In affected configurations, specially crafted <code
class="notranslate">.rsc</code> and segment-prefetch URLs can resolve to
the same page without being matched by the intended middleware rule,
which can allow protected content to be reached without the expected
authorization check.</p>
<h3 dir="auto">Fix</h3>
<p dir="auto">We now include App Router transport variants when
generating middleware matchers, so middleware protections are applied
consistently to those requests as well as to the normal page URL.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">If you cannot upgrade immediately, enforce authorization
in the underlying route or page logic instead of relying solely on
middleware.</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-wfc6-r584-vfw7">🚨
Next.js vulnerable to cache poisoning in React Server Component
responses</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">Applications using React Server Components can be
vulnerable to cache poisoning when shared caches do not correctly
partition response variants. Under affected conditions, an attacker can
cause an RSC response to be served from the original URL and poison
shared cache entries so later visitors receive component payloads
instead of the expected HTML.</p>
<h3 dir="auto">Fix</h3>
<p dir="auto">We now validate and interpret <code
class="notranslate">RSC</code> request headers consistently across
request classification and rendering, and we enforce the intended
cache-busting behavior so RSC payloads are not unexpectedly served from
the original URL.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">If you cannot upgrade immediately, ensure your CDN or
reverse proxy keys on the relevant RSC request headers and honors <code
class="notranslate">Vary</code>, or disable shared caching for affected
App Router and RSC responses.</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-492v-c6pp-mqqv">🚨
Next.js has a Middleware / Proxy bypass through dynamic route parameter
injection</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">Applications that rely on middleware to protect dynamic
routes can be vulnerable to authorization bypass. In affected
deployments, specially crafted query parameters can alter the dynamic
route value seen by the page while leaving the visible path unchanged,
which can allow protected content to be rendered without passing the
expected middleware check.</p>
<h3 dir="auto">Fix</h3>
<p dir="auto">We now only honor internal route-parameter normalization
in trusted routing flows and ignore externally supplied parameter
encodings that should never have been accepted from ordinary
requests.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">If you cannot upgrade immediately, enforce authorization
in route or page logic instead of relying solely on middleware path
matching.</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-c4j6-fc7j-m34r">🚨
Next.js vulnerable to server-side request forgery in applications using
WebSocket upgrades</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">Self-hosted applications using the built-in Node.js server
can be vulnerable to server-side request forgery through crafted
WebSocket upgrade requests. An attacker can cause the server to proxy
requests to arbitrary internal or external destinations, which may
expose internal services or cloud metadata endpoints. Vercel-hosted
deployments are not affected.</p>
<h3 dir="auto">Fix</h3>
<p dir="auto">We now apply the same safety checks to WebSocket upgrade
handling that already existed for normal HTTP requests, so upgrade
requests are only proxied when routing has explicitly marked them as
safe external rewrites.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">If you cannot upgrade immediately, do not expose the
origin server directly to untrusted networks. If WebSocket upgrades are
not required, block them at your reverse proxy or load balancer, and
restrict origin egress to internal networks and metadata services where
possible.</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-h64f-5h5j-jqjh">🚨
Next.js has a Denial of Service in the Image Optimization API</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">When self-hosting Next.js with the default image loader,
the Image Optimization API fetches local images entirely into memory
without enforcing a maximum size limit. An attacker could cause
out-of-memory conditions by requesting large local assets from the <code
class="notranslate">/_next/image</code> endpoint that match the <code
class="notranslate">images.localPatterns</code> configuration (by
default, all patterns are allowed).</p>
<ul dir="auto">
<li>If you are using <code
class="notranslate">images.localPatterns</code>, only the patterns in
that array are impacted.</li>
<li>If you are using <code class="notranslate">images.unoptimized:
true</code>, you are NOT impacted.</li>
<li>If you are using <code class="notranslate">images.loader:
'custom'</code>, you are NOT impacted.</li>
<li>If you are using Vercel, you are NOT impacted.</li>
</ul>
<h3 dir="auto">Fix</h3>
<p dir="auto">We now apply response size limits consistently to internal
image fetches, not just external ones, and fail oversized responses
before they can exhaust process memory.</p>
<p dir="auto">This can be adjusted using the <code
class="notranslate">images.maximumResponseBody</code> configuration.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">If you cannot upgrade immediately, avoid routing large
local assets through <code class="notranslate">/_next/image</code>,
disable image optimization for large or untrusted local files, or block
image optimization access to those assets at the edge.</p>
<p dir="auto">You can disable using the <code
class="notranslate">images.localPatterns: []</code> configuration. This
will still allow fetching remote images (which is not
impacted).</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-mg66-mrh9-m8jx">🚨
Next.js vulnerable to Denial of Service via connection exhaustion in
applications using Cache Components</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">Applications using Partial Prerendering through the Cache
Components feature can be vulnerable to connection exhaustion through
crafted POST requests to a server action. In affected configurations, a
malicious request can trigger a request-body handling deadlock that
leaves connections open for an extended period, consuming file
descriptors and server capacity until legitimate users are denied
service.</p>
<h3 dir="auto">Fix</h3>
<p dir="auto">We now treat the header used for resuming Partial
Prerendered requests as an internal-only header and strip it from
untrusted incoming requests. This header should never be accepted
directly from external clients.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">If you cannot upgrade immediately, block requests that
would be handled by Next.js if they contain the <code
class="notranslate">Next-Resume</code> header at the
edge.</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-gx5p-jg67-6x7h">🚨
Next.js has cross-site scripting in beforeInteractive scripts with
untrusted input</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">Applications that use <code
class="notranslate">beforeInteractive</code> scripts together with
untrusted content can be vulnerable to cross-site scripting. In affected
versions, serialized script content was not escaped safely before being
embedded into the document, which could allow attacker-controlled input
to break out of the intended script context and execute arbitrary
JavaScript in a visitor's browser.</p>
<h3 dir="auto">Fix</h3>
<p dir="auto">We now HTML-escape serialized <code
class="notranslate">beforeInteractive</code> script content before
embedding it into the page, preventing attacker-controlled content from
breaking out of the inline script boundary.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">If you cannot upgrade immediately, do not pass untrusted
data into <code class="notranslate">beforeInteractive</code> scripts. If
that pattern is unavoidable, sanitize or escape the content before
embedding it.</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-vfv6-92ff-j949">🚨
Next.js vulnerable to cache poisoning via collisions in React Server
Component cache-busting</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">React Server Component responses can be vulnerable to
cache poisoning in deployments that rely on shared caches with
insufficient response partitioning. In affected conditions, collisions
in the <code class="notranslate">_rsc</code> cache-busting value can
allow an attacker to poison cache entries so users receive the wrong
response variant for a given URL.</p>
<h3 dir="auto">Fix</h3>
<p dir="auto">We strengthened the <code class="notranslate">_rsc</code>
cache-busting mechanism to make practical collisions significantly
harder and to better separate response variants that should not share
cache entries.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">If you cannot upgrade immediately, ensure intermediary
caches correctly honor <code class="notranslate">Vary</code> for
RSC-related request headers, or disable shared caching for affected RSC
responses until you can deploy a patched release.</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-ffhc-5mcf-pf4q">🚨
Next.js vulnerable to cross-site scripting in App Router applications
using CSP nonces</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">App Router applications that rely on CSP nonces can be
vulnerable to stored cross-site scripting when deployed behind shared
caches. In affected versions, malformed nonce values derived from
request headers could be reflected into rendered HTML in an unsafe way,
allowing an attacker to poison cached responses and cause script
execution for later visitors.</p>
<h3 dir="auto">Fix</h3>
<p dir="auto">We now reject or ignore malformed nonce values before they
are embedded into HTML and apply stricter nonce sanitization so
request-derived nonce data cannot break out of the intended attribute
context.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">If you cannot upgrade immediately, strip inbound <code
class="notranslate">Content-Security-Policy</code> request headers from
untrusted traffic.</p></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-3g8h-86w9-wvmq">🚨
Next.js's Middleware / Proxy redirects can be cache-poisoned</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">Next.js uses the <code
class="notranslate">x-nextjs-data</code> request header for internal
data requests. On affected versions, an external client could send this
header on a normal request to a path handled by middleware that returns
a redirect.</p>
<p dir="auto">When that happened, the middleware/proxy could treat the
request as a data request and replace the standard <code
class="notranslate">Location</code> redirect header with the internal
<code class="notranslate">x-nextjs-redirect</code> header. Browsers do
not follow <code class="notranslate">x-nextjs-redirect</code>, so the
response became an unusable redirect for normal clients.</p>
<p dir="auto">If the application was deployed behind a CDN or reverse
proxy that caches 3xx responses without varying on this header, a single
attacker request could poison the cached redirect response for the
affected path. Subsequent visitors could then receive a cached redirect
response without a <code class="notranslate">Location</code> header,
causing a denial of service for that redirect path until the cache entry
expired or was purged.</p>
<h3 dir="auto">Affected scenarios</h3>
<p dir="auto">This affects applications that:</p>
<ul dir="auto">
<li>use middleware or proxy redirects</li>
<li>are deployed behind a caching CDN or reverse proxy</li>
<li>allow 3xx responses on those paths to be cached without
differentiating internal data requests from normal requests</li>
</ul>
<h3 dir="auto">Fix</h3>
<p dir="auto">The fix stops trusting <code
class="notranslate">x-nextjs-data</code> by itself for middleware
redirect handling. A request is now treated as an internal data request
only when it is validated as such by internal routing state, preserving
legitimate data-request redirect behavior while preventing external
header injection from changing normal redirect responses.</p>
<h3 dir="auto">Workarounds</h3>
<p dir="auto">Before upgrading, users can reduce risk by:</p>
<ul dir="auto">
<li>configuring the CDN or reverse proxy to vary its cache key on <code
class="notranslate">x-nextjs-data</code> for affected responses</li>
</ul></blockquote>
<h4><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-267c-6grr-h53f">🚨
Next.js has a Middleware / Proxy bypass in App Router applications via
segment-prefetch routes - Incomplete Fix Follow-Up</a></h4>
<blockquote><h3 dir="auto">Impact</h3>
<p dir="auto">It was found that the fix addressing <a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-267c-6grr-h53f">CVE-2026-44575</a>
did not apply to <code class="notranslate">middleware.ts</code> with
Turbopack. Refer to <a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-267c-6grr-h53f">CVE-2026-44575</a>
for further details.</p>
<h3 dir="auto">References</h3>
<ul dir="auto">
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-267c-6grr-h53f">CVE
CVE-2026-44575</a></li>
</ul></blockquote>
</details>

<details>
<summary>Release Notes</summary>
<h4><a
href="https://github.com/vercel/next.js/releases/tag/v16.2.6">16.2.6</a></h4>

<blockquote><p dir="auto">This release contains security fixes for the
following advisories:</p>
<p dir="auto">High:</p>
<ul dir="auto">
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-8h8q-6873-q5fj">GHSA-8h8q-6873-q5fj:
Denial of Service with Server Components</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-267c-6grr-h53f">GHSA-267c-6grr-h53f:
Middleware / Proxy bypass in App Router applications via
segment-prefetch routes</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-26hh-7cqf-hhc6">GHSA-26hh-7cqf-hhc6:
Middleware / Proxy bypass in App Router applications via
segment-prefetch routes - Incomplete Fix Follow-Up</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-mg66-mrh9-m8jx">GHSA-mg66-mrh9-m8jx:
Denial of Service via connection exhaustion in applications using Cache
Components</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-492v-c6pp-mqqv">GHSA-492v-c6pp-mqqv:
Middleware / Proxy bypass through dynamic route parameter
injection</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-c4j6-fc7j-m34r">GHSA-c4j6-fc7j-m34r:
Server-side request forgery in applications using WebSocket
upgrades</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-36qx-fr4f-26g5">GHSA-36qx-fr4f-26g5:
Middleware / Proxy bypass in Pages Router applications using
i18n</a></li>
</ul>
<p dir="auto">Moderate:</p>
<ul dir="auto">
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-ffhc-5mcf-pf4q">GHSA-ffhc-5mcf-pf4q:
Cross-site scripting in App Router applications using CSP
nonces</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-gx5p-jg67-6x7h">GHSA-gx5p-jg67-6x7h:
Cross-site scripting in beforeInteractive scripts with untrusted
input</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-h64f-5h5j-jqjh">GHSA-h64f-5h5j-jqjh:
Denial of Service in the Image Optimization API</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-wfc6-r584-vfw7">GHSA-wfc6-r584-vfw7:
Cache poisoning in React Server Component responses</a></li>
</ul>
<p dir="auto">Low:</p>
<ul dir="auto">
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-vfv6-92ff-j949">GHSA-vfv6-92ff-j949:
Cache poisoning via collisions in React Server Component
cache-busting</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-3g8h-86w9-wvmq">GHSA-3g8h-86w9-wvmq:
Middleware / Proxy redirects can be cache-poisoned</a></li>
</ul></blockquote>
<h4><a
href="https://github.com/vercel/next.js/releases/tag/v16.2.5">16.2.5</a></h4>

<blockquote><p dir="auto">This release contains security fixes for the
following advisories:</p>
<p dir="auto">High:</p>
<ul dir="auto">
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-8h8q-6873-q5fj">GHSA-8h8q-6873-q5fj:
Denial of Service with Server Components</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-267c-6grr-h53f">GHSA-267c-6grr-h53f:
Middleware / Proxy bypass in App Router applications via
segment-prefetch routes</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-mg66-mrh9-m8jx">GHSA-mg66-mrh9-m8jx:
Denial of Service via connection exhaustion in applications using Cache
Components</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-492v-c6pp-mqqv">GHSA-492v-c6pp-mqqv:
Middleware / Proxy bypass through dynamic route parameter
injection</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-c4j6-fc7j-m34r">GHSA-c4j6-fc7j-m34r:
Server-side request forgery in applications using WebSocket
upgrades</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-36qx-fr4f-26g5">GHSA-36qx-fr4f-26g5:
Middleware / Proxy bypass in Pages Router applications using
i18n</a></li>
</ul>
<p dir="auto">Moderate:</p>
<ul dir="auto">
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-ffhc-5mcf-pf4q">GHSA-ffhc-5mcf-pf4q:
Cross-site scripting in App Router applications using CSP
nonces</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-gx5p-jg67-6x7h">GHSA-gx5p-jg67-6x7h:
Cross-site scripting in beforeInteractive scripts with untrusted
input</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-h64f-5h5j-jqjh">GHSA-h64f-5h5j-jqjh:
Denial of Service in the Image Optimization API</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-wfc6-r584-vfw7">GHSA-wfc6-r584-vfw7:
Cache poisoning in React Server Component responses</a></li>
</ul>
<p dir="auto">Low:</p>
<ul dir="auto">
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-vfv6-92ff-j949">GHSA-vfv6-92ff-j949:
Cache poisoning via collisions in React Server Component
cache-busting</a></li>
<li><a
href="https://bounce.depfu.com/github.com/vercel/next.js/security/advisories/GHSA-3g8h-86w9-wvmq">GHSA-3g8h-86w9-wvmq:
Middleware / Proxy redirects can be cache-poisoned</a></li>
</ul></blockquote>
<p><em>Does any of this look wrong? <a
href="feedback">Please let us
know.</a></em></p>
</details>

<details>
<summary>Commits</summary>
<p><a
href="2275bd8598...ee6e79b179">See
the full diff on Github</a>. The new version differs by 36 commits:</p>
<ul>
<li><a
href="ee6e79b179"><code>v16.2.6</code></a></li>
<li><a
href="afa053d9eb"><code>Turbopack:
Match proxy matchers with webpack implementation
(#93594)</code></a></li>
<li><a
href="97a154e5bb"><code>Turbopack:
Fix middleware matcher suffix (#93590)</code></a></li>
<li><a
href="83899bc891"><code>[backport]
Disable build caches for production/staging/force-preview deploys
(#93586)</code></a></li>
<li><a
href="7b222b9095"><code>[backport][test]
Pin package manager to patch versions (#93595)</code></a></li>
<li><a
href="a8dc24f1fe"><code>[backport]
Turbopack: more strict vergen setup (#93587)</code></a></li>
<li><a
href="766148f9cd"><code>v16.2.5</code></a></li>
<li><a
href="0dd94836a8"><code>fix:
add explicit checks for RSC header (#83) (#98)</code></a></li>
<li><a
href="d166096c39"><code>fix
proxy matching for segment prefetch URLs (#89) (#96)</code></a></li>
<li><a
href="9d50c0b719"><code>Strip
next-resume header from incoming requests (#92)</code></a></li>
<li><a
href="df7ab5ad72"><code>fix:
skip internal param normalization in unsupported
environments</code></a></li>
<li><a
href="ed41d1d454"><code>Move
htmlescape to shared/lib (#91)</code></a></li>
<li><a
href="b4c6705c70"><code>Ignore
malformed CSP nonce headers</code></a></li>
<li><a
href="5b194ee2d4"><code>router-server:
guard upgrade proxy against absolute-url SSRF (#77)</code></a></li>
<li><a
href="cb171d7494"><code>Fix
i18n middleware matching for default-locale data routes
(#82)</code></a></li>
<li><a
href="89e995431a"><code>[16.x]
Type hardening and performance improvements (#80)</code></a></li>
<li><a
href="66f6017f15"><code>Escape
properties for beforeInteractive scripts (#86)</code></a></li>
<li><a
href="3d98505a24"><code>[backport]
fix: preserve HTTP access fallbacks during prerender recovery
(#93470)</code></a></li>
<li><a
href="bb5ada6e38"><code>[backport]
[test] Deflake `instant-navs-devtools` (#93534)</code></a></li>
<li><a
href="f1c11203d5"><code>[backport]
Fix double-encoding of URL pathname parts in client param parsing
(#93506)</code></a></li>
<li><a
href="2d08397b3d"><code>[backport]
fix accidental test duplication (#93507)</code></a></li>
<li><a
href="75d19ecbb3"><code>[backport]
Include deployment id in `cacheHandlers` keys (#93471)</code></a></li>
<li><a
href="7ab1e2e93d"><code>CI:
Download and run self-contained datadog-ci instead of using pnpm dlx or
npx (#92546)</code></a></li>
<li><a
href="084f2bcf19"><code>[ci]:
trigger signed release commit via API (#93285)</code></a></li>
<li><a
href="a3bb370b00"><code>[ci]:
app-based release workflow (#93245)</code></a></li>
<li><a
href="6e23383c56"><code>[ci]:
add environment to publishRelease flow (#93093)</code></a></li>
<li><a
href="f40b8876e6"><code>[ci]:
remove publish token in favor of OIDC (#93065)</code></a></li>
<li><a
href="f6bda26ef9"><code>Fix
fallback route params case in app-page handler (#93109)</code></a></li>
<li><a
href="70defda2a8"><code>[ci]:
switch to GitHub runners (#93164)</code></a></li>
<li><a
href="af0e96ba23"><code>Fix
invalid HTML response for route-level RSC requests in deployment adapter
(#91541)</code></a></li>
<li><a
href="2cdb7ed34f"><code>[tests]:
fix cache-components.test.ts type error (#93113)</code></a></li>
<li><a
href="8cd3fdc111"><code>test:
scope css data-url typing to fixture (#91877)</code></a></li>
<li><a
href="6fd09bf8ab"><code>Patch
setHeader for direct route handlers (#93101)</code></a></li>
<li><a
href="688ed31e21"><code>Strengthen
_rsc cache-busting param (#92755)</code></a></li>
<li><a
href="62ef305096"><code>fix(next/image):
ensure `images.maximumResponseBody` applies to local images too
(#92920)</code></a></li>
<li><a
href="15341fdf49"><code>Ensure
x-nextjs-data header is only set during resolve (#92752)</code></a></li>
</ul>
</details>












---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-05-12 17:12:36 +02:00
Robin Malfait
54d6b05b44
move localRequire to else branch 2026-05-12 11:19:48 +02:00
Antoine Lethimonnier
fc432e0fd5
fix(node): prefer Module#registerHooks over Module#register (#20028)
<!--

👋 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?

-->

Fix this warning when building apps with TailwindCSS with Node 26+:
```
(node:25346) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
    at node:internal/util:129:11
    at Module.register (node:internal/modules/esm/loader:969:3)
    at file:///Users/antoine/Developer/my-app/node_modules/.pnpm/@tailwindcss+node@4.3.0/node_modules/@tailwindcss/node/dist/index.mjs:18:214
    at ...
```

This PR:
- Correctly prefers using `Module#registerHooks` instead of
`Module#register` by checking its availability at runtime
- Adjusts the exports of the hooks’ file by creating a synchronous
version for the new API
- Remove now unused exports from the `package.json`, relying on the
[recommended usage in the
docs](https://nodejs.org/docs/latest/api/module.html#registration-of-asynchronous-customization-hooks)
for the `Module#register` calls

## 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.

-->

I'd be happy to ensure this works correctly on my end before merging
this, but it's not as trivial to test locally as a logic fix. Can you
guide me through what I need to do to test this?

I extensively based my change on the docs, following those guides:
- [Fixing imports for
`Module#register`](https://nodejs.org/docs/latest/api/module.html#registration-of-asynchronous-customization-hooks)
- [Using the new `Module#registerHooks`
function](https://nodejs.org/docs/latest/api/module.html#registration-of-synchronous-customization-hooks)

(and other more detailed sections of the same docs page)

---

Fixes #19893
Closes #19907

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-05-11 14:25:28 +00:00
depfu[bot]
ae96721fc5
Update @tybys/wasm-util 0.10.1 → 0.10.2 (minor) (#20031)
Here is everything you need to know about this update. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ @​tybys/wasm-util (0.10.1 → 0.10.2) ·
[Repo](https://github.com/toyobayashi/wasm-util)





Sorry, we couldn't find anything useful about this release.











---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-05-11 12:09:58 +02:00
Robin Malfait
f9216b2b8c
update changelog 2026-05-08 22:18:22 +02:00
Robin Malfait
588bd7371f
4.3.0 (#20023) 2026-05-08 22:03:59 +02:00
Robin Malfait
59936c6cbb
Add tab-* utilities (#20022)
This PR adds new `tab-*` utilities.

The `tab-*` utilities set the `tab-size` property. They support positive
integer bare values, and arbitrary values:

| Class | CSS |
| -- | -- |
| `tab-2` | `tab-size: 2;` |
| `tab-[12px]` | `tab-size: 12px;` |

This also adds `tab-size` to the property order near the other text
layout properties.

## Test plan

1. Added new `tab-*` utility tests
2. Updated the intellisense snapshot
3. Other existing tests still pass
2026-05-08 21:22:07 +02:00
Robin Malfait
90a2373620
add zoom-* utilities (#20020)
This PR adds new `zoom-*` utilities.

The `zoom-*` utilities accept bare values that are transformed into `%`
based values, and arbitrary values:

| Class | CSS |
| -- | -- |
| `zoom-50` | `zoom: 50%;` |
| `zoom-[1.1]` | `zoom: 1.1;` |
| `zoom-(--value)` | `zoom: var(--value);` |


This also adds the `zoom` property after the `transform` related
properties. Initially I added it right after `scale`, because logically
they are close together. But then `zoom-*` would sit between `scale` and
other tranfsorm related properties which is a bit weird. Instead, I
moved it after `transform`.

## Test plan

1. Added new zoom based tests
2. All other tests still pass
2026-05-08 21:15:59 +02:00
Robin Malfait
2e1ccf7f11
Add scrollbar-gutter-* utilities (#20018)
This PR adds new `scrollbar-gutter-*` utilities. The API looks like
this:

| Class | CSS |
| -- | -- |
| `scrollbar-gutter-auto` | `scrollbar-gutter: auto` |
| `scrollbar-gutter-stable` | `scrollbar-gutter: stable` |
| `scrollbar-gutter-both` | `scrollbar-gutter: stable both-edges` |
2026-05-08 21:13:06 +02:00
depfu[bot]
c00da894dd
Update listhen 1.9.1 → 1.10.0 (minor) (#20017)
Here is everything you need to know about this update. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ listhen (1.9.1 → 1.10.0) ·
[Repo](https://github.com/unjs/listhen) ·
[Changelog](https://github.com/unjs/listhen/blob/main/CHANGELOG.md)



<details>
<summary>Release Notes</summary>
<h4><a
href="https://github.com/unjs/listhen/releases/tag/v1.10.0">1.10.0</a></h4>

<blockquote><p dir="auto"><a
href="https://bounce.depfu.com/github.com/unjs/listhen/compare/v1.9.1...v1.10.0">compare
changes</a></p>
<h3 dir="auto">🚀 Enhancements</h3>
<ul dir="auto">
<li>Support <code class="notranslate">extraURLs</code> and detect <a
href="https://portless.sh/">portless</a> from env by default (<a
href="https://bounce.depfu.com/github.com/unjs/listhen/pull/228">#228</a>)</li>
</ul>
<h3 dir="auto">🩹 Fixes</h3>
<ul dir="auto">
<li>Filter IPv4 link-local addresses from network interfaces (<a
href="https://bounce.depfu.com/github.com/unjs/listhen/pull/226">#226</a>)</li>
<li>Do not use fallback port in production (<a
href="https://bounce.depfu.com/github.com/unjs/listhen/pull/223">#223</a>)</li>
</ul>
<h3 dir="auto">❤️ Contributors</h3>
<ul dir="auto">
<li>Kricsleo (<a
href="https://bounce.depfu.com/github.com/kricsleo">@kricsleo</a>)</li>
<li>Tim Krajcar (<a
href="https://bounce.depfu.com/github.com/tkrajcar">@tkrajcar</a>)</li>
<li>Max (<a
href="https://bounce.depfu.com/github.com/onmax">@onmax</a>)</li>
<li>Pooya Parsa (<a
href="https://bounce.depfu.com/github.com/pi0">@pi0</a>)</li>
</ul></blockquote>
<p><em>Does any of this look wrong? <a
href="feedback">Please let us
know.</a></em></p>
</details>

<details>
<summary>Commits</summary>
<p><a
href="60ba9f2ae2...33c98f262d">See
the full diff on Github</a>. The new version differs by 5 commits:</p>
<ul>
<li><a
href="33c98f262d"><code>fix:
do not use fallback port in production (#223)</code></a></li>
<li><a
href="49ef95e7a7"><code>fix:
filter IPv4 link-local addresses from network interfaces
(#226)</code></a></li>
<li><a
href="de2d49ae00"><code>chore(deps):
update all non-major dependencies (#221)</code></a></li>
<li><a
href="f341eb9f76"><code>feat:
support `extraURLs` and detect portless by env by default
(#228)</code></a></li>
<li><a
href="402c52df87"><code>chore:
update deps</code></a></li>
</ul>
</details>












---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

[ci-all]

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-05-07 12:43:28 +00:00
Robin Malfait
754e7512ca
Use non-existing example in tests (#20021)
This PR makes a small change to the tests. In some cases, where we test
`@utility` functionality we use `tab-*` utilities.

But there is a possibility that we will add this as an actual utility,
so instead let's use a much more generic `example-*` utility. Then we
can read from the `--example` theme value, and use `--resolved-value:
…;` and `--resolved-modifier: …;` values.

It's admittedly more vague, but chances of conflicts go way down. Until
CSS or Tailwind CSS introduces an `example-*` utility...


## Test plan

1. Only tests changed, and all of them still pass
2026-05-07 13:23:24 +02:00
Robin Malfait
12eb5ae7b6
Cleanup noisy test output (#20015)
This PR cleans up the noisy test output where some `console.warn`
messages were leaking into the test output.

This also uses the `src/` files instead of the `dist/` files of
`@tailwindcss/node` to get rid of a source map related warning in tests.
It also means that we don't have to rebuild `@tailwindcss/node` when we
make changes.

## Test plan

1. Existing tests pass
2. Output is clean when running tests (`vitest run --reporter=minimal`)

Before:
<img width="1193" height="1376" alt="image"
src="https://github.com/user-attachments/assets/9aceab62-cd99-4391-9734-0ae5c4c3fb48"
/>

After:
<img width="1099" height="294" alt="image"
src="https://github.com/user-attachments/assets/3ada4a0a-8a7a-48a7-9fbd-f3d5dd8700a5"
/>
2026-05-06 11:10:17 +02:00
Robin Malfait
4255671c5f
Improve snapshot tests (#20013)
This PR improves the snapshot tests. At first, this looks like a very
silly PR, but I swear I have legit reasons for these changes:

First, there are a few places where we use `.toMatchInlineSnapshot()`
for tests where we expect that nothing is being generated. This is fine,
but the issue is that this means that if you're not careful, and if we
have a bug, then these snapshots could start producing something.
Updating these snapshots is _too_ easy. So instead, we convert them to
an explicit `.toEqual('')`

Next, I introduced a `pretty` helper function which is used behind the
scenes in the `compileCss(…)`, `run(…)`, and `optimizeCss(…)` test
helpers. It's very silly and simple, it either returns `''` when the
trimmed input is empty, or it will wrap the result in `\n…\n`. The
reason for this is because of how (inline) snapshots works in Vitest. A
snapshot result will be in double quotes, and inside backticks:
```ts
expect(result.css.trim()).toMatchInlineSnapshot(`
  "@layer utilities {
    .foo {
      color: #000;
    }

    .bar {
      color: red;
    }
  }"
`)
```
This CSS now starts with `"` and ends with `"`. While that is fine, it
starts to get annoying when we have merge conflicts when CSS changes
(this is what triggered me to make this PR because I ran into this a
dozen times already). Because the first and last CSS line also contain a
`"` that you have to keep into account.

Instead we now use `\n` around the output, which makes the tests look
like this:
```ts
expect(pretty(result.css)).toMatchInlineSnapshot(`
  "
  @layer utilities {
    .foo {
      color: #000;
    }

    .bar {
      color: red;
    }
  }
  "
`)
```

In a perfect world, I wish we could use something like:
```css
expect(pretty(result.css)).toMatchInlineSnapshot(css`
  @layer utilities {
    .foo {
      color: #000;
    }

    .bar {
      color: red;
    }
  }
`)
```

That way you are only dealing with CSS, nothing else. Most editors will
show syntax highlighting, and even prettier will do formatting on the
CSS to keep everything consistent.

Unfortunately this also causes issues because when prettier formats
this, then the input/output will not always match. We can solve that by
parsing both sides and compare the ASTs but that would make things
slower.

The biggest issue is that Vitest doesn't support this. You can use
custom serializers
https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers and
domains https://vitest.dev/guide/snapshot.html#custom-snapshot-domain
but this has an annoying issue around escaping values.

When you use `css` it's typically implemented as `const css =
String.raw`, which means that you can write actual CSS instead of JS:
```ts
let input = css`
  .\[color:red\] {
    color: red;
  }
`
```

But vitest would double scape the `\`, which would make the snapshot
test fail:
```ts
let input = css`
  .\\[color:red\\] {
    color: red;
  }
`
```

**Edit**: It is possible with a custom snapshot environment! But this
still introduces some levels of indirection. We are also not really
testing the same thing anymore. Prettier will be formatting the CSS, we
rely on our own CSS parser / printer, which is fine but subtle bugs
could maybe be invisible or maybe it unlocks some hidden bugs, who
knows. Funnily enough, running tests with this new matcher also goes
from `23.60s` to `22.88s` (just 1 run comparison).
<img width="1227" height="897" alt="image"
src="https://github.com/user-attachments/assets/698a0c67-a1fc-46e3-ba5a-60e5873b32df"
/>

Long story short, simple `\n` and `\n` boundaries it is!

## Test plan

- Everything still works as expected.
- There are visual changes in tests, but no actual source code was
updated either so there can not be an accidental diff
2026-05-05 22:24:25 +02:00
Robin Malfait
8c779899bb
Ensure math operators are surrounded by whitespace in arbitrary values (#20011)
This PR fixes an issue where some `calc(…)` expressions become invalid
after we canonicalize them to a different syntax.

Let's say you start with:
`left-[calc(-1*(var(--my-var1)+var(--my-var2)))]`, then the produced AST
for this candidate looks like this:
```js
[
  {
    "kind": "functional",
    "root": "left",
    "modifier": null,
    "value": {
      "kind": "arbitrary",
      "dataType": null,
      "value": "calc(-1 * (var(--my-var1) + var(--my-var2)))"
    },
    "variants": [],
    "important": false,
    "raw": "left-[calc(-1*(var(--my-var1)+var(--my-var2)))]"
  }
]
```
Notice that the `+` in between the vars already contain spaces.

And the generated CSS looks like this:
```css
.left-\[calc\(-1\*\(var\(--my-var1\)\+var\(--my-var2\)\)\)\] {
  left: calc(-1 * (var(--my-var1) + var(--my-var2)));
}
```
Again, the `+` has spaces aroudn it.

However, we canoncialize this syntax where we remove the `calc(-1 *
<value>)` and move the `-` in front:
`-left-[(var(--my-var1)+var(--my-var2))]`, which should behave the same,
but it did not. The parsed value for this looks like:
```js
[
  {
    "kind": "functional",
    "root": "-left",
    "modifier": null,
    "value": {
      "kind": "arbitrary",
      "dataType": null,
      "value": "(var(--my-var1)+var(--my-var2))"
    },
    "variants": [],
    "important": false,
    "raw": "-left-[(var(--my-var1)+var(--my-var2))]"
  }
]
```
Notice that the `+` does not contain spaces around it. That's because we
add them when we parse arbitrary values and when we are in a `calc(…)`
expression. But the `calc(<value> * -1)` is added later, so at this
point, no spaces are added yet.
This also means that the generated CSS currently looks like:
```css
.-left-\[\(var\(--my-var1\)\+var\(--my-var2\)\)\] {
  left: calc((var(--my-var1)+var(--my-var2)) * -1);
}
```
Which is invalid.

To solve this, we will make sure to add the whitespace around operators
when we re-insert the `calc(<value> * -1)`. With this fix, the CSS looks
like this:
```css
.-left-\[\(var\(--my-var1\)\+var\(--my-var2\)\)\] {
  left: calc((var(--my-var1) + var(--my-var2)) * -1);
}
```
Which is correct again.

---

There are a few other issues that need a bit more work, but are not
required for this fix.
1. Can we get rid of the additional `(` and `)` parens? E.g.:
   ```diff
   - left-[calc(-1*(var(--my-var1)+var(--my-var2)))]
   - -left-[(var(--my-var1)+var(--my-var2))]
   + -left-[var(--my-var1)+var(--my-var2)]
   ```
Right now this means that we should use `calc((<value>) * -1)` instead
of
`calc(<value> * -1)` since `<value>` can be an expression on its own.
This
could lead to unwanted behavior, but this will be a follow up PR _if_
it's
   worth it.
2. Why did we even allow this canoncialization from A to B if it's not
the same result?

This last question is a bit more scary, but it has to do with how we
compare results. We create a signature where we normalize a bunch of
values to make sure that we can compare them. As a silly example
`calc(var(--a) + var(--b))` and `calc(var(--b) + var(--a))` will result
in the same value, therefor should have the same signature and should be
swappable.
But what's happening is that the signature of
`left-[calc(-1*(var(--my-var1)+var(--my-var2)))]` and
`-left-[(var(--my-var1)+var(--my-var2))]` result in:
```
/* Signature of: left-[calc(-1*(var(--my-var1)+var(--my-var2)))] */
.x {
  left: calc((var(--my-var1)+var(--my-var2))*-1);
}

/* Signature of: -left-[(var(--my-var1)+var(--my-var2))] */
.x {
  left: calc((var(--my-var1)+var(--my-var2))*-1);
}
```

This gets rid of whitespace to store less data, but this is obviously
wrong now, so we need to improve the signatures around this. That said,
that will be a follow up PR as well because this requires much more
testing.
But this PR at least fixes the #20010 issue because we will properly
insert the whitespace around the math operators.

Fixes: #20010

## Test plan

1. Added a regression test to make sure that the new canonicalized
syntax results in the correct CSS. Before the fix, the test would fail:
<img width="623" height="106" alt="image"
src="https://github.com/user-attachments/assets/c470ce38-c3fa-4080-92ca-8a3e509f700b"
/>
2. Existing tests still pass
2026-05-05 17:08:24 +02:00
Adam Wathan
b4db3b99d1
Add scrollbar-width and scrollbar-color utilities (#19981)
## Summary

Adds utilities for the
[`scrollbar-width`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/scrollbar-width)
and
[`scrollbar-color`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/scrollbar-color)
CSS properties.

### `scrollbar-width`

Three static utilities mirroring the spec keywords:

| Class | CSS |
| --- | --- |
| `scrollbar-auto` | `scrollbar-width: auto;` |
| `scrollbar-thin` | `scrollbar-width: thin;` |
| `scrollbar-none` | `scrollbar-width: none;` |

### `scrollbar-color`

The `scrollbar-color` property takes two colors (thumb and track). To
allow them to be set independently, two color utilities are added that
share a pair of CSS variables (`--tw-scrollbar-thumb` and
`--tw-scrollbar-track`), following the same pattern as the gradient stop
utilities (`from-*` / `via-*` / `to-*`):

| Class | CSS |
| --- | --- |
| `scrollbar-thumb-<color>` | `--tw-scrollbar-thumb: <color>;
scrollbar-color: var(--tw-scrollbar-thumb) var(--tw-scrollbar-track);` |
| `scrollbar-track-<color>` | `--tw-scrollbar-track: <color>;
scrollbar-color: var(--tw-scrollbar-thumb) var(--tw-scrollbar-track);` |

Both go through `colorUtility`, so they automatically support the
standard color palette, theme keys (`--scrollbar-thumb-color` /
`--scrollbar-track-color` with `--color` as a fallback), arbitrary
values, and the `/<alpha>` opacity modifier. The variables are
registered with `@property` (initial value `#0000`), matching the
gradient-stop convention so an unset side falls back to transparent.

```html
<div class="scrollbar-thin scrollbar-thumb-red-500 scrollbar-track-zinc-200">…</div>
```

## Test plan

- [x] `pnpm test` (all 4480 tests pass)
- [x] New `scrollbar-width`, `scrollbar-thumb`, and `scrollbar-track`
test cases in `utilities.test.ts` covering palette colors, theme-key
colors, `current` / `inherit` / `transparent`, arbitrary colors,
`/<alpha>` modifiers, and invalid candidates
- [x] `intellisense.test.ts` snapshot updated to include the new class
names


---
_Generated by [Claude
Code](https://claude.ai/code/session_014ajg5maQ4gUHKmqBs5o7vD)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-05-04 17:16:50 +02:00
Robin Malfait
08cad84bbe
Support --default(…) in --value(…) and --modifier(…) to support fallback values (#19989)
This PR adds a new `--default(…)` option that can be used inside
`--value(…)` or `--modifier(…)` such that functional utilities without
an explicit value/modifier can still be defined as a functional utility.
It would also allow you to use a functional utility without a value and
_with_ a modifier, e.g.: `shadow/50`.

---

This allows us to re-implement functional utilities with a default value
in CSS using `@utility`.

Used the explicit `--default(…)` argument of `--value(…)` for a few
reasons.

1. It's explicit about being a falllback value. If you have `@utility
foo-*`, then you want to be able to use `foo`, but `foo-bad` should not
compile.
2. When `--value(…)` is used in (complex) property values (think a bunch
of `calc(…)` expressions), then we don't need a separate property for
this.

One of the ideas was to have a literal fallback:
```css
@utility tab-* {
  tab-size: 4;
  tab-size: --value(number);
}
```

For `tab`, this would compile to:
```css
.tab {
  tab-size: 4;
}
```

For `tab-123`, this would compile to:
```css
.tab {
  tab-size: 4;
  tab-size: 123;
}
```
Getting rid of the `tab-size: 4` would be an option, but it's a common
pattern in real CSS for fallback values (think hex background color,
over a more modern `oklch` color).

For `tab-foo`, this would compile to:
```css
.tab {
  tab-size: 4;
}
```

Which means that we have an infinite amount classes that would result in
the same class, which is bad. We could special case this one because the
internal `value` would still be `null`, but it might be too confusing.

This syntax without the `--default(…)` also means repetition of certain
properties. Add `--modifier(…)` to the mix, and there is even more
repetition going on.

Another option to consider is that the default fallback is just another
option in the `--value(…, 4)`, but if a default fallback is a keyword,
then there is a chance that this might conflict with actual keywords we
interpret.

Main motivation is to be able to re-implement utilities such as
`shadow/50` purely in CSS. It's also something we support in the JS
based APIs, but not in the CSS based one, so while it's a "new" feature,
it's more like a missing feature right now, and often a reason for
people to use the JS based APIs instead.

For consistency reasons, this is also implemented for `--modifier(…)`
such that you can use a default value there. E.g. when re-implementing
`text-sm` where a default `line-height` is set without the explicit use
of a modifier.

Fixes: https://github.com/tailwindlabs/tailwindcss/issues/16824

## Test plan

1. Added a handful of new tests to make sure this functionality works
2. Existing tests still pass
2026-05-04 17:03:00 +02:00
Robin Malfait
0f6f7d480f
Ensure --value(…) is required in functional @utility definitions (#20005)
While working on #19989, I noticed that `--value(…)` inside functional
`@utility` definitions is not required right now.

That means that the following CSS is valid:
```css
@utility foo-* {
  color: red;
}
```

But this doesn't really makes sense, because this now accepts a value
and `foo-a`, `foo-b` and `foo-c` would generate the following CSS:
```css
.foo-a {
  color: red;
}
.foo-b {
  color: red;
}
.foo-c {
  color: red;
}
```
The `a`, `b`, and `c` are not doing anything here apart from making your
CSS bigger. So this is very likely an actual bug that you forgot to use
`--value(…)`.

Additionally, if a `--value(…)` was used, but it didn't resolve
anything, then we already properly discared the candidate.

## Test plan

1. Add test to ensure `--value(…)` is required in functional `@utility`
definitions
2. Existing tests pass
2026-05-04 16:44:28 +02:00
Robin Malfait
6e2b60e84a
Do not generate CSS for start and end (#20003)
This PR fixes a bug where CSS was generated for `start` and `end`. This
was accidentally introduced when we moved the `start-*` and `end-*`
utilities to the legacy utilities. But this meant that we now generate
CSS for `start` and `end` even if no value is provided.

Fixes: #20002

## Test plan

1. Updated the tests to make sure of `--spacing: 0.25rem` which made the
tests fail, and are fixed again by applying the fix.
2. Other tests are still passing
2026-05-03 01:00:56 +02:00
depfu[bot]
4b5d6a5943
Update enhanced-resolve 5.20.1 → 5.21.0 (minor) (#19998)
Here is everything you need to know about this update. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ enhanced-resolve (5.20.1 → 5.21.0) ·
[Repo](https://github.com/webpack/enhanced-resolve) ·
[Changelog](https://github.com/webpack/enhanced-resolve/blob/main/CHANGELOG.md)



<details>
<summary>Release Notes</summary>
<h4><a
href="https://github.com/webpack/enhanced-resolve/releases/tag/v5.21.0">5.21.0</a></h4>

<blockquote><h3 dir="auto">Minor Changes</h3>
<ul dir="auto">
<li>
<p dir="auto">Added promise API and support to resolve without <code
class="notranslate">context</code> and <code
class="notranslate">resolveContext</code>. (by <a
href="https://bounce.depfu.com/github.com/alexander-akait">@alexander-akait</a>
in <a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/520">#520</a>)</p>
</li>
<li>
<p dir="auto">Add <code
class="notranslate">extensionAliasForExports</code> option. When <code
class="notranslate">true</code>, <code
class="notranslate">extensionAlias</code> also applies to paths resolved
through the <code class="notranslate">package.json</code> <code
class="notranslate">exports</code> field. Off by default to match
Node.js; opt in for full TypeScript-resolver parity with packages that
ship <code class="notranslate">.ts</code> sources alongside the compiled
<code class="notranslate">.js</code> they declare in <code
class="notranslate">exports</code>. (by <a
href="https://bounce.depfu.com/github.com/alexander-akait">@alexander-akait</a>
in <a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/554">#554</a>)</p>
</li>
</ul>
<h3 dir="auto">Patch Changes</h3>
<ul dir="auto">
<li>
<p dir="auto">Properly handle DOS device paths (<code
class="notranslate">\\?\…</code> and <code
class="notranslate">\\.\…</code>). (by <a
href="https://bounce.depfu.com/github.com/alexander-akait">@alexander-akait</a>
in <a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/551">#551</a>)</p>
</li>
<li>
<p dir="auto">Prevent fallback to parent node_modules when the <code
class="notranslate">exports</code> field target file is not found. (by
<a href="https://bounce.depfu.com/github.com/xiaoxiaojx">@xiaoxiaojx</a>
in <a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/495">#495</a>)</p>
</li>
<li>
<p dir="auto">Imports field spec deviation: non-relative targets (e.g.
<code class="notranslate">"#a": "#b"</code>) no longer re-enter imports
resolution, aligning with the Node.js ESM spec where <code
class="notranslate">PACKAGE_IMPORTS_RESOLVE</code> does not recursively
resolve <code class="notranslate">#</code> specifiers. (by <a
href="https://bounce.depfu.com/github.com/xiaoxiaojx">@xiaoxiaojx</a> in
<a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/503">#503</a>)</p>
<p dir="auto">Previously <code class="notranslate">{ "#a": "#b", "#b":
"./the.js" }</code> would chain-resolve <code
class="notranslate">#a</code> to <code
class="notranslate">./the.js</code>; now it correctly fails, matching
Node.js behavior.</p>
</li>
<li>
<p dir="auto">Move <code class="notranslate">cachedJoin</code>/<code
class="notranslate">cachedDirname</code>/<code
class="notranslate">createCachedBasename</code> caches from module-level
globals to per-Resolver instances. This prevents unbounded memory growth
in long-running processes — when a Resolver is garbage collected, its
join/dirname/basename caches are released with it. (by <a
href="https://bounce.depfu.com/github.com/xiaoxiaojx">@xiaoxiaojx</a> in
<a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/507">#507</a>)</p>
</li>
<li>
<p dir="auto">Fixed when <code class="notranslate">tsconfig: true</code>
is used (default config file) and no <code
class="notranslate">tsconfig.json</code> exists. (by <a
href="https://bounce.depfu.com/github.com/xiaoxiaojx">@xiaoxiaojx</a> in
<a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/502">#502</a>)</p>
</li>
<li>
<p dir="auto">Apply the <code class="notranslate">extensionAlias</code>
option to the <code class="notranslate">imports</code> field to be align
with typescript resolution. (by <a
href="https://bounce.depfu.com/github.com/alexander-akait">@alexander-akait</a>
in <a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/549">#549</a>)</p>
</li>
<li>
<p dir="auto">Improved performance of the many plugins. (by <a
href="https://bounce.depfu.com/github.com/alexander-akait">@alexander-akait</a>
in <a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/529">#529</a>)</p>
</li>
<li>
<p dir="auto">Replace the <code
class="notranslate">Set&lt;string&gt;</code>-based resolver stack with a
singly-linked <code class="notranslate">StackEntry</code> class that
exposes a Set-compatible API. (by <a
href="https://bounce.depfu.com/github.com/xiaoxiaojx">@xiaoxiaojx</a> in
<a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/526">#526</a>)</p>
<p dir="auto">Each <code class="notranslate">doResolve</code> call now
prepends a single linked-list node instead of cloning the entire Set,
making stack push O(1) in time and memory. Recursion detection walks the
linked list (O(n)), but because the stack is typically shallow this is
much cheaper than cloning a Set per call.</p>
</li>
<li>
<p dir="auto">Cache the result of <code
class="notranslate">stripJsonComments</code> + <code
class="notranslate">JSON.parse</code> in <code
class="notranslate">readJson</code> using a <code
class="notranslate">WeakMap</code> keyed by the raw file buffer. This
avoids redundant comment-stripping and JSON parsing on every resolve
call that reads tsconfig.json files (via <code
class="notranslate">stripComments: true</code>), improving
TsconfigPathsPlugin warm performance by ~20-35% depending on the depth
of the <code class="notranslate">extends</code> chain. (by <a
href="https://bounce.depfu.com/github.com/xiaoxiaojx">@xiaoxiaojx</a> in
<a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/524">#524</a>)</p>
</li>
<li>
<p dir="auto">Avoid OOM in CachedInputFileSystem when duration is
Infinity. (by <a
href="https://bounce.depfu.com/github.com/alexander-akait">@alexander-akait</a>
in <a
href="https://bounce.depfu.com/github.com/webpack/enhanced-resolve/pull/527">#527</a>)</p>
</li>
</ul></blockquote>
<p><em>Does any of this look wrong? <a
href="feedback">Please
let us know.</a></em></p>
</details>

<details>
<summary>Commits</summary>
<p><a
href="ebc67d3896...35035ca158">See
the full diff on Github</a>. The new version differs by more commits
than we can show here.</p>
</details>












---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-05-01 11:29:19 +00:00
Robin Malfait
52f94c74bb
Improve codebase quality (#19999)
Small PR that improves the overall quality of the codebase. It's a bit
of everything:

1. Using correct variants for the variants we are testing
2. Use `@reference` instead of `@import` in a test, testing the
`@reference` according to the test name
3. Use `using` for Vitest related mocks. They have a `Symbol.dispose`
implemented, so we can don't have to restore mocks ourselves (right now,
some of them are not cleaned up at all).
4. Updated deprecated `.toThrowError` with `.toThrow` APIs

## Test plan

Everything still passes.

[ci-all]
2026-04-30 23:32:37 +02:00
Robin Malfait
0db856f3de
Make integration tests more stable in CI (#19995)
This PR is an attempt to make CI more stable related to integration
tests.

[ci-all]
2026-04-30 23:23:36 +02:00
Robin Malfait
1ca0aacdae
Add source map visualization for tests (#19997)
This PR is an internal change only related to how we visualize source
maps.

As part of this PR
(https://github.com/tailwindlabs/tailwindcss/pull/19996) I added a test
for source maps related to how `@variant` is processed. And while the
result was correct, I had a hard time verifying if this was _actually_
correct. I did the mental mapping of comparing locations from the output
to the input.
<img width="495" height="495" alt="image"
src="https://github.com/user-attachments/assets/2b1c12cd-43ee-461a-b93b-bafe6ce1cce5"
/>

With this PR, I want to make that more visual by actually printing the
input source(s) and output file and highlight the necessary parts:
<img width="1101" height="1085" alt="image"
src="https://github.com/user-attachments/assets/14390026-f211-4cfc-8c6c-3293105f6403"
/>

I didn't want to get too clever here. But printing line numbers also
helps in case we point to different spots on the same line:
<img width="1200" height="981" alt="image"
src="https://github.com/user-attachments/assets/e75f7622-eb51-49ee-928e-fdc8672d2c3f"
/>

And if we point to different files, then we visualize these as well:
<img width="851" height="957" alt="image"
src="https://github.com/user-attachments/assets/77e65801-b46b-4579-8659-d919a8750f60"
/>


If you combine this with snapshot tests, then it's very easy to verify
that locations match up correctly. Each source map location is
highlighted and references a symbol starting at `A`, `B`, etc.

A change to the source maps _can_ result in a big diff, and even this PR
introduces a big diff because of the preflight diffs. But at least you
can see how things line up.

## Test plan

1. All tests still pass
2. Added tests for the source map visualizer that is only used in tests
3. No actual source code was touched
2026-04-30 14:52:09 +02:00
Robin Malfait
e1201bc6e3
Simplify @variant usage, allow compound and stacked variants (#19996)
This PR improves and simplifies the `@variant` usage. 

When we originally added support for `@variant`, we wanted to keep
things simple, where we could only use a single variant at a time. The
original PR did have a more complex system with all these features
enabled, but we wanted to make sure that we only introduced the
additional complexity when the community felt like it was needed.

But of course we still wanted to make sure that you could do compound
and stacked variants, it just required some additional code.

For compound variants, where you want to use variant `a` and variant
`b`, you could duplicate the rules as siblings:
```css
.foo {
  @variant a {
    display: flex;
  }

  @variant b {
    display: flex;
  }
}
```

But with this PR, you can comma separate each variant to get the same
effect:
```css
.foo {
  @variant a, b {
    display: flex;
  }
}
```
You can think of this as-if we are expanding this syntax into the
aforementioned syntax. In other words, we would do the duplication for
you.

Additionally, you also want to be able to stack variants. For that you
had to nest your `@variant` rules:
```css
.foo {
  @variant a {
    @variant b {
      display: flex;
    }
  }
}
```

Not the end of the world, but it can get pretty nested if you want to
use multiple variants. Luckily we already have a syntax for this in
normal Tailwind CSS classes: `a🅱️flex`. Which is exactly what we can
use here as well:
```css
.foo {
  @variant a:b {
    display: flex;
  }
}
```
Again, conceptually you can think of this syntax being expanded into the
syntax from above.

Last but not least, we can also combine these:
```css
.foo {
  background: black;

  @variant a, b:c {
    background: red;

    @variant d, e:f {
      background: blue;
    }
  }
}
```
This conceptually translates into the much more verbose version today:
```css
.foo {
  background: black;

  @variant a {
    background: red;

    @variant d {
      background: blue;
    }

    @variant e {
      @variant f {
        background: blue;
      }
    }
  }

  @variant b {
    @variant c {
      background: red;

      @variant d {
        background: blue;
      }

      @variant e {
        @variant f {
          background: blue;
        }
      }
    }
  }
}
```

The biggest downside is that this could potentially easily balloon your
CSS file size if you're not careful. Because with this, it's pretty easy
to add one more variant that introduces a lot of duplicated CSS.

This feature is completely backwards compatible, you can still nest your
`@variant` calls yourself if you want, and combine them with these
features if you want.

This is also a continuation of #19526 and #19884, but for some reason I
don't have push rights, so I'm creating this new PR instead. I did keep
the original commits of those PRs so these contributors are still
properly marked as contributors.
<img width="808" height="135" alt="image"
src="https://github.com/user-attachments/assets/bee334ab-39d7-4d4d-a48f-afa2253cf17b"
/>
<img width="349" height="75" alt="image"
src="https://github.com/user-attachments/assets/fb68906c-db74-43f7-83e4-918ad3d4a036"
/>


Closes: #19526
Closes: #19884

## Test plan

1. Added a bunch of new tests to verify this new behavior
2. Added tests that compare the short (new) version, and the long (old)
version
3. Added a sourcemap related test to ensure that the src and dst
locations are correct
4. Existing tests still pass

---------

Co-authored-by: orteth01 <tortega128@gmail.com>
Co-authored-by: Ray Knight <array.knight+github@gmail.com>
2026-04-30 12:19:44 +02:00
silverwind
6cf1af26b3
Fix TS2742 error when inferring exported Config type (#19707)
Type aliases get resolved through to `UserConfig` during declaration
emit, which lives in a hashed internal DTS chunk with no stable import
path, causing TS2742. This is 100% backwards-compatible because type
aliases and interfaces are structurally comparable.

Fixes: https://github.com/tailwindlabs/tailwindcss/issues/15844
Fixes: https://github.com/tailwindlabs/tailwindcss/issues/19706
Fixes: https://github.com/tailwindlabs/tailwindcss/discussions/15458

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-04-29 18:48:08 +00:00
Matt Van Horn
f036f80195
Allow multiple @utility definitions with same name but different value types (#19777)
## Summary

Fixes #16948

When defining multiple CSS `@utility foo-*` with different value types
(e.g., one for colors, one for numbers), only the first handler was
tried. If it returned `null` (value didn't match), the compile loop
stopped, preventing subsequent handlers from being attempted.

```css
@utility foo-* {
  color: --value(--color-*);
}
@utility foo-* {
  font-size: --spacing(--value(number));
}
```

Previously, `foo-red-500` worked but `foo-123` did not (or vice versa
depending on definition order).

The fix distinguishes between CSS `@utility` handlers and JS plugin
`matchUtilities` handlers:
- **CSS `@utility`** (no typed options): `null` means "try the next
handler" - allows multiple definitions with different value types to
coexist
- **JS `matchUtilities`** (with explicit types): `null` means "the value
was invalid for this type, stop" - preserves existing behavior where
typed utilities prevent invalid values from falling through

## Test plan

- Added test: two `@utility foo-*` definitions with different value
types - verifies both `foo-red-500` (color) and `foo-123` (number)
produce correct CSS
- All 4621 existing tests pass (including the `matchUtilities`
type-safety tests)
- `pnpm build && pnpm test` passes

This contribution was developed with AI assistance (Claude Code).

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-04-29 14:56:17 +00:00
Abhijeet Abhi
d194d4c3e6
docs: fix various typos in comments and documentation (#19878)
Fixes a few minor typos across the codebase (e.g. 'overriden' ->
'overridden', 're-use' -> 'reuse').

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-04-29 13:28:39 +00:00
Daniel Polito
ba667400e8
[@tailwindcss/upgrade] Don’t migrate inline style properties (#19918)
Prevent the upgrade tool from rewriting CSS properties inside inline
`style` attributes.

This fixes cases like `style="flex-grow: 1"` being changed to
`style="grow: 1"` and adds regression tests.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-04-29 13:18:02 +00:00
Robin Malfait
73f3a6a743
Canonicalization: migrate [&:has(…)] to has-[…] variant (#19991)
This PR improves the canonicalization around arbitrary variants
containing `[&:has(…)]`, by converting them to `has-[…]`.

Essentially when you have a variant, with `&:has(…)`, we will convert
it:

```diff
- [&:has([role=checkbox])]:flex
+ has-[[role=checkbox]]:flex
```

This also means that if the arbitrary selector inside of the `&:has(…)`
can be converted to something that aligns with known variants, then we
will do that as well:

```diff
  // `data-*` can be hoisted outside of the arbitrary value
- [&:has([data-slot=description])]:flex
+ has-data-[slot=description]:flex

  // `aria-visible="true"` can be entirely replaced by `aria-visible`
- [&:has([aria-visible="true"])]:flex
+ has-aria-visible:flex
```

Noticed this while looking into:
https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1562

## Test plan

1. Added additional tests for this use case
2. Existing tests still pass
2026-04-29 14:20:11 +02:00
Robin Malfait
b9449612ba
Canonicalization: do not canonicalize units in arbitrary values (#19988)
This PR improves the canonicalization when dealing with arbitrary
values.

As part of the canonicalization process we compute a signature for a
given utility. This way we can ensure that when we canonicalize a
candidate into a simpler candidate that it's still equivalent if the
signatures match.

One thing we do during signature computation is normalizing dimensions
(value + unit) into the same unit to make comparisons easier.

For example:
```css
.foo { margin-top: 20in; }
.bar { margin-top: 1920px; }
```

Will both get converted to `1920px` and therefore `foo` and `bar` will
have the same signature.

Up until this part, everything is fine. However, this normalization also
leaks when we try to canonicalize arbitrary values. One of the things we
do is try to move the `-` into the arbitrary value:
```diff
- -mt-[20in]
+ mt-[calc(20in_*_-1)]
```

This is obviously not cleaner, but we can perform some canonicalization
of the arbitrary value. As part of that we do constant folding _and_ the
normalization of base units. That means that we would see this:
```diff
- -mt-[20in]
- mt-[calc(20in_*_-1)]
+ mt-[-1920px]
```

But this might be very confusing because it might not make sense where
the `1920px` even came from... the only thing we should have done here
is constant fold that calc expression.

That's what this PR does, it only does the constant folding but without
the unit normalization:
```diff
- -mt-[20in]
- mt-[calc(20in_*_-1)]
+ mt-[-20in]
```

Which is exactly what we want!

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

## Test plan

1. Added a regression test based on the linked issue
2. All other tests still pass
2026-04-28 16:42:02 +02:00
Robin Malfait
3aac5dafe8
Improve whitespace handling during canonicalization (#19986)
This PR fixes a printing bug during canonicalization where it converts:
```
[&:has(~_*_*:checked)]:text-green-500
```

into:
```
[&:has(~**:checked)]:text-green-500
```

This is because the `_` was marked as insignificant and therefore
removed. This PR fixes that and maintains the whitespace (`_`)
characters when needed.

Additionally, in the comments of the linked issue somebody mentioned
that:
```
w-[calc(100%_-_--spacing(60))]
```

was turned into:
```
w-[calc(100%---spacing(60))]
```

...and while that's still correct and parseable, it's not the prettiest.

This PR will still get rid of the whitespace, but introduce wrapping
parens `(…)` instead, in case readability is not ideal.

In this case, we will turn it into:
```diff
- w-[calc(100%_-_--spacing(60))]
- w-[calc(100%---spacing(60))]
+ w-[calc(100%-(--spacing(60)))]
```

Of course there are some cases where we don't need to introduce `(…)`
unnecessarily:

- `shadow-[inset_0px_1px_--theme(--color-white/15%)]` would not be
turned into `shadow-[inset_0px_1px_(--theme(--color-white/15%))]`
because no readability is gained when it's part of a normal space
separated list
- `m-[--spacing(12.34)]` would not be turned into
`m-[(--spacing(12.34))]` because there is nothing else it can conflict
with
- `m-[calc(--spacing(12.34)*2)]` would not be turned into
`m-[calc((--spacing(12.34))*2)]` because it's the first argument and
doesn't conflict with the `*`
- `m-[min(100%,--spacing(12.34))]` would not be turned into
`m-[min(100%,(--spacing(12.34)))]` because a `,` doesn't cause
readability issues


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

## Test plan

1. Added new tests to ensure the bug is fixed
2. Added new tests to ensure readability is improved (and not degraged)
when whitespace was used to improve readability
2026-04-27 00:20:53 +02:00
Robin Malfait
107e451e37
Improve CI for NAPI-RS (#19983)
This PR updates the CI workflows related to NAPI-RS. So far, we've been
using a custom ghcr.io based image, but these run on Node v18.

With this PR, I created a new project using `napi new`, with GitHub
Actions workflow setup. Then essentially ported the changes from that
new project to this project (as minimal as possible).

The main goal was to be able to bump NAPI-RS related dependencies _and_
get an insiders release out. The images used in these CI jobs were
blockers to get an actual (insiders) release out.

## Test plan

1. CI still passes
2. Added a dry-run on the prepare release workflow to test that we got
through the entire workflow without issues
2026-04-26 19:37:28 +02:00
Robin Malfait
615835d1f5
Bump NAPI related dependencies (#19982)
This PR bumps all the NAPI related dependencies

Closes: #19977
Closes: #19976
Closes: #19973
Closes: #19972
Closes: #19971
Closes: #19970

## Test plan

1. All tests still pass

[ci-all] to verify on Windows and macOS as well
2026-04-26 17:49:06 +02:00
It's Me!
bfb5732b0b
Fall back to the plugin base when PostCSS has no from option (#19980)
## Summary

`@tailwindcss/postcss` derives `inputBasePath` from `result.opts.from`:

```ts
let inputFile = result.opts.from ?? ''
let inputBasePath = path.dirname(path.resolve(inputFile))
```

When PostCSS calls the plugin without `from` (some bundlers, including
Turbopack, do this for certain CSS inputs), `inputFile` is `''`,
`path.resolve('')` returns `process.cwd()`, and `path.dirname(...)`
therefore returns the **parent of CWD**. The downstream `compileAst({
base: inputBasePath })` call then asks the resolver to find
`tailwindcss` from one level above the project root, which fails with:

```
Can't resolve 'tailwindcss' in '<parent of CWD>'
```

The plugin already computes `base = opts.base ?? process.cwd()` near the
top. Reusing that as the fallback gives a sensible default (CWD) and
respects an explicit `opts.base` when set.

```diff
-let inputBasePath = path.dirname(path.resolve(inputFile))
+let inputBasePath = inputFile
+  ? path.dirname(path.resolve(inputFile))
+  : base
```

## Test plan

Added a test in `packages/@tailwindcss-postcss/src/index.test.ts` that
processes `@import 'tailwindcss'` via `processor.process(input)` with no
`from` option. Before the fix, this throws `Error: Can't resolve
'tailwindcss' in '<parent of CWD>'`; after the fix, the import resolves
and the processor returns non-empty CSS.

I wasn't able to run the suite locally — `pnpm build` requires `cargo`
for `@tailwindcss/oxide` and I don't have a Rust toolchain set up — so
the test has been written to match existing conventions in
`index.test.ts` (vitest, plain
`postcss([tailwindcss({...})]).process(...)`), and I'm relying on CI to
verify.

---------

Co-authored-by: rebasecase <rebasecase@localhost>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-04-26 13:45:39 +00:00
Robin Malfait
3a890c3572
Bump dependencies (#19957)
This PR bumps dependencies in all the packages, typically just bumping
to the latest patch release.


Closes: #19936
Closes: #19917
Closes: #19899
Closes: #19897
Closes: #19845
Closes: #19832
Closes: #19967
Closes: #19968 

## Test plan

1. All tests still pass
2. All integration tests still pass

[ci-all] to verify Linux, Windows and macOS
2026-04-24 21:21:12 +02:00
Tommy D. Rossi
db27049caa
fix(@tailwindcss/vite): include @variant in feature detection (#19966)
Fixes #19964

CSS files imported via JS that only use `@variant` (no `@apply`,
`theme()`, or utility classes) are silently skipped by the Vite plugin.
The `@variant` directive gets passed through raw to the browser, which
drops it as an unknown at-rule.

**Root cause** — `Features.Variants` is missing from the [feature
detection
bitmask](https://github.com/tailwindlabs/tailwindcss/blob/v4.2.4/packages/%40tailwindcss-vite/src/index.ts#L526):

```ts
// before
Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities

// after
Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities | Features.Variants
```

**Reproduction** — https://github.com/remorses/tailwind-variant-bug

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-04-24 14:11:48 +00:00
Robin Malfait
5a799900d4
Always resolve relative files, relative to the current .css file (#19965)
This PR fixes an issue where resolving of certain CSS or JS files
results in the wrong paths. The issue happens if you have a setup where
a relative file path _also_ exists in the parent folder:

```css
/* src/foo.css */
.foo-in-root {}

/* src/theme/a.css */
@import "./foo.css"; /* This resolved to the file above, instead of the file below */

/* src/theme/foo.css */
.foo-in-theme {}
```

This happened because we resolved relative to a `base` folder, but Vite
expects an `importer` instead. The difference is subtle, but they expect
a file. On that file they use `let base = path.dirname(importer)` to get
a base path out themselves.

This in turn means that if you pass in a folder, you get this:
```js
path.dirname('/path/to/my-project') // /path/to
```

If we gave it a proper file, then we get the proper base path
```js
path.dirname('/path/to/my-project/index.css') // /path/to/my-project
```

I'm actually surprised that this didn't cause issues earlier... but it
did result in error since we recently started resolving files using
Vite's `aliasOnly: true` feature such taht Vite aliases work as well.

With this change, we now make sure that:

1. We use a proper `importer` instead of the `base` path
2. We refactor the resolving logic such that we try with `aliasOnly:
true` first, then `aliasOnly: false`

We also still ensure that in the CSS resolver we expect a `.css` file,
and in the JS resolver we _don't_ expect a `.css` file (which can happen
if a `"browser": "./dist/index.css"` field in package.json points to a
CSS file, daisyUI does this for example).

Fixes: #19956

## Test plan

1. Added additional (failing) integration tests to reproduce the linked
issue
2. Existing integration tests pass

While the build still worked, the linked issue resulted in a much bigger
file size because the wrong .css files were included. With this fix, the
number is correct again:
<img width="1234" height="1037" alt="image"
src="https://github.com/user-attachments/assets/fdff803c-0db2-4066-92fa-064c2816b35c"
/>

Since this is touching code related to previous PRs, I wanted to
manually make sure that these still work as expected:

- https://github.com/tailwindlabs/tailwindcss/issues/19950: This one is
about daisyUI and the `.css` file referenced in the package.json's
`"browser"` field: <img width="782" height="1229" alt="image"
src="https://github.com/user-attachments/assets/fb7b9d3b-527b-414b-94b0-2be7e96056f1"
/>

- https://github.com/tailwindlabs/tailwindcss/issues/19946: This one is
about the Vite alias being just a single `@` causing issues with
`@plugin "@tailwindcss/typography";` for example: <img width="1694"
height="1856" alt="image"
src="https://github.com/user-attachments/assets/d1935ecf-e80a-4945-a69a-ec3c5223efab"
/>


[ci-all]
2026-04-24 15:07:56 +02:00
Robin Malfait
f62597a82c
Bump dependencies in playgrounds (#19954)
This PR bumps dependencies in our playgrounds.

Closes: https://github.com/tailwindlabs/tailwindcss/pull/19952
Closes: https://github.com/tailwindlabs/tailwindcss/pull/19945

### Test plan

Ran each playground separately to make sure they are still working
2026-04-23 12:51:20 +02:00
ArcherGu
f3fdda2a5c
fix(vite): avoid resolving JS plugins to browser CSS entries (#19949)
Edit: some edits by @RobinMalfait 

---

## Summary

Fix a regression in `@tailwindcss/vite` introduced by `#19803` where JS
plugin resolution could incorrectly resolve a package to its `browser`
CSS entry.

In cases like `daisyui`, Vite can resolve `@plugin "daisyui"` to
`daisyui.css` instead of the package's JS entry, which causes Tailwind
to try to load a CSS file as a JS plugin and fail with:

```txt
Unknown file extension ".css"
```

This change keeps the `aliasOnly: false` behavior from `#19803` so
tsconfig path resolution still works, but adds a JS-entry guard to
`customJsResolver` in `@tailwindcss/vite`. If Vite resolves a plugin
request to a non-JS file like `.css`, the custom resolver now returns
`undefined` so Tailwind's internal fallback resolver can resolve the
package as a JS plugin entry instead.

I also added integration coverage for a package whose `main`/`module`
points to JS while `browser` points to CSS, and verified that `@plugin
"pkg"` still resolves to the JS entry in both build and dev mode.

## Test plan

Added new integration tests in `integrations/vite/resolvers.test.ts`
covering a package with:

- `main` / `module` -> JS
- `browser` -> CSS
- `@plugin "pkg"` -> should resolve to JS, not CSS

Verified with:

```sh
pnpm test:integrations vite/resolvers.test.ts -t "browser points to CSS"
pnpm test:integrations vite/resolvers.test.ts -t "resolves tsconfig paths"
```

These verify that:

- `@plugin` no longer resolves to a CSS browser entry
- the original tsconfig paths fix from `#19803` still works in both
build and dev mode

---

Maintainer edits:

Instead of hardcoding file extensions, first try to resolve aliases and
then fallback to the default resolving system we had before. We still
check for a `.css` extension, even in the JS resolver because some
dependencies (like `daisyUI`) put the CSS file there instead of in an
`exports.style`. If we detect that, we still fallback to the default
resolving logic.

This should be compatible with the original issue we were trying to fix
where we wanted to make Vite aliases work.

Fixes: #19950

[ci-all]

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-04-22 17:25:01 +02:00
Robin Malfait
69ad7cc5ec
4.2.4 (#19948) 2026-04-21 14:52:34 +02:00
Robin Malfait
685c19e266
Fix issue around resolving paths in @tailwindcss/vite (#19947)
This PR fixes an issue when using `@tailwindcss/vite` and you're trying
to resolve paths. In the latest 4.2.3 release, we added support for
following the vite `aliases` option.

However, some people run into issues because if you just use `@` as an
alias then using `@tailwindcss/typography` wouldn't resolve because it's
a package, and not something local.

With this PR we fix that by making sure that Vite's resolver can
actually resolve to an absolute path. If not, then we fallback to the
resolving that happens in `@tailwindcss/node`.

Fixes: #19946

## Test plan

1. Added an integration test that reproduces this issue, and is now
solved
2. Tested it on a reproduction provided in the corresponding issue

Before:
<img width="1694" height="1856" alt="image"
src="https://github.com/user-attachments/assets/c50f7104-78b3-476d-9e60-0c83e0976a5c"
/>


After:
<img width="1694" height="1856" alt="image"
src="https://github.com/user-attachments/assets/44ea0e3b-f60f-479f-a4a1-203a6230475b"
/>
2026-04-21 14:11:46 +02:00
Robin Malfait
2e3fa490a5
4.2.3 (#19944) 2026-04-20 22:32:52 +02:00
Pavan Shinde
4527123f68
docs(postcss): remove duplicated optimize example from README (#19938)
<!--

👋 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?

-->
Removed a duplicated optimize: { minify: false } example from
@tailwindcss/postcss README (doc-only, one file).

## 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.

-->
Check the duplicate block is removed, the earlier optimize example still
exists, and only packages/@tailwindcss-postcss/README.md changed.
2026-04-20 17:22:33 +02:00
Robin Malfait
77801e7c62
use pnpm@9.6.0 2026-04-20 17:02:28 +02:00
Robin Malfait
039bd7d82f
Setup OIDC publishing (#19943)
This PR merges the `release-insiders.yml` and `release.yml` such that we
can setup OIDC publishing to npmjs.com.
2026-04-20 16:59:31 +02:00
Robin Malfait
998a6be85d
format 2026-04-20 13:12:24 +02:00
Steffen Deusch
5a835e1728
support NODE_PATH in standalone build (#19617)
References https://github.com/tailwindlabs/tailwindcss/pull/19391.
References https://github.com/tailwindlabs/tailwindcss/pull/16274.

Right now, when using the standalone build of the TailwindCSS CLI, you
cannot use a custom `NODE_PATH`, but you can when using it via Node.js
directly.

A custom NODE_PATH allows you to resolve imports from multiple
locations. For example, in [Phoenix
LiveView](https://github.com/phoenixframework/phoenix_live_view/), we
have a feature where you can write scripts in templates that we extract
at compile time to a custom folder and users can import those in their
application bundle by saying

```javascript
import { hooks as colocatedHooks } from "phoenix-colocated/my_app"
```

where the "phoenix-colocated" folder lives in a different location than
the usual `node_modules` folder. This works fine with the default
esbuild setup, as it respects `NODE_PATH`, so we can pass it a custom
location.

We want to also support colocating CSS in templates soon, but the same
approach doesn't work with the standalone Tailwind CLI we ship with
default Phoenix projects. It works when running Tailwind through
Node.js, but we don't want to tell users they need to install it, just
to use the feature.

This patch changes the lookup logic for the standalone CLI to also
account for `NODE_PATH`. Note that you can pass multiple paths, that are
split according the the OS PATH separator.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-04-17 18:29:18 +02:00
Pavan Shinde
aad601711f
docs/fix-lightning-css-typo-postcss-readme (#19913)
<!--

👋 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
Fix a typo in the @tailwindcss/postcss README by changing Lighting CSS
to Lightning CSS in the optimize option documentation.
<!--

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

-->

## Test plan
Verified the README now says Lightning CSS and that the diff is
docs-only.
<!--

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-04-07 11:31:10 +02:00
depfu[bot]
e01946f8b7
Update @vitejs/plugin-react 5.1.3 → 5.2.0 (minor) (#19826)
Here is everything you need to know about this update. Please take a
good look at what changed and the test results before merging this pull
request.

### What changed?




#### ✳️ @​vitejs/plugin-react (5.1.3 → 5.2.0) ·
[Repo](https://github.com/vitejs/vite-plugin-react) ·
[Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)



<details>
<summary>Release Notes</summary>

<h4>5.1.4 (from changelog)</h4>
<blockquote><h3 dir="auto">Fix <code
class="notranslate">canSkipBabel</code> not accounting for <code
class="notranslate">babel.overrides</code> (<a
href="https://bounce.depfu.com/github.com/vitejs/vite-plugin-react/pull/1098">#1098</a>)</h3>
<p dir="auto">When configuring <code
class="notranslate">babel.overrides</code> without top-level plugins or
presets, Babel was incorrectly skipped. The <code
class="notranslate">canSkipBabel</code> function now checks for <code
class="notranslate">overrides.length</code> to ensure override
configurations are processed.</p></blockquote>
<p><em>Does any of this look wrong? <a
href="feedback">Please
let us know.</a></em></p>
</details>













---
![Depfu
Status](https://depfu.com/badges/edd6acd35d74c8d41cbb540c30442adf/stats.svg)

[Depfu](https://depfu.com) will automatically keep this PR
conflict-free, as long as you don't add any commits to this branch
yourself. You can also trigger a rebase manually by commenting with
`@depfu rebase`.

<details><summary>All Depfu comment commands</summary>
<blockquote><dl>
<dt>@​depfu rebase</dt><dd>Rebases against your default branch and
redoes this update</dd>
<dt>@​depfu recreate</dt><dd>Recreates this PR, overwriting any edits
that you've made to it</dd>
<dt>@​depfu merge</dt><dd>Merges this PR once your tests are passing and
conflicts are resolved</dd>
<dt>@​depfu cancel merge</dt><dd>Cancels automatic merging of this
PR</dd>
<dt>@​depfu close</dt><dd>Closes this PR and deletes the branch</dd>
<dt>@​depfu reopen</dt><dd>Restores the branch and reopens this PR (if
it's closed)</dd>
<dt>@​depfu pause</dt><dd>Ignores all future updates for this dependency
and closes this PR</dd>
<dt>@​depfu pause [minor|major]</dt><dd>Ignores all future minor/major
updates for this dependency and closes this PR</dd>
<dt>@​depfu resume</dt><dd>Future versions of this dependency will
create PRs again (leaves this PR as is)</dd>
</dl></blockquote>
</details>

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
2026-04-03 19:28:21 +00:00
Robin Malfait
2093a72e2d
Bump Next.js and drop ESLint (#19898)
This PR bumps Next.js and drops ESLint. 

We only had ESLint installed because it came with `next lint`. We don't
actively use ESLint, it was just used in the playgrounds where we test
things out. Dropping this reduces the amount of dependencies we have to
deal with.

Closes: #19867
Closes: #19852

# Test plan

Tested both the `nextjs` and `v3` playgrounds after bumping the Next.js
dependency and getting rid of ESLint, and they still work as expected.
2026-04-03 21:07:27 +02:00
Jordan Pittman
d7fc281a0e
Use json preprocessor for new-line delimited JSON files (#19862)
## Summary

This specializes the `.jsonl` and `.ndjson` file extensions so they're
preprocessed like JSON instead of by the standard scanner. This prevents
them from creating thousands of sub machines and reduces scanning time
(see #17125 where this was done for `.json` files).

It seems reasonable to handle new-line delimited JSON files as well
otherwise scanning these files can take quite a long time.

It's quite unlikely that these will contain classes so, alternatively,
these *could* go in the binary extensions list so they get ignored
entirely.

## Test plan

I ran manual tests inside the `oxide` crate against some large-ish JSONL
files (5MB–15MB). These changes bring down scanning time from 2s–3s on
my M3 Max (via `cargo test --release …`) to less than 20ms.

I also ran tests through a full CLI build pipeline on a low-spec linux
box. This change brought scanning time down from ~90s to ~300ms for a
single ~15MB file.
2026-03-26 23:42:03 +01:00
Robin Malfait
df6209ab8b
Canonicalize negative arbitrary values (#19858)
This PR adds a few more canonicalizations for some cases I noticed on
our templates.

When dealing with arbitrary values, and the utility is a "negative"
utility, then we will try to put the `-` inside of the arbitrary value:

```diff
- -left-[9rem]
+ left-[-9rem]
```

The idea is that the arbitrary value is already an escape hatch for when
a value is not available by default. The `-` in front uses an implicit
`calc(<expression> * -1)` which might be confusion if you have an value
like this already.

This also can allow for some further optimizations. For example
```diff
- -mt-[492px]
  ↓↓↓↓↓↓↓                           Into a simpler arbitrary value
+ mt-[-492px]
  ↓↓↓↓↓↓↓                           Into a bare value
+ mt-123
```

This PR also improve the constant folding of calc expressions a bit more
such that nested calc expressions with 2 constants and an unknown can be
folded. Bit of a mouthful, but it allows us to handle this:
```diff
- mt-[calc(-1*calc(-1*var(--foo)))]
  ↓↓↓↓↓↓↓                           The -1 * -1 becomes a no-op
+ mt-[var(--foo)]
  ↓↓↓↓↓↓↓                           Into the shorthand for CSS variables
+ mt-(--foo)
```

Now that we can handle moving the `-` into the arbitrary value, there
are also cases where we can get the `-` _out_ of the arbitrary value:
```diff
- mt-[calc(-1*var(--foo))]
  ↓↓↓↓↓↓↓                           Simplify calc, move `-` to the front
+ -mt-[var(--foo)]
  ↓↓↓↓↓↓↓                           Into the shorthand for CSS variables
+ -mt-(--foo)
```

Another missing piece that this PR adds is the concept of canonicalizing
or normalizing calc expressions. This is a separate step used when
calculating the signature for each utility. This allows us to normalize
`calc(-1*var(--foo))` and `calc(var(--foo)*-1)`. Without this they would
not be considered the same, but not it will.

It's only used when comparing values, it won't unify the actual
arbitrary values with this logic (at least for now).

With the additional constant folding logic and the canonicalization when
comparing signatures it unlocks the necessary power to perform the above
transformations.

## Test plan

1. Existing tests still pass
2. Added additional tests for the constant folding logic
3. Added tests for the canonicalization of calc expressions
4. Added new tests where we move the `-` inside the value, or move the
`-` outside of the arbitrary value.
2026-03-26 14:44:09 +01:00
Robin Malfait
52fd421cc9
Small refactor of canonicalization tests (#19851)
This are getting a little bit out of hand here, so this is an initial
refactor.

## Test plan

1. All tests are still there
2. All tests are still passing
2026-03-25 15:18:14 +01:00
Robin Malfait
c385fd36bc
use test.each instead of manual loop 2026-03-25 12:16:50 +01:00
Robin Malfait
0d6e038889
fix index in test name 2026-03-25 12:16:50 +01:00
Robin Malfait
88a2d22c2f
Add more canonicalization rules for deprecated utilities (#19849)
This PR adds more canonicalization rules for deprecated utilities.

| Before | After |
| --- | --- |
| `overflow-ellipsis` | `text-ellipsis` |
| `start-full` | `inset-s-full` |
| `-start-full` | `-inset-s-full` |
| `start-auto` | `inset-s-auto` |
| `start-px` | `inset-s-px` |
| `-start-px` | `-inset-s-px` |
| `start-8` | `inset-s-8` |
| `-start-8` | `-inset-s-8` |
| `start-123` | `inset-s-123` |
| `-start-123` | `-inset-s-123` |
| `end-full` | `inset-e-full` |
| `-end-full` | `-inset-e-full` |
| `end-auto` | `inset-e-auto` |
| `end-px` | `inset-e-px` |
| `-end-px` | `-inset-e-px` |
| `end-8` | `inset-e-8` |
| `-end-8` | `-inset-e-8` |
| `end-123` | `inset-e-123` |
| `-end-123` | `-inset-e-123` |

In a few cases we already had canonicalization rules, for example
`start-8` where `8` is one of the default suggested spacing scale
values. But this now adds support for positive and negative values that
exceed the default suggested spacing scale as well as some keywords.

## Test plan

1. Existing tests pass
2. Added new tests to ensure these canonicalizations work
2026-03-25 11:38:28 +01:00
Robin Malfait
e4856c9720
Make upgrade tooling more stable (#19846)
This PR is an attempt to make the upgrade tooling more stable.


### TL;DR

1. When migrating from Tailwind CSS v3 → Tailwind CSS v4, only migrate
files listed in the `config.content` instead of relying on v4's auto
content detection feature
2. Skip writing files that have not been changed
3. Write changed files in a safe way: first write to a temporary file,
then rename the file atomically
4. Never migrate files that are git ignored, even if they are listed in
the `config.content` file
5. Always ignore `.env` and `.env.*` files when scanning for files. Most
people will have this in their `.gitignore` file, but if not, then this
is a fallback mechanism.

---

Looking at the #18972 issue, it looks like some people are running into
weird situations where some of the contents is just gone.

I have never been able to reproduce this on my own devices and in my own
projects unfortunately. But there is definitely _something_ happening
that's not right that people are running into.

Therefore, this PR is an attempt to fix what I think _might_ be wrong,
but I'm not 100% sure if these fixes are enough, or if something else is
still happening here.

This builds on top of the #19779 PR which has some small fixes, but is
incomplete to make this work.

### What's happening

Looking at some of the comments, it looks like a few things are
happening such as:

1. The upgrade tool is emptying out my files — it looks like these are
only happening if you ctrl+c while the process is taking a while. It
could be that a lot of files are being checked and therefore the tooling
looks like its stuck.
6. The upgrade tool is looking at files it shouldn't look at — in
Tailwind CSS v4 we have this concept of the auto-content detection. This
means that we will look at any plain text file that is not git ignored.

### Fixes


#### Emptying out files

The files being emptied looks like it's because how `fs.writeFile`
behaves by default. It opens the file handle with the `w` flag, which
will first truncate the file before writing the new contents. This is
not a single atomic operation, so a killed process in the middle will
cause invalid state.

When we migrate your template files, everything is happening in promises
to migrate things at the same time. When a lot of files are being
scanned, truncating might have happened already before we write the new
content. Since we migrate a bunch of files in parallel, a ctrl+c could
cause data loss in multiple files.

To mitigate this, I switched to an alternative way of writing files. 

1. First, we do some quick checks where if the contents didn't change we
just bail out immediately. Files that don't include Tailwind CSS classes
won't change, and therefore we don't need to override these files with
the same contents.
2. When the migrated contents is empty, we bail out as well. I'm 100%
sure that this is not the spot where the "emptying out" happens, I still
believe it happens in the `writeFile` itself, but added it just in case.
7. Next, I introduced a safe write, where we first write to a temporary
file in the same folder. We could write it to `/tmp`, but then we can't
guarantee that we are on the same file system.
   
If we ctrl+c at this stage, then the worst case scenario is that you
have additional temporary files in your project, but your original files
are still there.
   
Once that file was written, we will use the atomic `fs.rename`. This
should be atomic as long as we are on the same file system, so either
the rename didn't happen yet, or it completed.

I added an integration test for this, but I had to change the
`writeFile` implementation slightly. In the test, we will truncate the
file first, after that we will write the new contents. This is so that
we have enough time to kill the current process and allows us to verify
that we didn't clear out the file. Again, this is a hacky way of testing
this, just because I can't reproduce this issue myself, let alone
reproduce it reliable in a CI environment.

Note: we are also using `realpath` to make sure that we are updating the
real file. Otherwise, if we were dealing with a symlinked file, we would
override the symlink with a "hard" copy instead.

#### Touching files that should not be touched

During the migration, we rely on the Tailwind CSS v4 auto detection
logic which means that it will scan any plain text file that is not git
ignored. Therefore changes to php files could happen because in theory
they could contain Tailwind CSS classes.

To solve this, when migrating from Tailwind CSS v3 to Tailwind CSS v4,
we will _only_ take the sources into account that were listed in the
`config.content` array. Since this was a requirement in Tailwind CSS v3,
it should be safe to rely on this array.

Additionally, this will make sure that we are dealing with way fewer
files to migrate as well.

On top of that, files that match the patterns in the content array that
are git ignored will also be skipped. This is to prevent that we mutate
files in `node_modules` for example.

In one of the comments I read that `.env` files were emptied out. In
most cases people will have these files gitignored but I explicitly
added `.env` and `.env.*` as files to never ever touch by default when
scanning.

Last but not least, this also updates the output a little bit of the
upgrade tool in case we skip content files (because of git ignore) and
if we changed a file.

<img width="1122" height="1376" alt="image"
src="https://github.com/user-attachments/assets/318fdbbf-e319-4c7e-9648-ee9283842624"
/>

- "Git ignored folder, skipping: `./node_modules`": this is because the
content array looks like this while the `node_modules` are being
ignored:
<img width="1090" height="398" alt="image"
src="https://github.com/user-attachments/assets/7d694720-5671-47ec-bb2e-f24c5f2c4248"
/>

- "Migrated
`./resources/views/vendor/filament-panels/components/logo.blade.php`":
this is because **I** made a change to showcase this feature.

Fixes: #18972
Closes: #19779

### Test plan

1. Existing tests still pass
1. Added a dedicated integration test to ensure that we only take
`config.content` into account when migrating from Tailwind CSS v3 to
Tailwind CSS v4 projects.
1. Added a dedicated integration test to make sure that files listed in
`config.content` that are also git ignored, will still be skipped.
1. Added a dedicated integration test to ensure that when `writeFile` is
cancelled mid-write that our old files are still present.
1. Added a dedicated integration test to ensure that we ignore `.env`
and `.env.*` files even if you didn't git ignore them.

[ci-all] To verify on Windows

---------

Co-authored-by: Sami <sychocouldy@gmail.com>
2026-03-24 17:24:12 +01:00
Robin Malfait
2c1ef9eb25
Use --placeholder-color instead of --background-color for placeholder-* utilities (#19843)
This PR fixes an issue where `placeholder-*` utilities were reading
values from `--background-color` instead of `--placeholder-color`. In
Tailwind CSS v3, we read from `placeholderColor` which is why we should
use `--placeholder-color` here as well.


f38be227df/src/corePlugins.js (L2317)

That said, this is technically a breaking change in case somebody relies
on `--background-color` for `placeholder` values. But since this is text
related, and most people will rely on the default `--color` values
instead, I think it's safe to change this as-is.

In the unlikely event that somebody _does_ rely on this, then we have 2
options:

1. Guide them to make use of `--placeholder-color` instead (preferred
solution)
2. Re-add `--background-color` after the `--placeholder-color` (band-aid
solution, but might be worth it who knows)

Fixes: #19838

## Test plan

1. Existing tests still pass
2. Verified in the Tailwind CSS v3 codebase that we did read from
`placeholderColor` which in turn reads from `color` by default. Which is
equivalent to `--placeholder-color` and `--color` in Tailwind CSS v4.
2026-03-23 15:57:20 +01:00
Robin Malfait
6ec8b89191
update CHANGELOG 2026-03-23 15:35:48 +01:00
Robin Malfait
28d526859d
Collapse more utilities by expanding their declarations (#19842)
This PR adds more declaration expansions such that we can collapse more
utilities.

While testing #19837 I noticed that in my tests some utilities weren't
canonicalized correctly. As part of that PR, we check for
`parsedCandidate.value === null`, which means that a functional utility
without a value is skipped. We do have utilities like that such as
`border` (which is equivalent to `border-1`). But while testing, I
noticed that `border-x border-y` should collapse to `border` but they
didn't. This PR fixes that.

By expanding these properties to their long-form physical properties
(instead of the shorter logical properties) we make the signatures of
utilities a bit bigger, but also more correct such that we can collapse
the physical form into logical utilities.

To make this more concrete, this PR allows for the following
canonicalizations now:

| Input | Output |
| --- | --- |
| `border-t-123 border-r-123 border-b-123 border-l-123` | `border-123` |
| `border-t-1 border-r-1 border-b-1 border-l-1` | `border` |
| `border-t-123 border-b-123` | `border-y-123` |
| `border-l-123 border-r-123` | `border-x-123` |
| `border-t-red-500 border-r-red-500 border-b-red-500 border-l-red-500`
| `border-red-500` |
| `border-t-red-500 border-b-red-500` | `border-y-red-500` |
| `border-l-red-500 border-r-red-500` | `border-x-red-500` |
| `scroll-mt-123 scroll-mr-123 scroll-mb-123 scroll-ml-123` |
`scroll-m-123` |
| `scroll-mt-123 scroll-mb-123` | `scroll-my-123` |
| `scroll-ml-123 scroll-mr-123` | `scroll-mx-123` |
| `scroll-pt-123 scroll-pr-123 scroll-pb-123 scroll-pl-123` |
`scroll-p-123` |
| `scroll-pt-123 scroll-pb-123` | `scroll-py-123` |
| `scroll-pl-123 scroll-pr-123` | `scroll-px-123` |
| `overflow-x-hidden overflow-y-hidden` | `overflow-hidden` |
| `overscroll-x-contain overscroll-y-contain` | `overscroll-contain` |

## Test plan

1. Existing tests pass
2. Added a few more tests to verify that these canonicalizations work
2026-03-23 15:33:44 +01:00
Aaron Tinio
b55d96002c
fix(canonicalize): collapse arbitrary values into shorthand utilities (#19837)
The guard on `dynamicUtilities` restricted root-swapping to named
values, so arbitrary values like `px-[1.2rem] py-[1.2rem]` were never
collapsed into `p-[1.2rem]`.

This is what caused #19835 — in `--stream` mode, the collapse only
happened if an earlier line caused the shorthand to be registered in
`STATIC_UTILITIES_KEY` as a side effect, making the output
non-deterministic. The underlying issue is that arbitrary value collapse
wasn't supported at all.

The fix relaxes the guard from `parsedCandidate.value?.kind !== 'named'`
to `parsedCandidate.value === null`. `cloneCandidate` and
`printCandidate` already handle arbitrary values, so the root-swapping
machinery works without other changes.

The iteration in `dynamicUtilities` is over
`designSystem.utilities.keys('functional')` — a fixed set of roots, not
input-proportional — so the performance cost of including arbitrary
values should be negligible.

Fixes #19835.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-03-23 11:32:25 +01:00
Qingyu Wang
17d324f896
Fix webpack loader cache key for resource queries (#19723)
<!--

👋 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?

-->

`@tailwindcss/webpack` currently uses `this.resourcePath` as the cache
key, which ignores the resource query. When the same CSS file is
imported multiple times with different `resourceQuery` values, all of
those imports share a single `CacheEntry`. That means the utilities
discovered for one entry can leak into the CSS output for another entry.

This PR changes the cache key to use `this.resource` (path + query)
instead, while still using `this.resourcePath` for all filesystem work.

## 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 test:integrations -- webpack/loader.test.ts`  
  - Confirms all existing webpack loader integration tests pass.  
- Confirms the new `@tailwindcss/webpack loader isolates cache by
resource including query` test passes, verifying that two entries
importing the same CSS file with different queries produce isolated
outputs (`dist/a.css` only contains `only-a` / `--color-red-500`, and
`dist/b.css` only contains `only-b` / `--color-blue-500`).

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-03-20 16:34:11 +00:00
Amir Zarrinkafsh
5cb1efdf41
fix(vite): resolve tsconfig paths in CSS and JS resolvers (#19803)
Change `aliasOnly` from `true` to `false` when calling Vite's resolver
so that the full resolution pipeline runs, including the oxc resolver
responsible for tsconfig path resolution.

When `aliasOnly` was `true`, only the @rollup/plugin-alias plugin ran,
which meant `resolve.tsconfigPaths: true` had no effect on CSS `@import`
or JS `@plugin` resolution in `@tailwindcss/vite`.

Closes #19802.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-03-20 15:19:18 +00:00
Robin Malfait
bd30a716e6
Fix crash due to invalid characters in candidate (#19829)
This PR fixes an issue where the compiler can crash if it encounters an
invalid codepoint.

When we extract potential candidates from files, it could be that we
encounter values that look like a class or a CSS variable, if it turns
out that it's an invalid CSS variable we can ignore it.

The problem is that sometimes there are escaped values in there that
result in invalid code points crashing the compiler.

This PR fixes that by gracefully handling that and making sure that
invalid code points are replaced by `\uFFFD` as per the spec.

The bug report
(https://github.com/tailwindlabs/tailwindcss/issues/19786) has a clean
example where a piece of text looks like a CSS variable, but contains
invalid code points.

```
--Coding-Projects-CharacterMapper-Master-Workspace\d8819554-4725-4235-9d22-2d0ed572e924
```

Luckily we can fix this today by ignoring the file paths that contain
these strings using `@source not "…";`, but the better way is to
actually fix this.

To solve this, instead of blindly passing numbers to
`String.fromCodePoint`, we will first validate whether it's a valid
codepoint:

1. `0x0000` — `0x10FFFF` (inclusive) is the range of valid code points.
See: https://infra.spec.whatwg.org/#code-point
2. `0xD800` — `0xDBFF` (inclusive) are leading surrogates. See:
https://infra.spec.whatwg.org/#leading-surrogate
3. `0xDC00` — `0xDFFF` (inclusive) are trailing surrogates. See:
https://infra.spec.whatwg.org/#trailing-surrogate

In the code we use the `0xD800` — `0xDFFF` range because the ranges
overlap.

There are various references in the spec to replace surrogates (and
invalid codepoints) with `\uFFFD`. Here is one of them:
https://drafts.csswg.org/css-syntax-3/#consume-escaped-code-point

Fixes: https://github.com/tailwindlabs/tailwindcss/issues/19786
Fixes: #19801 (this issue talks about a similar invalid code point
issue)

## Test plan

1. Added a regression test where the above string was used as a CSS
variable
2. Added a regression test for the unescape functionality to make sure
that invalid code points and surrogates are replaced by the `\uFFFD`
replacement character.

[ci-all] Just to verify on Windows as well
2026-03-20 14:51:37 +01:00
Robin Malfait
7482d47a54
Add canonicalizations for tracking-* utilities (#19827)
This PR adds support for canonicalizations for `tracking-*` utilities.

This one is a bit of a funny one, if you take a look at the linked
issue, there is a beautiful table:

| Utility Name | Value | Arbitrary Value | Throws Suggestion |
| - | -: | - | - |
| tracking-tighter | -0.05em | tracking-[-0.05em] | ✗ |
| tracking-tight | -0.025em | tracking-[-0.025em] | ✗ |
| tracking-normal | 0em | tracking-[0em] | ✗ |
| tracking-wide | 0.025em | tracking-[0.025em] | ✗ |
| tracking-wider | 0.05em | tracking-[0.05em] | ✗ |
| tracking-widest | 0.1em | tracking-[0.1em] | ✓ |

It doesn't really make sense to _why_ only the `tracking-widest` one is
properly suggested here. Until you look a little bit closer.

Turns out that `-tracking-tighter` is equivalent to `tracking-wider`,
`-tracking-tight` is equivalent to `tracking-wide` and so on.

The way the canonicalization works internally is by generating a
signature for a given utility class. If two utilities have the exact
same signature, we can consider them the same. In this case
`tracking-widest` and `tracking-[0.1em]` have the same signature.

One of the rules we have internally is that if we find more than one
replacement utility then we don't really know what to do, so we bail.
Because if you get `foo` or `bar`, which one do you pick?

If we refer to this above table again, the moment we want to
canonicalize the `tracking-[-0.05em]` we get two suggestions:
`tracking-tighter` and `-tracking-wider`, since we don't know what to
do, we bail and we don't suggest anything.

So the reason that `tracking-widest` _was_ suggested is just because we
don't have a `-tracking-tightest`.

How do we fix this? Well, since we have `tracking-*` and `-tracking-*`
utilities, I wanted to deprecate the `-tracking-*` ones for named
utilities (where the values come from your theme) because that doesn't
really make sense.

However, we have this exact pattern documented here:
https://tailwindcss.com/docs/letter-spacing#using-negative-values Which
means that I can't just deprecate those utilities.

<img width="723" height="511" alt="image"
src="https://github.com/user-attachments/assets/164b659b-abe9-4f6e-a176-701dd7ea505a"
/>

Instead, I added a different rule which says that if you get multiple
possible replacements, then we prefer the "positive" one, the one
without the `-`. Also added some additional checks to make sure that if
you get `foo`, `-bar`, `baz`, that we also bail because we know that we
should prefer `foo` or `baz` over `-bar`, but we don't know if we should
pick `foo` or `baz`...

This additional rule does solve the original issue, and we already
prefer possible values over negative values in other places (related to
bare values).

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

## Test plan

1. Existing tests pass
2. Added regression tests to make sure that the table from above _does_
get canonicalized correctly into the expected values.
2026-03-19 19:53:16 +01:00
225 changed files with 38213 additions and 20254 deletions

View file

@ -27,8 +27,10 @@ jobs:
- name: Linux - name: Linux
os: namespace-profile-default 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 - name: macOS
os: macos-14 os: macos-15
# Exclude windows and macos from being built on feature branches # Exclude windows and macos from being built on feature branches
run-all: run-all:
@ -47,21 +49,21 @@ jobs:
name: ${{ matrix.runner.name }} name: ${{ matrix.runner.name }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@v4 with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
# Cargo already skips downloading dependencies if they already exist # Cargo already skips downloading dependencies if they already exist
- name: Cache cargo - name: Cache cargo
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with: with:
path: | path: |
~/.cargo/bin/
~/.cargo/registry/index/ ~/.cargo/registry/index/
~/.cargo/registry/cache/ ~/.cargo/registry/cache/
~/.cargo/git/db/ ~/.cargo/git/db/
@ -70,7 +72,7 @@ jobs:
# Cache the `oxide` Rust build # Cache the `oxide` Rust build
- name: Cache oxide build - name: Cache oxide build
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with: with:
path: | path: |
./crates/node/*.node ./crates/node/*.node
@ -88,7 +90,7 @@ jobs:
run: rustup target add wasm32-wasip1-threads run: rustup target add wasm32-wasip1-threads
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install --frozen-lockfile
- name: Build - name: Build
run: pnpm run build run: pnpm run build
@ -116,7 +118,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Notify Discord - name: Notify Discord
uses: discord-actions/message@v2 uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2
with: with:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }}
message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.' message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'

View file

@ -55,25 +55,25 @@ jobs:
name: ${{ matrix.runner.name }} / ${{ matrix.integration }} name: ${{ matrix.runner.name }} / ${{ matrix.integration }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@v4 with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- run: | - run: |
git config --global user.name "github-actions[bot]" git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
# Cargo already skips downloading dependencies if they already exist # Cargo already skips downloading dependencies if they already exist
- name: Cache cargo - name: Cache cargo
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with: with:
path: | path: |
~/.cargo/bin/
~/.cargo/registry/index/ ~/.cargo/registry/index/
~/.cargo/registry/cache/ ~/.cargo/registry/cache/
~/.cargo/git/db/ ~/.cargo/git/db/
@ -82,7 +82,7 @@ jobs:
# Cache the `oxide` Rust build # Cache the `oxide` Rust build
- name: Cache oxide build - name: Cache oxide build
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with: with:
path: | path: |
./crates/node/*.node ./crates/node/*.node
@ -119,7 +119,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Notify Discord - name: Notify Discord
uses: discord-actions/message@v2 uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2
with: with:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }}
message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.' message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'

View file

@ -2,6 +2,12 @@ name: Prepare Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
dry_run:
description: Skip creating the draft GitHub release
required: false
default: true
type: boolean
push: push:
tags: tags:
- 'v*' - 'v*'
@ -9,6 +15,7 @@ on:
env: env:
APP_NAME: tailwindcss-oxide APP_NAME: tailwindcss-oxide
NODE_VERSION: 24 NODE_VERSION: 24
PNPM_VERSION: '11.9.0'
OXIDE_LOCATION: ./crates/node OXIDE_LOCATION: ./crates/node
permissions: permissions:
@ -47,44 +54,40 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
strip: strip strip: strip
container: build-flags: --use-napi-cross
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
- os: ubuntu-latest - os: ubuntu-latest
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
strip: llvm-strip strip: aarch64-linux-gnu-strip
container: build-flags: --use-napi-cross
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
- os: ubuntu-latest - os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf target: armv7-unknown-linux-gnueabihf
strip: llvm-strip strip: arm-linux-gnueabihf-strip
container: build-flags: --use-napi-cross
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
- os: ubuntu-latest - os: ubuntu-latest
target: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl
strip: aarch64-linux-musl-strip strip-zig: true
download: true build-flags: -x
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl
strip: strip strip: strip
download: true build-flags: -x
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
name: Build ${{ matrix.target }} (oxide) name: Build ${{ matrix.target }} (oxide)
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
container: ${{ matrix.container }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@v4 with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm' package-manager-cache: false
- name: Install gcc-arm-linux-gnueabihf - name: Install gcc-arm-linux-gnueabihf
if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }} if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
@ -92,62 +95,56 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
# Cargo already skips downloading dependencies if they already exist - name: Install binutils-aarch64-linux-gnu
- name: Cache cargo if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }}
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v5
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Install Node.JS
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust (Stable)
if: ${{ matrix.download }}
run: | run: |
rustup default stable sudo apt-get update
sudo apt-get install binutils-aarch64-linux-gnu -y
- uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
if: ${{ contains(matrix.target, 'musl') }}
with:
version: 0.14.1
use-cache: false
- name: Install cargo-zigbuild
uses: taiki-e/install-action@65851e10cd6c377f11a60e600abc07cb08643468 # v2
if: ${{ contains(matrix.target, 'musl') }}
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tool: cargo-zigbuild
- name: Setup rust target - name: Setup rust target
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
- name: Install dependencies - name: Install dependencies
run: pnpm install --ignore-scripts --filter=!./playgrounds/* run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/*
- name: Build release - name: Build release
run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} ${{ matrix.build-flags }}
env: env:
RUST_TARGET: ${{ matrix.target }} RUST_TARGET: ${{ matrix.target }}
JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }} JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
- name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034 - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
if: ${{ matrix.strip }} if: ${{ matrix.strip || matrix.strip-zig }}
run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node env:
STRIP_COMMAND: ${{ matrix.strip }}
STRIP_ZIG: ${{ matrix.strip-zig }}
run: |
if [ "$STRIP_ZIG" = "true" ]; then
for file in ${{ env.OXIDE_LOCATION }}/*.node; do
zig objcopy --strip-all "$file" "$file.stripped"
mv "$file.stripped" "$file"
done
exit 0
fi
eval "$STRIP_COMMAND ${{ env.OXIDE_LOCATION }}/*.node"
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: bindings-${{ matrix.target }} name: bindings-${{ matrix.target }}
path: ${{ env.OXIDE_LOCATION }}/*.node path: ${{ env.OXIDE_LOCATION }}/*.node
@ -157,9 +154,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Build FreeBSD - name: Build FreeBSD
uses: cross-platform-actions/action@v0.25.0 uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # v0.25.0
env: env:
DEBUG: napi:* DEBUG: napi:*
RUSTUP_HOME: /usr/local/rustup RUSTUP_HOME: /usr/local/rustup
@ -175,12 +174,12 @@ jobs:
shell: bash shell: bash
run: | run: |
sudo pkg install -y -f curl node libnghttp2 npm sudo pkg install -y -f curl node libnghttp2 npm
sudo npm install -g pnpm@9.6.0 --unsafe-perm=true sudo npm install -g pnpm@${{ env.PNPM_VERSION }} --unsafe-perm=true
curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init
chmod +x rustup-init chmod +x rustup-init
./rustup-init -y --profile minimal ./rustup-init -y --profile minimal
source "$HOME/.cargo/env" source "$HOME/.cargo/env"
pnpm install --ignore-scripts --filter=!./playgrounds/* || true pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* || true
echo "~~~~ rustc --version ~~~~" echo "~~~~ rustc --version ~~~~"
rustc --version rustc --version
echo "~~~~ node -v ~~~~" echo "~~~~ node -v ~~~~"
@ -191,7 +190,7 @@ jobs:
strip -x ${{ env.OXIDE_LOCATION }}/*.node strip -x ${{ env.OXIDE_LOCATION }}/*.node
ls -la ${{ env.OXIDE_LOCATION }} ls -la ${{ env.OXIDE_LOCATION }}
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: bindings-x86_64-unknown-freebsd name: bindings-x86_64-unknown-freebsd
path: ${{ env.OXIDE_LOCATION }}/*.node path: ${{ env.OXIDE_LOCATION }}/*.node
@ -202,18 +201,17 @@ jobs:
name: Build and release Tailwind CSS name: Build and release Tailwind CSS
permissions: permissions:
contents: write # for softprops/action-gh-release to create GitHub release contents: write # Required for creating releases
# https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions
id-token: write
needs: needs:
- build - build
- build-freebsd - build-freebsd
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: with:
fetch-depth: 20 fetch-depth: 20
persist-credentials: false
- run: git fetch --tags -f - run: git fetch --tags -f
@ -222,51 +220,25 @@ jobs:
run: | run: |
echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v5
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Setup WASM target - name: Setup WASM target
run: rustup target add wasm32-wasip1-threads run: rustup target add wasm32-wasip1-threads
- name: Install dependencies - name: Install dependencies
run: pnpm --filter=!./playgrounds/* install run: pnpm --filter=!./playgrounds/* install --frozen-lockfile
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@v7 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
path: ${{ env.OXIDE_LOCATION }} path: ${{ env.OXIDE_LOCATION }}
@ -305,30 +277,31 @@ jobs:
echo "EOF" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV
- name: Upload standalone artifacts - name: Upload standalone artifacts
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: tailwindcss-standalone name: tailwindcss-standalone
path: packages/@tailwindcss-standalone/dist/ path: packages/@tailwindcss-standalone/dist/
- name: Upload npm package tarballs - name: Upload npm package tarballs
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: npm-package-tarballs name: npm-package-tarballs
path: dist/*.tgz path: dist/*.tgz
- name: Prepare GitHub Release - name: Prepare GitHub Release
uses: softprops/action-gh-release@v2 if: ${{ !inputs.dry_run }}
with: env:
draft: true GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ env.TAG_NAME }} run: |
body: | gh release create "${TAG_NAME}" \
${{ env.RELEASE_NOTES }} --draft \
files: | --title "${TAG_NAME}" \
packages/@tailwindcss-standalone/dist/sha256sums.txt --notes "${RELEASE_NOTES}" \
packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64 packages/@tailwindcss-standalone/dist/sha256sums.txt \
packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64 \
packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64 packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl \
packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64 \
packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64 packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl \
packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64 packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64 \
packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64 \
packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe

View file

@ -1,322 +0,0 @@
name: Release Insiders
on:
push:
branches: [main]
permissions:
contents: read
env:
APP_NAME: tailwindcss-oxide
NODE_VERSION: 24
OXIDE_LOCATION: ./crates/node
RELEASE_CHANNEL: insiders
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
strategy:
matrix:
include:
# Windows
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: windows-latest
target: aarch64-pc-windows-msvc
# macOS
- os: macos-latest
target: x86_64-apple-darwin
strip: strip -x # Must use -x on macOS. This produces larger results on linux.
- os: macos-latest
target: aarch64-apple-darwin
page-size: 14
strip: strip -x # Must use -x on macOS. This produces larger results on linux.
# Android
- os: ubuntu-latest
target: aarch64-linux-android
strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
- os: ubuntu-latest
target: armv7-linux-androideabi
strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
# Linux
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
strip: strip
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
strip: llvm-strip
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
strip: llvm-strip
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
strip: aarch64-linux-musl-strip
download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
strip: strip
download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
name: Build ${{ matrix.target }} (oxide)
runs-on: ${{ matrix.os }}
container: ${{ matrix.container }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install gcc-arm-linux-gnueabihf
if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
run: |
sudo apt-get update
sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v5
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Install Node.JS
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust (Stable)
if: ${{ matrix.download }}
run: |
rustup default stable
- name: Setup rust target
run: rustup target add ${{ matrix.target }}
- name: Install dependencies
run: pnpm install --ignore-scripts --filter=!./playgrounds/*
- name: Build release
run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }}
env:
RUST_TARGET: ${{ matrix.target }}
JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
- name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
if: ${{ matrix.strip }}
run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node
- name: Upload artifacts
uses: actions/upload-artifact@v6
with:
name: bindings-${{ matrix.target }}
path: ${{ env.OXIDE_LOCATION }}/*.node
build-freebsd:
name: Build x86_64-unknown-freebsd (OXIDE)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Build FreeBSD
uses: cross-platform-actions/action@v0.25.0
env:
DEBUG: napi:*
RUSTUP_HOME: /usr/local/rustup
CARGO_HOME: /usr/local/cargo
RUSTUP_IO_THREADS: 1
RUST_TARGET: x86_64-unknown-freebsd
with:
operating_system: freebsd
version: '14.0'
memory: 13G
cpu_count: 3
environment_variables: 'DEBUG RUSTUP_IO_THREADS'
shell: bash
run: |
sudo pkg install -y -f curl node libnghttp2 npm
sudo npm install -g pnpm@9.6.0 --unsafe-perm=true
curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init
chmod +x rustup-init
./rustup-init -y --profile minimal
source "$HOME/.cargo/env"
echo "~~~~ rustc --version ~~~~"
rustc --version
echo "~~~~ node -v ~~~~"
node -v
echo "~~~~ pnpm --version ~~~~"
pnpm --version
pnpm install --ignore-scripts --filter=!./playgrounds/* || true
pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform
strip -x ${{ env.OXIDE_LOCATION }}/*.node
ls -la ${{ env.OXIDE_LOCATION }}
- name: Upload artifacts
uses: actions/upload-artifact@v6
with:
name: bindings-x86_64-unknown-freebsd
path: ${{ env.OXIDE_LOCATION }}/*.node
release:
runs-on: macos-14
timeout-minutes: 15
name: Build and release Tailwind CSS insiders
permissions:
contents: write # for softprops/action-gh-release to create GitHub release
# https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions
id-token: write
needs:
- build
- build-freebsd
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 20
- name: Resolve version
id: vars
run: |
echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v5
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Setup WASM target
run: rustup target add wasm32-wasip1-threads
- name: Install dependencies
run: pnpm --filter=!./playgrounds/* install
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: ${{ env.OXIDE_LOCATION }}
- name: Move artifacts
run: |
cd ${{ env.OXIDE_LOCATION }}
cp bindings-x86_64-pc-windows-msvc/* ./npm/win32-x64-msvc/
cp bindings-aarch64-pc-windows-msvc/* ./npm/win32-arm64-msvc/
cp bindings-x86_64-apple-darwin/* ./npm/darwin-x64/
cp bindings-aarch64-apple-darwin/* ./npm/darwin-arm64/
cp bindings-aarch64-linux-android/* ./npm/android-arm64/
cp bindings-armv7-linux-androideabi/* ./npm/android-arm-eabi/
cp bindings-aarch64-unknown-linux-gnu/* ./npm/linux-arm64-gnu/
cp bindings-aarch64-unknown-linux-musl/* ./npm/linux-arm64-musl/
cp bindings-armv7-unknown-linux-gnueabihf/* ./npm/linux-arm-gnueabihf/
cp bindings-x86_64-unknown-linux-gnu/* ./npm/linux-x64-gnu/
cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/
cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/
- name: 'Version based on commit: 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }}'
run: pnpm run version-packages 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }}
- name: Build Tailwind CSS
run: pnpm run build
- name: Run pre-publish optimizations scripts
run: node ./scripts/pre-publish-optimizations.mjs
- name: Lock pre-release versions
run: node ./scripts/lock-pre-release-versions.mjs
- name: Upload npm package tarballs
uses: actions/upload-artifact@v6
with:
name: npm-package-tarballs
path: dist/*.tgz
- name: Publish
run: |
pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks
# The wasm package needs a special npm config that isn't read when pnpm --recursive is used
pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks; popd;
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger Tailwind Play update
uses: actions/github-script@v8
with:
github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'tailwindlabs',
repo: 'upgrades',
ref: 'main',
workflow_id: 'upgrade-tailwindcss.yml'
})

View file

@ -1,9 +1,20 @@
name: Release name: Release
on: on:
push:
branches: [main]
release: release:
types: [published] types: [published]
workflow_dispatch: workflow_dispatch:
inputs:
channel:
description: Release channel to publish
required: true
default: insiders
type: choice
options:
- insiders
- release
permissions: permissions:
contents: read contents: read
@ -11,8 +22,13 @@ permissions:
env: env:
APP_NAME: tailwindcss-oxide APP_NAME: tailwindcss-oxide
NODE_VERSION: 24 NODE_VERSION: 24
PNPM_VERSION: '11.9.0'
OXIDE_LOCATION: ./crates/node OXIDE_LOCATION: ./crates/node
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build: build:
strategy: strategy:
@ -42,44 +58,40 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
strip: strip strip: strip
container: build-flags: --use-napi-cross
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
- os: ubuntu-latest - os: ubuntu-latest
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
strip: llvm-strip strip: aarch64-linux-gnu-strip
container: build-flags: --use-napi-cross
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
- os: ubuntu-latest - os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf target: armv7-unknown-linux-gnueabihf
strip: llvm-strip strip: arm-linux-gnueabihf-strip
container: build-flags: --use-napi-cross
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
- os: ubuntu-latest - os: ubuntu-latest
target: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl
strip: aarch64-linux-musl-strip strip-zig: true
download: true build-flags: -x
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl
strip: strip strip: strip
download: true build-flags: -x
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
name: Build ${{ matrix.target }} (oxide) name: Build ${{ matrix.target }} (oxide)
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
container: ${{ matrix.container }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@v4 with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm' package-manager-cache: false
- name: Install gcc-arm-linux-gnueabihf - name: Install gcc-arm-linux-gnueabihf
if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }} if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
@ -87,62 +99,56 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
# Cargo already skips downloading dependencies if they already exist - name: Install binutils-aarch64-linux-gnu
- name: Cache cargo if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }}
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v5
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Install Node.JS
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust (Stable)
if: ${{ matrix.download }}
run: | run: |
rustup default stable sudo apt-get update
sudo apt-get install binutils-aarch64-linux-gnu -y
- uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
if: ${{ contains(matrix.target, 'musl') }}
with:
version: 0.14.1
use-cache: false
- name: Install cargo-zigbuild
uses: taiki-e/install-action@65851e10cd6c377f11a60e600abc07cb08643468 # v2
if: ${{ contains(matrix.target, 'musl') }}
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tool: cargo-zigbuild
- name: Setup rust target - name: Setup rust target
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
- name: Install dependencies - name: Install dependencies
run: pnpm install --ignore-scripts --filter=!./playgrounds/* run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/*
- name: Build release - name: Build release
run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} ${{ matrix.build-flags }}
env: env:
RUST_TARGET: ${{ matrix.target }} RUST_TARGET: ${{ matrix.target }}
JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }} JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
- name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034 - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
if: ${{ matrix.strip }} if: ${{ matrix.strip || matrix.strip-zig }}
run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node env:
STRIP_COMMAND: ${{ matrix.strip }}
STRIP_ZIG: ${{ matrix.strip-zig }}
run: |
if [ "$STRIP_ZIG" = "true" ]; then
for file in ${{ env.OXIDE_LOCATION }}/*.node; do
zig objcopy --strip-all "$file" "$file.stripped"
mv "$file.stripped" "$file"
done
exit 0
fi
eval "$STRIP_COMMAND ${{ env.OXIDE_LOCATION }}/*.node"
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: bindings-${{ matrix.target }} name: bindings-${{ matrix.target }}
path: ${{ env.OXIDE_LOCATION }}/*.node path: ${{ env.OXIDE_LOCATION }}/*.node
@ -152,9 +158,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Build FreeBSD - name: Build FreeBSD
uses: cross-platform-actions/action@v0.25.0 uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # v0.25.0
env: env:
DEBUG: napi:* DEBUG: napi:*
RUSTUP_HOME: /usr/local/rustup RUSTUP_HOME: /usr/local/rustup
@ -170,7 +178,7 @@ jobs:
shell: bash shell: bash
run: | run: |
sudo pkg install -y -f curl node libnghttp2 npm sudo pkg install -y -f curl node libnghttp2 npm
sudo npm install -g pnpm@9.6.0 --unsafe-perm=true sudo npm install -g pnpm@${{ env.PNPM_VERSION }} --unsafe-perm=true
curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init
chmod +x rustup-init chmod +x rustup-init
./rustup-init -y --profile minimal ./rustup-init -y --profile minimal
@ -181,12 +189,12 @@ jobs:
node -v node -v
echo "~~~~ pnpm --version ~~~~" echo "~~~~ pnpm --version ~~~~"
pnpm --version pnpm --version
pnpm install --ignore-scripts --filter=!./playgrounds/* || true pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* || true
pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform
strip -x ${{ env.OXIDE_LOCATION }}/*.node strip -x ${{ env.OXIDE_LOCATION }}/*.node
ls -la ${{ env.OXIDE_LOCATION }} ls -la ${{ env.OXIDE_LOCATION }}
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: bindings-x86_64-unknown-freebsd name: bindings-x86_64-unknown-freebsd
path: ${{ env.OXIDE_LOCATION }}/*.node path: ${{ env.OXIDE_LOCATION }}/*.node
@ -194,10 +202,10 @@ jobs:
release: release:
runs-on: macos-14 runs-on: macos-14
timeout-minutes: 15 timeout-minutes: 15
name: Build and release Tailwind CSS name: Build and publish Tailwind CSS
permissions: permissions:
contents: write # for softprops/action-gh-release to create GitHub release contents: read
# https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions
id-token: write id-token: write
@ -206,55 +214,51 @@ jobs:
- build-freebsd - build-freebsd
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: with:
fetch-depth: 20 fetch-depth: 20
persist-credentials: false
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
# Cargo already skips downloading dependencies if they already exist # npm trusted publishing validates the caller workflow filename, so all npm publishes live here.
- name: Cache cargo # This workflow rebuilds the publish artifacts instead of depending on prepare-release.yml.
uses: actions/cache@v5 - name: Resolve release metadata
with: env:
path: | INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }}
~/.cargo/bin/ run: |
~/.cargo/registry/index/ if [[ "${{ github.event_name }}" == "release" || "$INPUT_CHANNEL" == "release" ]]; then
~/.cargo/registry/cache/ release_channel=$(node ./scripts/release-channel.js)
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build echo "RELEASE_KIND=release" >> $GITHUB_ENV
- name: Cache oxide build echo "RELEASE_CHANNEL=$release_channel" >> $GITHUB_ENV
uses: actions/cache@v5 echo "FEATURES_ENV=stable" >> $GITHUB_ENV
with: else
path: | sha_short=$(git rev-parse --short HEAD)
./crates/node/*.node
./crates/node/*.wasm echo "RELEASE_KIND=insiders" >> $GITHUB_ENV
./crates/node/index.d.ts echo "RELEASE_CHANNEL=insiders" >> $GITHUB_ENV
./crates/node/index.js echo "SHA_SHORT=$sha_short" >> $GITHUB_ENV
./crates/node/browser.js echo "INSIDERS_VERSION=0.0.0-insiders.$sha_short" >> $GITHUB_ENV
./crates/node/tailwindcss-oxide.wasi-browser.js fi
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Setup WASM target - name: Setup WASM target
run: rustup target add wasm32-wasip1-threads run: rustup target add wasm32-wasip1-threads
- name: Install dependencies - name: Install dependencies
run: pnpm --filter=!./playgrounds/* install run: pnpm --filter=!./playgrounds/* install --frozen-lockfile
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@v7 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
path: ${{ env.OXIDE_LOCATION }} path: ${{ env.OXIDE_LOCATION }}
@ -274,10 +278,19 @@ jobs:
cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/ cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/
cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/ cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/
- name: 'Version based on commit: ${{ env.INSIDERS_VERSION }}'
if: env.RELEASE_KIND == 'insiders'
run: pnpm run version-packages ${INSIDERS_VERSION}
- name: Build Tailwind CSS - name: Build Tailwind CSS
if: env.RELEASE_KIND == 'insiders'
run: pnpm run build
- name: Build Tailwind CSS
if: env.RELEASE_KIND == 'release'
run: pnpm run build run: pnpm run build
env: env:
FEATURES_ENV: stable FEATURES_ENV: ${{ env.FEATURES_ENV }}
- name: Run pre-publish optimizations scripts - name: Run pre-publish optimizations scripts
run: node ./scripts/pre-publish-optimizations.mjs run: node ./scripts/pre-publish-optimizations.mjs
@ -285,22 +298,20 @@ jobs:
- name: Lock pre-release versions - name: Lock pre-release versions
run: node ./scripts/lock-pre-release-versions.mjs run: node ./scripts/lock-pre-release-versions.mjs
- name: Calculate environment variables - name: Upload npm package tarballs
run: | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
echo "RELEASE_CHANNEL=$(node ./scripts/release-channel.js)" >> $GITHUB_ENV with:
echo "TAILWINDCSS_VERSION=$(node -e 'console.log(require(`./packages/tailwindcss/package.json`).version);')" >> $GITHUB_ENV name: npm-package-tarballs
path: dist/*.tgz
- name: Publish - name: Publish
run: | run: |
pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${RELEASE_CHANNEL} --no-git-checks
# The wasm package needs a special npm config that isn't read when pnpm --recursive is used # The wasm package needs a special npm config that isn't read when pnpm --recursive is used
pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks; popd; pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${RELEASE_CHANNEL} --no-git-checks --config.node-linker=hoisted; popd;
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger Tailwind Play update - name: Trigger Tailwind Play update
if: env.RELEASE_CHANNEL == 'latest' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with: with:
github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }} github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }}
script: | script: |
@ -310,3 +321,38 @@ jobs:
ref: 'main', ref: 'main',
workflow_id: 'upgrade-tailwindcss.yml' workflow_id: 'upgrade-tailwindcss.yml'
}) })
notify:
if: ${{ always() && (needs.build.result == 'failure' || needs.build-freebsd.result == 'failure' || needs.release.result == 'failure') }}
needs:
- build
- build-freebsd
- release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Resolve release label
id: release
env:
INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }}
RELEASE_TAG: ${{ github.event.release.tag_name || '' }}
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
tag_name="${RELEASE_TAG:-${GITHUB_REF_NAME}}"
echo "label=release ${tag_name}" >> $GITHUB_OUTPUT
elif [[ "$INPUT_CHANNEL" == "release" ]]; then
version=$(node -p "require('./packages/tailwindcss/package.json').version")
echo "label=release v${version}" >> $GITHUB_OUTPUT
else
sha_short=$(git rev-parse --short HEAD)
echo "label=insiders release 0.0.0-insiders.${sha_short}" >> $GITHUB_OUTPUT
fi
- name: Notify Discord
uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2
with:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }}
message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) for `${{ steps.release.outputs.label }}` has failed.'

View file

@ -9,7 +9,169 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- _Experimental_: Add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901)) - 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
### Fixed
- Support bare spacing values for `auto-rows-*` and `auto-cols-*` utilities (e.g. `auto-rows-12` and `auto-cols-16`) ([#20229](https://github.com/tailwindlabs/tailwindcss/pull/20229))
- Prevent `@tailwindcss/cli` in `--watch` mode from crashing on Windows when `@source` points to a directory that doesn't exist ([#20242](https://github.com/tailwindlabs/tailwindcss/pull/20242))
- Prevent `@tailwindcss/vite` from crashing in Deno v2.8.x when `context.parentURL` is not a valid URL ([#20245](https://github.com/tailwindlabs/tailwindcss/pull/20245))
- Ensure `@tailwindcss/cli` in `--watch` mode rebuilds when the input CSS file changes in an ignored directory ([#20246](https://github.com/tailwindlabs/tailwindcss/pull/20246))
- Allow `@variant` rules used in `addBase(…)` to use custom variants defined later ([#20247](https://github.com/tailwindlabs/tailwindcss/pull/20247))
- Prevent `@tailwindcss/vite` from crashing during HMR when scanned files or directories are deleted ([#20259](https://github.com/tailwindlabs/tailwindcss/pull/20259))
- Generate `font-size` instead of `color` declarations for `text-[--spacing(…)]` ([#20260](https://github.com/tailwindlabs/tailwindcss/pull/20260))
- Prevent `@source` patterns from scanning unrelated sibling files and folders ([#20263](https://github.com/tailwindlabs/tailwindcss/pull/20263))
- Extract class candidates adjacent to Template Toolkit delimiters like `%]…[%` in `.tt`, `.tt2`, and `.tx` files ([#20269](https://github.com/tailwindlabs/tailwindcss/pull/20269))
- Extract class candidates from conditional Maud syntax like `p.text-black[condition]` ([#20269](https://github.com/tailwindlabs/tailwindcss/pull/20269))
- Prevent `@position-try` rules from triggering unknown at-rule warnings when optimizing CSS ([#20277](https://github.com/tailwindlabs/tailwindcss/pull/20277))
- Support class suggestions for named opacity modifiers from `--opacity` theme values ([#20287](https://github.com/tailwindlabs/tailwindcss/pull/20287))
- Prevent type errors in `@tailwindcss/postcss` when used with newer PostCSS patch releases ([#20289](https://github.com/tailwindlabs/tailwindcss/pull/20289))
## [4.3.1] - 2026-06-12
### Added
- Add `--silent` option to suppress output in `@tailwindcss/cli` ([#20100](https://github.com/tailwindlabs/tailwindcss/pull/20100))
### Fixed
- Remove deprecation warnings by using `Module#registerHooks` instead of `Module#register` on Node 26+ ([#20028](https://github.com/tailwindlabs/tailwindcss/pull/20028))
- Canonicalization: don't crash when plugin utilities throw for unsupported values ([#20052](https://github.com/tailwindlabs/tailwindcss/pull/20052))
- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427))
- Ensure `not-*` correctly negates `@container` queries, including `style(…)` queries ([#20059](https://github.com/tailwindlabs/tailwindcss/pull/20059))
- Ensure `drop-shadow-*` color utilities work with custom shadow values containing `calc(…)` ([#20080](https://github.com/tailwindlabs/tailwindcss/pull/20080))
- Fix 'Sourcemap is likely to be incorrect' warnings when using `@tailwindcss/vite` ([#20103](https://github.com/tailwindlabs/tailwindcss/pull/20103))
- Ensure `@tailwindcss/webpack` can be installed in Rspack projects without requiring `webpack` as a peer dependency ([#20027](https://github.com/tailwindlabs/tailwindcss/pull/20027))
- Canonicalization: don't suggest invalid `calc(…)` expressions (e.g. `px-[calc(1rem+0px)]` → `px-[calc(1rem+0)]`) ([#20127](https://github.com/tailwindlabs/tailwindcss/pull/20127))
- Canonicalization: avoid suggesting large spacing-scale values for arbitrary lengths (e.g. `left-[99999px]` → `left-[99999px]`, not `left-24999.75`) ([#20130](https://github.com/tailwindlabs/tailwindcss/pull/20130))
- Ensure `@tailwindcss/cli` in `--watch` mode recovers when a tracked dependency is deleted and restored ([#20137](https://github.com/tailwindlabs/tailwindcss/pull/20137))
- Ensure standalone `@tailwindcss/cli` binaries are ignored when scanning for class candidates ([#20139](https://github.com/tailwindlabs/tailwindcss/pull/20139))
- Ensure class candidates are extracted from Twig `addClass(…)` and `removeClass(…)` calls ([#20198](https://github.com/tailwindlabs/tailwindcss/pull/20198))
- Don't crash in the Ruby or Vue preprocessors when scanning files containing invalid UTF-8 bytes ([#19588](https://github.com/tailwindlabs/tailwindcss/pull/19588))
- Allow `@variant` to be used inside `addBase` ([#19480](https://github.com/tailwindlabs/tailwindcss/pull/19480))
- Ensure `@source` globs with symlinks are preserved ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203))
- Ensure later `@source` rules can re-include files excluded by earlier `@source not` rules ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203))
- Upgrade: don't migrate empty class rules to invalid `@utility` rules ([#20205](https://github.com/tailwindlabs/tailwindcss/pull/20205))
- Ensure transitions between `inset-shadow-none` and other inset shadows work correctly ([#20208](https://github.com/tailwindlabs/tailwindcss/pull/20208))
- Ensure explicitly referenced `@source` directories are scanned even when ignored by git ([#20214](https://github.com/tailwindlabs/tailwindcss/pull/20214))
- Ensure `@source` globs ending in `**/*` preserve dynamic path segments to avoid scanning too many files ([#20217](https://github.com/tailwindlabs/tailwindcss/pull/20217))
- Canonicalization: don't fold `calc(…)` divisions when the result would require high precision (e.g. `w-[calc(100%/3.5)]` → `w-[calc(100%/3.5)]`, not `w-[28.571428571428573%]`) ([#20221](https://github.com/tailwindlabs/tailwindcss/pull/20221))
- Serve ESM type declarations to ESM importers of `@tailwindcss/postcss` ([#20228](https://github.com/tailwindlabs/tailwindcss/pull/20228))
### Changed
- Generate `0` instead of `calc(var(--spacing) * 0)` for spacing utilities like `m-0` and `left-0` ([#20196](https://github.com/tailwindlabs/tailwindcss/pull/20196))
- Generate `var(--spacing)` instead of `calc(var(--spacing) * 1)` for spacing utilities like `m-1` and `left-1` ([#20196](https://github.com/tailwindlabs/tailwindcss/pull/20196))
## [4.3.0] - 2026-05-08
### Added
- Add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901))
- Add `scrollbar-{auto,thin,none}` utilities for `scrollbar-width`, and `scrollbar-thumb-*` / `scrollbar-track-*` color utilities for `scrollbar-color` ([#19981](https://github.com/tailwindlabs/tailwindcss/pull/19981), [#20019](https://github.com/tailwindlabs/tailwindcss/pull/20019))
- Add `scrollbar-gutter-*` utilities ([#20018](https://github.com/tailwindlabs/tailwindcss/pull/20018))
- Add `zoom-*` utilities ([#20020](https://github.com/tailwindlabs/tailwindcss/pull/20020))
- Add `tab-*` utilities ([#20022](https://github.com/tailwindlabs/tailwindcss/pull/20022))
- Allow using `@variant` with stacked variants (e.g. `@variant hover:focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996))
- Allow using `@variant` with compound variants (e.g. `@variant hover, focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996))
- Support `--default(…)` in `--value(…)` and `--modifier(…)` for functional `@utility` definitions ([#19989](https://github.com/tailwindlabs/tailwindcss/pull/19989))
### Fixed
- Ensure `@plugin` resolves package JavaScript entries instead of browser CSS entries when using `@tailwindcss/vite` ([#19949](https://github.com/tailwindlabs/tailwindcss/pull/19949))
- Fix relative `@import` and `@plugin` paths resolving from the wrong directory when using `@tailwindcss/vite` ([#19965](https://github.com/tailwindlabs/tailwindcss/pull/19965))
- Ensure CSS files containing `@variant` are processed by `@tailwindcss/vite` ([#19966](https://github.com/tailwindlabs/tailwindcss/pull/19966))
- Resolve imports relative to `base` when `result.opts.from` is not provided when using `@tailwindcss/postcss` ([#19980](https://github.com/tailwindlabs/tailwindcss/pull/19980))
- Canonicalization: preserve significant `_` whitespace in arbitrary values ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986))
- Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability (e.g. `w-[calc(100%---spacing(60))]` → `w-[calc(100%-(--spacing(60)))]`) ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986))
- Canonicalization: preserve the original unit in arbitrary values instead of normalizing to base units (e.g. `-mt-[20in]` → `mt-[-20in]`, not `mt-[-1920px]`) ([#19988](https://github.com/tailwindlabs/tailwindcss/pull/19988))
- Canonicalization: migrate arbitrary `:has()` variants from `[&:has(…)]` to `has-[…]` ([#19991](https://github.com/tailwindlabs/tailwindcss/pull/19991))
- Upgrade: don’t migrate inline `style` attributes (e.g. `style="flex-grow: 1"` → `style="flex-grow: 1"`, not `style="grow: 1"`) ([#19918](https://github.com/tailwindlabs/tailwindcss/pull/19918))
- Allow multiple `@utility` definitions with the same name but different value types ([#19777](https://github.com/tailwindlabs/tailwindcss/pull/19777))
- Export missing `PluginWithConfig` type from `tailwindcss/plugin` to fix errors when inferring plugin config types ([#19707](https://github.com/tailwindlabs/tailwindcss/pull/19707))
- Ensure `start` and `end` legacy utilities without values do not generate CSS ([#20003](https://github.com/tailwindlabs/tailwindcss/pull/20003))
- Ensure `--value(…)` is required in functional `@utility` definitions ([#20005](https://github.com/tailwindlabs/tailwindcss/pull/20005))
- Canonicalization: preserve required whitespace around operators in negated arbitrary values (e.g. `-left-[(var(--a)+var(--b))]`) ([#20011](https://github.com/tailwindlabs/tailwindcss/pull/20011))
## [4.2.4] - 2026-04-21
### Fixed
- Ensure imports in `@import` and `@plugin` still resolve correctly when using Vite aliases in `@tailwindcss/vite` ([#19947](https://github.com/tailwindlabs/tailwindcss/pull/19947))
## [4.2.3] - 2026-04-20
### Fixed
- Canonicalization: improve canonicalization for `tracking-*` utilities by preferring non-negative utilities (e.g. `-tracking-tighter` → `tracking-wider`) ([#19827](https://github.com/tailwindlabs/tailwindcss/pull/19827))
- Fix crash due to invalid characters in candidate (exceeding valid unicode code point range) ([#19829](https://github.com/tailwindlabs/tailwindcss/pull/19829))
- Ensure query params in imports are considered unique resources when using `@tailwindcss/webpack` ([#19723](https://github.com/tailwindlabs/tailwindcss/pull/19723))
- Canonicalization: collapse arbitrary values into shorthand utilities (e.g. `px-[1.2rem] py-[1.2rem]` → `p-[1.2rem]`) ([#19837](https://github.com/tailwindlabs/tailwindcss/pull/19837))
- Canonicalization: collapse `border-{t,b}-*` into `border-y-*`, `border-{l,r}-*` into `border-x-*`, and `border-{t,r,b,l}-*` into `border-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `scroll-m{t,b}-*` into `scroll-my-*`, `scroll-m{l,r}-*` into `scroll-mx-*`, and `scroll-m{t,r,b,l}-*` into `scroll-m-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `scroll-p{t,b}-*` into `scroll-py-*`, `scroll-p{l,r}-*` into `scroll-px-*`, and `scroll-p{t,r,b,l}-*` into `scroll-p-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `overflow-{x,y}-*` into `overflow-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `overscroll-{x,y}-*` into `overscroll-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Read from `--placeholder-color` instead of `--background-color` for `placeholder-*` utilities ([#19843](https://github.com/tailwindlabs/tailwindcss/pull/19843))
- Upgrade: ensure files are not emptied out when killing the upgrade process while it's running ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
- Upgrade: use `config.content` when migrating from Tailwind CSS v3 to Tailwind CSS v4 ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
- Upgrade: never migrate files that are ignored by git ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
- Add `.env` and `.env.*` to default ignored content files ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
- Canonicalization: migrate `overflow-ellipsis` into `text-ellipsis` ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
- Canonicalization: migrate `start-full` → `inset-s-full`, `start-auto` → `inset-s-auto`, `start-px` → `inset-s-px`, and `start-<number>` → `inset-s-<number>` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
- Canonicalization: migrate `end-full` → `inset-e-full`, `end-auto` → `inset-e-auto`, `end-px` → `inset-e-px`, and `end-<number>` → `inset-e-<number>` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
- Canonicalization: move the `-` sign inside the arbitrary value `-left-[9rem]` → `left-[-9rem]` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858))
- Canonicalization: move the `-` sign outside the arbitrary value `ml-[calc(-1*var(--width))]` → `-ml-(--width)` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858))
- Improve performance when scanning JSONL / NDJSON files ([#19862](https://github.com/tailwindlabs/tailwindcss/pull/19862))
- Support `NODE_PATH` environment variable in standalone CLI ([#19617](https://github.com/tailwindlabs/tailwindcss/pull/19617))
## [4.2.2] - 2026-03-18 ## [4.2.2] - 2026-03-18
@ -22,6 +184,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for Vite 8 in `@tailwindcss/vite` ([#19790](https://github.com/tailwindlabs/tailwindcss/pull/19790)) - Add support for Vite 8 in `@tailwindcss/vite` ([#19790](https://github.com/tailwindlabs/tailwindcss/pull/19790))
- Improve canonicalization for bare values exceeding default spacing scale suggestions (e.g. `w-1234 h-1234` → `size-1234`) ([#19809](https://github.com/tailwindlabs/tailwindcss/pull/19809)) - Improve canonicalization for bare values exceeding default spacing scale suggestions (e.g. `w-1234 h-1234` → `size-1234`) ([#19809](https://github.com/tailwindlabs/tailwindcss/pull/19809))
- Fix canonicalization resulting in empty list (e.g. `w-5 h-5 size-5` → `''` instead of `size-5`) ([#19812](https://github.com/tailwindlabs/tailwindcss/pull/19812)) - Fix canonicalization resulting in empty list (e.g. `w-5 h-5 size-5` → `''` instead of `size-5`) ([#19812](https://github.com/tailwindlabs/tailwindcss/pull/19812))
- Resolve tsconfig paths to allow for `@import '@/path/to/file';` when using `@tailwindcss/vite` ([#19803](https://github.com/tailwindlabs/tailwindcss/pull/19803))
## [4.2.1] - 2026-02-23 ## [4.2.1] - 2026-02-23
@ -3960,7 +4123,13 @@ No release notes
- Everything! - Everything!
[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.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
[4.2.4]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.3...v4.2.4
[4.2.3]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.2...v4.2.3
[4.2.2]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.1...v4.2.2 [4.2.2]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.1...v4.2.2
[4.2.1]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.0...v4.2.1 [4.2.1]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.0...v4.2.1
[4.2.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.18...v4.2.0 [4.2.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.18...v4.2.0

251
Cargo.lock generated
View file

@ -40,7 +40,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0" checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
dependencies = [ dependencies = [
"memchr", "memchr",
"regex-automata 0.4.8", "regex-automata 0.4.18",
"serde", "serde",
] ]
@ -60,10 +60,21 @@ dependencies = [
] ]
[[package]] [[package]]
name = "convert_case" name = "console"
version = "0.8.0" version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
@ -104,19 +115,9 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]] [[package]]
name = "ctor" name = "ctor"
version = "0.5.0" version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f"
dependencies = [
"ctor-proc-macro",
"dtor",
]
[[package]]
name = "ctor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
[[package]] [[package]]
name = "diff" name = "diff"
@ -124,21 +125,6 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "dtor"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934"
dependencies = [
"dtor-proc-macro",
]
[[package]]
name = "dtor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
@ -151,6 +137,12 @@ version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.9" version = "0.3.9"
@ -177,15 +169,103 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6"
[[package]] [[package]]
name = "globset" name = "futures"
version = "0.4.17" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eab69130804d941f8075cfd713bf8848a2c3b3f201a9457a11e6f87e1ab62305" checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "globset"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"bstr", "bstr",
"log", "log",
"regex-automata 0.4.8", "regex-automata 0.4.18",
"regex-syntax 0.8.5", "regex-syntax 0.8.5",
] ]
@ -210,7 +290,7 @@ dependencies = [
"globset", "globset",
"log", "log",
"memchr", "memchr",
"regex-automata 0.4.8", "regex-automata 0.4.18",
"same-file", "same-file",
"walkdir", "walkdir",
"winapi-util", "winapi-util",
@ -218,7 +298,7 @@ dependencies = [
[[package]] [[package]]
name = "ignore" name = "ignore"
version = "0.4.24" version = "0.4.33"
dependencies = [ dependencies = [
"bstr", "bstr",
"crossbeam-channel", "crossbeam-channel",
@ -227,12 +307,24 @@ dependencies = [
"globset", "globset",
"log", "log",
"memchr", "memchr",
"regex-automata 0.4.8", "regex-automata 0.4.18",
"same-file", "same-file",
"walkdir", "walkdir",
"winapi-util", "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]] [[package]]
name = "itertools" name = "itertools"
version = "0.11.0" version = "0.11.0"
@ -256,12 +348,12 @@ checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5"
[[package]] [[package]]
name = "libloading" name = "libloading"
version = "0.8.5" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-targets", "windows-link",
] ]
[[package]] [[package]]
@ -299,12 +391,13 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "napi" name = "napi"
version = "3.3.0" version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1b74e3dce5230795bb4d2821b941706dee733c7308752507254b0497f39cad7" checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"ctor", "ctor",
"futures",
"napi-build", "napi-build",
"napi-sys", "napi-sys",
"nohash-hasher", "nohash-hasher",
@ -313,15 +406,15 @@ dependencies = [
[[package]] [[package]]
name = "napi-build" name = "napi-build"
version = "2.2.3" version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcae8ad5609d14afb3a3b91dee88c757016261b151e9dcecabf1b2a31a6cab14" checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]] [[package]]
name = "napi-derive" name = "napi-derive"
version = "3.2.5" version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7552d5a579b834614bbd496db5109f1b9f1c758f08224b0dee1e408333adf0d0" checksum = "a49c513341a61a16a10af6efcce46b30d0822ba2d4fb197d24d33dfc199c78d5"
dependencies = [ dependencies = [
"convert_case", "convert_case",
"ctor", "ctor",
@ -333,9 +426,9 @@ dependencies = [
[[package]] [[package]]
name = "napi-derive-backend" name = "napi-derive-backend"
version = "2.2.0" version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6a81ac7486b70f2532a289603340862c06eea5a1e650c1ffeda2ce1238516a" checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd"
dependencies = [ dependencies = [
"convert_case", "convert_case",
"proc-macro2", "proc-macro2",
@ -346,9 +439,9 @@ dependencies = [
[[package]] [[package]]
name = "napi-sys" name = "napi-sys"
version = "3.0.0" version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e4e7135a8f97aa0f1509cce21a8a1f9dcec1b50d8dee006b48a5adb69a9d64d" checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a"
dependencies = [ dependencies = [
"libloading", "libloading",
] ]
@ -381,9 +474,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.19.0" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "overload" name = "overload"
@ -418,18 +511,18 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.28" version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.10.0" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [ dependencies = [
"either", "either",
"rayon-core", "rayon-core",
@ -437,9 +530,9 @@ dependencies = [
[[package]] [[package]]
name = "rayon-core" name = "rayon-core"
version = "1.12.1" version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [ dependencies = [
"crossbeam-deque", "crossbeam-deque",
"crossbeam-utils", "crossbeam-utils",
@ -453,7 +546,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
"regex-automata 0.4.8", "regex-automata 0.4.18",
"regex-syntax 0.8.5", "regex-syntax 0.8.5",
] ]
@ -468,9 +561,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.8" version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -538,6 +631,18 @@ dependencies = [
"lazy_static", "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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.10.0" version = "1.10.0"
@ -546,9 +651,9 @@ checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.18" version = "2.0.87"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -576,7 +681,8 @@ dependencies = [
"dunce", "dunce",
"fast-glob", "fast-glob",
"globwalk", "globwalk",
"ignore 0.4.24", "ignore 0.4.33",
"insta",
"log", "log",
"pretty_assertions", "pretty_assertions",
"rayon", "rayon",
@ -738,6 +844,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@ -756,6 +868,15 @@ dependencies = [
"windows-targets", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "ignore" name = "ignore"
version = "0.4.24" #:version version = "0.4.33" #:version
authors = ["Andrew Gallant <jamslam@gmail.com>"] authors = ["Andrew Gallant <jamslam@gmail.com>"]
description = """ description = """
A fast library for efficiently matching ignore files such as `.gitignore` 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" readme = "README.md"
keywords = ["glob", "ignore", "gitignore", "pattern", "file"] keywords = ["glob", "ignore", "gitignore", "pattern", "file"]
license = "Unlicense OR MIT" 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" edition = "2024"
rust-version = "1.88"
[lib] [lib]
name = "ignore" name = "ignore"
@ -20,15 +23,17 @@ bench = false
[dependencies] [dependencies]
crossbeam-deque = "0.8.3" 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" log = "0.4.20"
memchr = "2.6.3" memchr = "2.6.3"
same-file = "1.0.6" same-file = "1.0.6"
walkdir = "2.4.0" walkdir = "2.4.0"
# CHANGED: Added `dunce` to canonicalize paths without UNC prefixes on Windows.
dunce = "1.0.5" dunce = "1.0.5"
[dependencies.regex-automata] [dependencies.regex-automata]
version = "0.4.0" version = "0.4.18"
default-features = false default-features = false
features = ["std", "perf", "syntax", "meta", "nfa", "hybrid", "dfa-onepass"] 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 stdout_thread = std::thread::spawn(move || {
let mut stdout = std::io::BufWriter::new(std::io::stdout()); let mut stdout = std::io::BufWriter::new(std::io::stdout());
for dent in rx { for dent in rx {
stdout stdout.write_all(&Vec::from_path_lossy(dent.path())).unwrap();
.write_all(&Vec::from_path_lossy(dent.path()))
.unwrap();
stdout.write_all(b"\n").unwrap(); stdout.write_all(b"\n").unwrap();
} }
}); });

View file

@ -47,6 +47,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["cml"], &["*.cml"]), (&["cml"], &["*.cml"]),
(&["coffeescript"], &["*.coffee"]), (&["coffeescript"], &["*.coffee"]),
(&["config"], &["*.cfg", "*.conf", "*.config", "*.ini"]), (&["config"], &["*.cfg", "*.conf", "*.config", "*.ini"]),
(&["container"], &["*Containerfile*", "*Dockerfile*"]),
(&["coq"], &["*.v"]), (&["coq"], &["*.v"]),
(&["cpp"], &[ (&["cpp"], &[
"*.[ChH]", "*.cc", "*.[ch]pp", "*.[ch]xx", "*.hh", "*.inl", "*.[ChH]", "*.cc", "*.[ch]pp", "*.[ch]xx", "*.hh", "*.inl",
@ -109,6 +110,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["hbs"], &["*.hbs"]), (&["hbs"], &["*.hbs"]),
(&["hs"], &["*.hs", "*.lhs"]), (&["hs"], &["*.hs", "*.lhs"]),
(&["html"], &["*.htm", "*.html", "*.ejs"]), (&["html"], &["*.htm", "*.html", "*.ejs"]),
(&["hurl"], &["*.hurl"]),
(&["hy"], &["*.hy"]), (&["hy"], &["*.hy"]),
(&["idris"], &["*.idr", "*.lidr"]), (&["idris"], &["*.idr", "*.lidr"]),
(&["janet"], &["*.janet"]), (&["janet"], &["*.janet"]),
@ -185,6 +187,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["mint"], &["*.mint"]), (&["mint"], &["*.mint"]),
(&["mk"], &["mkfile"]), (&["mk"], &["mkfile"]),
(&["ml"], &["*.ml"]), (&["ml"], &["*.ml"]),
(&["mojo"], &["*.mojo"]),
(&["motoko"], &["*.mo"]), (&["motoko"], &["*.mo"]),
(&["msbuild"], &[ (&["msbuild"], &[
"*.csproj", "*.fsproj", "*.vcxproj", "*.proj", "*.props", "*.targets", "*.csproj", "*.fsproj", "*.vcxproj", "*.proj", "*.props", "*.targets",
@ -206,11 +209,12 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
"*.php", "*.php3", "*.php4", "*.php5", "*.php7", "*.php8", "*.php", "*.php3", "*.php4", "*.php5", "*.php7", "*.php8",
"*.pht", "*.phtml" "*.pht", "*.phtml"
]), ]),
(&["pkgbuild"], &["PKGBUILD"]),
(&["po"], &["*.po"]), (&["po"], &["*.po"]),
(&["pod"], &["*.pod"]), (&["pod"], &["*.pod"]),
(&["postscript"], &["*.eps", "*.ps"]), (&["postscript"], &["*.eps", "*.ps"]),
(&["prolog"], &["*.pl", "*.pro", "*.prolog", "*.P"]), (&["prolog"], &["*.pl", "*.pro", "*.prolog", "*.P"]),
(&["protobuf"], &["*.proto"]), (&["proto", "protobuf"], &["*.proto"]),
(&["ps"], &["*.cdxml", "*.ps1", "*.ps1xml", "*.psd1", "*.psm1"]), (&["ps"], &["*.cdxml", "*.ps1", "*.ps1xml", "*.psd1", "*.psm1"]),
(&["puppet"], &["*.epp", "*.erb", "*.pp", "*.rb"]), (&["puppet"], &["*.epp", "*.erb", "*.pp", "*.rb"]),
(&["purs"], &["*.purs"]), (&["purs"], &["*.purs"]),
@ -231,6 +235,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["red"], &["*.r", "*.red", "*.reds"]), (&["red"], &["*.r", "*.red", "*.reds"]),
(&["rescript"], &["*.res", "*.resi"]), (&["rescript"], &["*.res", "*.resi"]),
(&["robot"], &["*.robot"]), (&["robot"], &["*.robot"]),
(&["rocq"], &["*.v"]),
(&["rst"], &["*.rst"]), (&["rst"], &["*.rst"]),
(&["ruby"], &[ (&["ruby"], &[
// Idiomatic files // Idiomatic files
@ -274,6 +279,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["spark"], &["*.spark"]), (&["spark"], &["*.spark"]),
(&["spec"], &["*.spec"]), (&["spec"], &["*.spec"]),
(&["sql"], &["*.sql", "*.psql"]), (&["sql"], &["*.sql", "*.psql"]),
(&["ssa"], &["*.ssa"]),
(&["stylus"], &["*.styl"]), (&["stylus"], &["*.styl"]),
(&["sv"], &["*.v", "*.vg", "*.sv", "*.svh", "*.h"]), (&["sv"], &["*.v", "*.vg", "*.sv", "*.svh", "*.h"]),
(&["svelte"], &["*.svelte", "*.svelte.ts"]), (&["svelte"], &["*.svelte", "*.svelte.ts"]),
@ -359,4 +365,14 @@ mod tests {
previous_name = name; 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 /// Note that I/O errors are ignored. For more granular control over
/// errors, use `GitignoreBuilder`. /// 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 path = gitignore_path.as_ref();
let parent = path.parent().unwrap_or(Path::new("/")); let parent = path.parent().unwrap_or(Path::new("/"));
let mut builder = GitignoreBuilder::new(parent); let mut builder = GitignoreBuilder::new(parent);
@ -123,6 +125,17 @@ impl Gitignore {
/// The global config file path is specified by git's `core.excludesFile` /// The global config file path is specified by git's `core.excludesFile`
/// config option. /// 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` /// Git's config file location is `$HOME/.gitconfig`. If `$HOME/.gitconfig`
/// does not exist or does not specify `core.excludesFile`, then /// does not exist or does not specify `core.excludesFile`, then
/// `$XDG_CONFIG_HOME/git/ignore` is read. If `$XDG_CONFIG_HOME` is not /// `$XDG_CONFIG_HOME/git/ignore` is read. If `$XDG_CONFIG_HOME` is not
@ -145,7 +158,8 @@ impl Gitignore {
num_ignores: 0, num_ignores: 0,
num_whitelists: 0, num_whitelists: 0,
matches: None, 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, only_on_files: false,
} }
} }
@ -190,7 +204,11 @@ impl Gitignore {
/// determined by a common suffix of the directory containing this /// determined by a common suffix of the directory containing this
/// gitignore) is stripped. If there is no common suffix/prefix overlap, /// gitignore) is stripped. If there is no common suffix/prefix overlap,
/// then `path` is assumed to be relative to this matcher. /// 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() { if self.is_empty() {
return Match::None; return Match::None;
} }
@ -243,11 +261,16 @@ impl Gitignore {
} }
/// Like matched, but takes a path that has already been stripped. /// 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() { if self.is_empty() {
return Match::None; 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 { if self.only_on_files && is_dir {
return Match::None; return Match::None;
} }
@ -270,7 +293,10 @@ impl Gitignore {
/// Strips the given path such that it's suitable for matching with this /// Strips the given path such that it's suitable for matching with this
/// gitignore matcher. /// 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(); let mut path = path.as_ref();
// A leading ./ is completely superfluous. We also strip it from // A leading ./ is completely superfluous. We also strip it from
// our gitignore root path, so we need to strip it from our candidate // our gitignore root path, so we need to strip it from our candidate
@ -326,7 +352,8 @@ impl GitignoreBuilder {
globs: vec![], globs: vec![],
case_insensitive: false, case_insensitive: false,
allow_unclosed_class: true, 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, only_on_files: false,
} }
} }
@ -337,18 +364,21 @@ impl GitignoreBuilder {
pub fn build(&self) -> Result<Gitignore, Error> { pub fn build(&self) -> Result<Gitignore, Error> {
let nignore = self.globs.iter().filter(|g| !g.is_whitelist()).count(); let nignore = self.globs.iter().filter(|g| !g.is_whitelist()).count();
let nwhite = 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 { let set = self
glob: None, .builder
err: err.to_string(), .build()
})?; .map_err(|err| Error::Glob { glob: None, err: err.to_string() })?;
Ok(Gitignore { Ok(Gitignore {
set, set,
root: self.root.clone(), root: self.root.clone(),
globs: self.globs.clone(), globs: self.globs.clone(),
num_ignores: nignore as u64, num_ignores: nignore as u64,
num_whitelists: nwhite as u64, num_whitelists: nwhite as u64,
matches: Some(Arc::new(Pool::new(|| vec![]))), matches: Some(Arc::new(
// CHANGED: Add a flag to have Gitignore rules that apply only to files. 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, 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 // Match Git's handling of .gitignore files that begin with the Unicode BOM
const UTF8_BOM: &str = "\u{feff}"; const UTF8_BOM: &str = "\u{feff}";
let line = if i == 0 { let line =
line.trim_start_matches(UTF8_BOM) if i == 0 { line.trim_start_matches(UTF8_BOM) } else { &line };
} else {
&line
};
if let Err(err) = self.add_line(Some(path.to_path_buf()), &line) { if let Err(err) = self.add_line(Some(path.to_path_buf()), &line) {
errs.push(err.tagged(path, lineno)); errs.push(err.tagged(path, lineno));
@ -537,7 +564,10 @@ impl GitignoreBuilder {
/// affected. /// affected.
/// ///
/// This is disabled by default. /// 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 // TODO: This should not return a `Result`. Fix this in the next semver
// release. // release.
self.case_insensitive = yes; self.case_insensitive = yes;
@ -556,7 +586,10 @@ impl GitignoreBuilder {
/// modes since the glob parser becomes more permissive. You might want to /// modes since the glob parser becomes more permissive. You might want to
/// enable this when compatibility (e.g., with POSIX glob implementations) /// enable this when compatibility (e.g., with POSIX glob implementations)
/// is more important than good error messages. /// 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.allow_unclosed_class = yes;
self self
} }
@ -576,32 +609,56 @@ impl GitignoreBuilder {
/// ///
/// Note that the file path returned may not exist. /// Note that the file path returned may not exist.
pub fn gitconfig_excludes_path() -> Option<PathBuf> { pub fn gitconfig_excludes_path() -> Option<PathBuf> {
// git supports $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config. Notably, // When GIT_CONFIG_GLOBAL is set, it replaces both $HOME/.gitconfig and
// both can be active at the same time, where $HOME/.gitconfig takes // $XDG_CONFIG_HOME/git/config (per git 2.32+). Otherwise, git supports
// precedent. So if $HOME/.gitconfig defines a `core.excludesFile`, then // $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config simultaneously, where
// we're done. // $HOME/.gitconfig takes precedent.
match gitconfig_home_contents().and_then(|x| parse_excludes_file(&x)) { gitconfig_global_env_contents()
Some(path) => return Some(path), .and_then(|x| parse_excludes_file(&x))
None => {} .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)
} }
match gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x)) {
Some(path) => return Some(path), /// Returns the file contents of git's global config file from the path
None => {} /// 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;
} }
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 /// Returns the file contents of git's global config file, if one exists, in
/// the user's home directory. /// the user's home directory.
fn gitconfig_home_contents() -> Option<Vec<u8>> { fn gitconfig_home_contents() -> Option<Vec<u8>> {
let home = match home_dir() { let home = home_dir()?;
None => return None, let mut file = BufReader::new(File::open(home.join(".gitconfig")).ok()?);
Some(home) => home,
};
let mut file = match File::open(home.join(".gitconfig")) {
Err(_) => return None,
Ok(file) => BufReader::new(file),
};
let mut contents = vec![]; let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents) 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. /// the user's XDG_CONFIG_HOME directory.
fn gitconfig_xdg_contents() -> Option<Vec<u8>> { fn gitconfig_xdg_contents() -> Option<Vec<u8>> {
let path = std::env::var_os("XDG_CONFIG_HOME") let path = std::env::var_os("XDG_CONFIG_HOME")
.and_then(|x| { .map(PathBuf::from)
if x.is_empty() { .filter(|x| !x.as_os_str().is_empty())
None
} else {
Some(PathBuf::from(x))
}
})
.or_else(|| home_dir().map(|p| p.join(".config"))) .or_else(|| home_dir().map(|p| p.join(".config")))
.map(|x| x.join("git/config")); .map(|x| x.join("git/config"))?;
let mut file = match path.and_then(|p| File::open(p).ok()) { let mut file = BufReader::new(File::open(path).ok()?);
None => return None,
Some(file) => BufReader::new(file),
};
let mut contents = vec![]; let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents) 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. /// Specifically, this respects XDG_CONFIG_HOME.
fn excludes_file_default() -> Option<PathBuf> { fn excludes_file_default() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME") std::env::var_os("XDG_CONFIG_HOME")
.and_then(|x| { .map(PathBuf::from)
if x.is_empty() { .filter(|x| !x.as_os_str().is_empty())
None
} else {
Some(PathBuf::from(x))
}
})
.or_else(|| home_dir().map(|p| p.join(".config"))) .or_else(|| home_dir().map(|p| p.join(".config")))
.map(|x| x.join("git/ignore")) .map(|x| x.join("git/ignore"))
} }
@ -667,9 +711,7 @@ fn parse_excludes_file(data: &[u8]) -> Option<PathBuf> {
re.captures(data, &mut caps); re.captures(data, &mut caps);
let span = caps.get_group(1)?; let span = caps.get_group(1)?;
let candidate = &data[span]; let candidate = &data[span];
std::str::from_utf8(candidate) std::str::from_utf8(candidate).ok().map(|s| PathBuf::from(expand_tilde(s)))
.ok()
.map(|s| PathBuf::from(expand_tilde(s)))
} }
/// Expands ~ in file paths to the value of $HOME. /// Expands ~ in file paths to the value of $HOME.
@ -831,7 +873,10 @@ mod tests {
fn parse_excludes_file4() { fn parse_excludes_file4() {
let data = bytes("[core]\nexcludesFile = \"~/foo/bar\""); let data = bytes("[core]\nexcludesFile = \"~/foo/bar\"");
let got = super::parse_excludes_file(&data); 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] #[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}; use std::path::{Path, PathBuf};
pub use crate::incremental::{IncrementalIgnore, IncrementalMatch};
pub use crate::walk::{ pub use crate::walk::{
DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, WalkParallel, WalkState, DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder,
WalkParallel, WalkState,
}; };
mod default_types; mod default_types;
mod dir; mod dir;
pub mod gitignore; pub mod gitignore;
mod incremental;
pub mod overrides; pub mod overrides;
mod pathutil; mod pathutil;
pub mod types; pub mod types;
@ -120,34 +123,31 @@ impl Clone for Error {
fn clone(&self) -> Error { fn clone(&self) -> Error {
match *self { match *self {
Error::Partial(ref errs) => Error::Partial(errs.clone()), Error::Partial(ref errs) => Error::Partial(errs.clone()),
Error::WithLineNumber { line, ref err } => Error::WithLineNumber { Error::WithLineNumber { line, ref err } => {
line, Error::WithLineNumber { line, err: err.clone() }
err: err.clone(), }
}, Error::WithPath { ref path, ref err } => {
Error::WithPath { ref path, ref err } => Error::WithPath { Error::WithPath { path: path.clone(), err: err.clone() }
path: path.clone(), }
err: err.clone(), Error::WithDepth { depth, ref err } => {
}, Error::WithDepth { depth, err: err.clone() }
Error::WithDepth { depth, ref err } => Error::WithDepth { }
depth, Error::Loop { ref ancestor, ref child } => Error::Loop {
err: err.clone(),
},
Error::Loop {
ref ancestor,
ref child,
} => Error::Loop {
ancestor: ancestor.clone(), ancestor: ancestor.clone(),
child: child.clone(), child: child.clone(),
}, },
Error::Io(ref err) => match err.raw_os_error() { Error::Io(ref err) => match err.raw_os_error() {
Some(e) => Error::Io(std::io::Error::from_raw_os_error(e)), 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 { Error::Glob { ref glob, ref err } => {
glob: glob.clone(), Error::Glob { glob: glob.clone(), err: err.clone() }
err: err.clone(), }
}, Error::UnrecognizedFileType(ref err) => {
Error::UnrecognizedFileType(ref err) => Error::UnrecognizedFileType(err.clone()), Error::UnrecognizedFileType(err.clone())
}
Error::InvalidDefinition => Error::InvalidDefinition, Error::InvalidDefinition => Error::InvalidDefinition,
} }
} }
@ -269,19 +269,14 @@ impl Error {
/// Turn an error into a tagged error with the given depth. /// Turn an error into a tagged error with the given depth.
fn with_depth(self, depth: usize) -> Error { fn with_depth(self, depth: usize) -> Error {
Error::WithDepth { Error::WithDepth { depth, err: Box::new(self) }
depth,
err: Box::new(self),
}
} }
/// Turn an error into a tagged error with the given file path and line /// 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. /// number. If path is empty, then it is omitted from the error.
fn tagged<P: AsRef<Path>>(self, path: P, lineno: u64) -> Error { fn tagged<P: AsRef<Path>>(self, path: P, lineno: u64) -> Error {
let errline = Error::WithLineNumber { let errline =
line: lineno, Error::WithLineNumber { line: lineno, err: Box::new(self) };
err: Box::new(self),
};
if path.as_ref().as_os_str().is_empty() { if path.as_ref().as_os_str().is_empty() {
return errline; return errline;
} }
@ -301,12 +296,12 @@ impl Error {
}; };
} }
let path = err.path().map(|p| p.to_path_buf()); 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 {
if let Some(path) = path { depth,
ig_err = Error::WithPath { err: Box::new(Error::Io(std::io::Error::from(err))),
path,
err: Box::new(ig_err),
}; };
if let Some(path) = path {
ig_err = Error::WithPath { path, err: Box::new(ig_err) };
} }
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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self { match *self {
Error::Partial(ref errs) => { 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")) write!(f, "{}", msgs.join("\n"))
} }
Error::WithLineNumber { line, ref err } => { Error::WithLineNumber { line, ref err } => {
@ -343,10 +339,7 @@ impl std::fmt::Display for Error {
write!(f, "{}: {}", path.display(), err) write!(f, "{}: {}", path.display(), err)
} }
Error::WithDepth { ref err, .. } => err.fmt(f), Error::WithDepth { ref err, .. } => err.fmt(f),
Error::Loop { Error::Loop { ref ancestor, ref child } => write!(
ref ancestor,
ref child,
} => write!(
f, f,
"File system loop found: \ "File system loop found: \
{} points to an ancestor {}", {} points to an ancestor {}",
@ -354,14 +347,8 @@ impl std::fmt::Display for Error {
ancestor.display() ancestor.display()
), ),
Error::Io(ref err) => err.fmt(f), Error::Io(ref err) => err.fmt(f),
Error::Glob { Error::Glob { glob: None, ref err } => write!(f, "{}", err),
glob: None, Error::Glob { glob: Some(ref glob), ref err } => {
ref err,
} => write!(f, "{}", err),
Error::Glob {
glob: Some(ref glob),
ref err,
} => {
write!(f, "error parsing glob '{}': {}", glob, err) write!(f, "error parsing glob '{}': {}", glob, err)
} }
Error::UnrecognizedFileType(ref ty) => { Error::UnrecognizedFileType(ref ty) => {
@ -507,7 +494,8 @@ mod tests {
}; };
/// A convenient result type alias. /// 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 { macro_rules! err {
($($tt:tt)*) => { ($($tt:tt)*) => {
@ -545,8 +533,9 @@ mod tests {
if path.is_dir() { if path.is_dir() {
continue; continue;
} }
fs::create_dir_all(&path) fs::create_dir_all(&path).map_err(|e| {
.map_err(|e| err!("failed to create {}: {}", path.display(), e))?; err!("failed to create {}: {}", path.display(), e)
})?;
return Ok(TempDir(path)); return Ok(TempDir(path));
} }
Err(err!("failed to create temp dir after {} tries", TRIES)) 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 /// 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 /// `path` is assumed to reside in the same directory as the root path for
/// this set of overrides. /// 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() { if self.is_empty() {
return Match::None; return Match::None;
} }
@ -146,7 +150,10 @@ impl OverrideBuilder {
/// affected. /// affected.
/// ///
/// This is disabled by default. /// 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 // TODO: This should not return a `Result`. Fix this in the next semver
// release. // release.
self.builder.case_insensitive(yes)?; self.builder.case_insensitive(yes)?;
@ -276,11 +283,8 @@ mod tests {
#[test] #[test]
fn default_case_sensitive() { fn default_case_sensitive() {
let ov = OverrideBuilder::new(ROOT) let ov =
.add("*.html") OverrideBuilder::new(ROOT).add("*.html").unwrap().build().unwrap();
.unwrap()
.build()
.unwrap();
assert!(ov.matched("foo.html", false).is_whitelist()); assert!(ov.matched("foo.html", false).is_whitelist());
assert!(ov.matched("foo.HTML", false).is_ignore()); assert!(ov.matched("foo.HTML", false).is_ignore());
assert!(ov.matched("foo.htm", false).is_ignore()); assert!(ov.matched("foo.htm", false).is_ignore());

View file

@ -2,30 +2,65 @@ use std::{ffi::OsStr, path::Path};
use crate::walk::DirEntry; 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. /// ## Windows
#[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.
/// ///
/// 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 base name of the path starts with a `.`.
/// * The file attributes have the `HIDDEN` property set. /// * 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_path(dent: &Path) -> bool {
#[cfg(not(windows))]
fn imp(path: &Path) -> bool {
is_hidden_path_only(path)
}
#[cfg(windows)] #[cfg(windows)]
pub(crate) fn is_hidden(dent: &DirEntry) -> bool { 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 std::os::windows::fs::MetadataExt;
use winapi_util::file; use winapi_util::file;
@ -37,20 +72,19 @@ pub(crate) fn is_hidden(dent: &DirEntry) -> bool {
return true; return true;
} }
} }
if let Some(name) = file_name(dent.path()) { is_hidden_path_only(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. imp(dent)
}
/// Returns true if and only if this path is considered to be hidden from only
/// the path itself.
/// ///
/// This only returns true if the base name of the path starts with a `.`. /// This has the same behavior on all platforms.
#[cfg(not(any(unix, windows)))] fn is_hidden_path_only(path: &Path) -> bool {
pub(crate) fn is_hidden(dent: &DirEntry) -> bool { if let Some(name) = file_name(path) {
if let Some(name) = file_name(dent.path()) { name.as_encoded_bytes().starts_with(b".")
name.to_str().map(|s| s.starts_with(".")).unwrap_or(false)
} else { } else {
false false
} }
@ -59,14 +93,15 @@ pub(crate) fn is_hidden(dent: &DirEntry) -> bool {
/// Strip `prefix` from the `path` and return the remainder. /// Strip `prefix` from the `path` and return the remainder.
/// ///
/// If `path` doesn't have a prefix `prefix`, then return `None`. /// If `path` doesn't have a prefix `prefix`, then return `None`.
#[cfg(unix)]
pub(crate) fn strip_prefix<'a, P: AsRef<Path> + ?Sized>( pub(crate) fn strip_prefix<'a, P: AsRef<Path> + ?Sized>(
prefix: &'a P, prefix: &'a P,
path: &'a Path, path: &'a Path,
) -> Option<&'a Path> { ) -> Option<&'a Path> {
#[cfg(unix)]
fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> {
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
let prefix = prefix.as_ref().as_os_str().as_bytes(); let prefix = prefix.as_os_str().as_bytes();
let path = path.as_os_str().as_bytes(); let path = path.as_os_str().as_bytes();
if prefix.len() > path.len() || prefix != &path[0..prefix.len()] { if prefix.len() > path.len() || prefix != &path[0..prefix.len()] {
None None
@ -75,49 +110,45 @@ pub(crate) fn strip_prefix<'a, P: AsRef<Path> + ?Sized>(
} }
} }
/// Strip `prefix` from the `path` and return the remainder.
///
/// If `path` doesn't have a prefix `prefix`, then return `None`.
#[cfg(not(unix))] #[cfg(not(unix))]
pub(crate) fn strip_prefix<'a, P: AsRef<Path> + ?Sized>( fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> {
prefix: &'a P,
path: &'a Path,
) -> Option<&'a Path> {
path.strip_prefix(prefix).ok() path.strip_prefix(prefix).ok()
} }
/// Returns true if this file path is just a file name. i.e., Its parent is imp(prefix.as_ref(), path)
/// 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 /// Returns true if this file path is just a file name. i.e., Its parent is
/// the empty string. /// the empty string.
#[cfg(not(unix))]
pub(crate) fn is_file_name<P: AsRef<Path>>(path: P) -> bool { pub(crate) fn is_file_name<P: AsRef<Path>>(path: P) -> bool {
#[cfg(unix)]
{
memchr::memchr(b'/', path.as_ref().as_os_str().as_encoded_bytes())
.is_none()
}
#[cfg(not(unix))]
{
path.as_ref() path.as_ref()
.parent() .parent()
.map(|p| p.as_os_str().is_empty()) .map(|p| p.as_os_str().is_empty())
.unwrap_or(false) .unwrap_or(false)
} }
}
/// The final component of the path, if it is a normal file. /// 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, /// If the path terminates in `.`, `..`, or consists solely of a root of
/// file_name will return None. /// prefix, this will return `None`.
pub(crate) fn file_name<'a, P: AsRef<Path> + ?Sized>(
path: &'a P,
) -> Option<&'a OsStr> {
#[cfg(unix)] #[cfg(unix)]
pub(crate) fn file_name<'a, P: AsRef<Path> + ?Sized>(path: &'a P) -> Option<&'a OsStr> { fn imp(path: &Path) -> Option<&OsStr> {
use memchr::memrchr;
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
let path = path.as_ref().as_os_str().as_bytes(); use memchr::memrchr;
let path = path.as_os_str().as_bytes();
if path.is_empty() { if path.is_empty() {
return None; return None;
} else if path.len() == 1 && path[0] == b'.' { } else if path.len() == 1 && path[0] == b'.' {
@ -131,11 +162,10 @@ pub(crate) fn file_name<'a, P: AsRef<Path> + ?Sized>(path: &'a P) -> Option<&'a
Some(OsStr::from_bytes(&path[last_slash..])) 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))] #[cfg(not(unix))]
pub(crate) fn file_name<'a, P: AsRef<Path> + ?Sized>(path: &'a P) -> Option<&'a OsStr> { fn imp(path: &Path) -> Option<&OsStr> {
path.as_ref().file_name() 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> { fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Selection<U> {
match self { match self {
Selection::Select(name, inner) => Selection::Select(name, f(inner)), Selection::Select(name, inner) => {
Selection::Negate(name, inner) => Selection::Negate(name, f(inner)), Selection::Select(name, f(inner))
}
Selection::Negate(name, inner) => {
Selection::Negate(name, f(inner))
}
} }
} }
@ -227,7 +231,9 @@ impl Types {
has_selected: false, has_selected: false,
glob_to_selection: vec![], glob_to_selection: vec![],
set: GlobSetBuilder::new().build().unwrap(), 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. /// 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 /// If at least one file type is selected and `path` doesn't match, then
/// the path is also considered ignored. /// 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 // File types don't apply to directories, and we can't do anything
// if our glob set is empty. // if our glob set is empty.
if is_dir || self.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 /// of default type definitions can be added with `add_defaults`, and
/// additional type definitions can be added with `select` and `negate`. /// additional type definitions can be added with `select` and `negate`.
pub fn new() -> TypesBuilder { pub fn new() -> TypesBuilder {
TypesBuilder { TypesBuilder { types: HashMap::new(), selections: vec![] }
types: HashMap::new(),
selections: vec![],
}
} }
/// Build the current set of file type definitions *and* selections into /// Build the current set of file type definitions *and* selections into
@ -343,17 +350,18 @@ impl TypesBuilder {
} }
selections.push(selection.clone().map(move |_| def)); selections.push(selection.clone().map(move |_| def));
} }
let set = build_set.build().map_err(|err| Error::Glob { let set = build_set
glob: None, .build()
err: err.to_string(), .map_err(|err| Error::Glob { glob: None, err: err.to_string() })?;
})?;
Ok(Types { Ok(Types {
defs, defs,
selections, selections,
has_selected, has_selected,
glob_to_selection, glob_to_selection,
set, 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 { pub fn select(&mut self, name: &str) -> &mut TypesBuilder {
if name == "all" { if name == "all" {
for name in self.types.keys() { for name in self.types.keys() {
self.selections self.selections.push(Selection::Select(name.to_string(), ()));
.push(Selection::Select(name.to_string(), ()));
} }
} else { } else {
self.selections self.selections.push(Selection::Select(name.to_string(), ()));
.push(Selection::Select(name.to_string(), ()));
} }
self self
} }
@ -393,12 +399,10 @@ impl TypesBuilder {
pub fn negate(&mut self, name: &str) -> &mut TypesBuilder { pub fn negate(&mut self, name: &str) -> &mut TypesBuilder {
if name == "all" { if name == "all" {
for name in self.types.keys() { for name in self.types.keys() {
self.selections self.selections.push(Selection::Negate(name.to_string(), ()));
.push(Selection::Negate(name.to_string(), ()));
} }
} else { } else {
self.selections self.selections.push(Selection::Negate(name.to_string(), ()));
.push(Selection::Negate(name.to_string(), ()));
} }
self self
} }
@ -453,7 +457,10 @@ impl TypesBuilder {
3 => { 3 => {
let name = parts[0]; let name = parts[0];
let types_string = parts[2]; 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); return Err(Error::InvalidDefinition);
} }
let types = types_string.split(','); let types = types_string.split(',');
@ -463,7 +470,8 @@ impl TypesBuilder {
return Err(Error::InvalidDefinition); return Err(Error::InvalidDefinition);
} }
for type_name in types { 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 { for glob in globs {
self.add(name, &glob)?; self.add(name, &glob)?;
} }
@ -549,30 +557,9 @@ mod tests {
matched!(not, matchnot1, types(), vec!["rust"], vec![], "index.html"); matched!(not, matchnot1, types(), vec!["rust"], vec![], "index.html");
matched!(not, matchnot2, types(), vec![], vec!["rust"], "main.rs"); matched!(not, matchnot2, types(), vec![], vec!["rust"], "main.rs");
matched!( matched!(not, matchnot3, types(), vec!["foo"], vec!["rust"], "main.rs");
not, matched!(not, matchnot4, types(), vec!["rust"], vec!["foo"], "main.rs");
matchnot3, matched!(not, matchnot5, types(), vec!["rust"], vec!["foo"], "main.foo");
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, matchnot6, types(), vec!["combo"], vec![], "leftpad.js");
matched!(not, matchnot7, types(), vec!["py"], vec![], "index.html"); matched!(not, matchnot7, types(), vec!["py"], vec![], "index.html");
matched!(not, matchnot8, types(), vec!["python"], vec![], "doc.md"); matched!(not, matchnot8, types(), vec!["python"], vec![], "doc.md");

View file

@ -17,7 +17,9 @@ use {
use crate::{ use crate::{
Error, PartialErrorBuilder, Error, PartialErrorBuilder,
dir::{Ignore, IgnoreBuilder}, dir::{Ignore, IgnoreBuilder},
// CHANGED: Also import `Gitignore` for `WalkBuilder::add_gitignore`.
gitignore::{Gitignore, GitignoreBuilder}, gitignore::{Gitignore, GitignoreBuilder},
incremental::{IncrementalIgnore, IncrementalIgnoreOptions},
overrides::Override, overrides::Override,
types::Types, types::Types,
}; };
@ -104,24 +106,15 @@ impl DirEntry {
} }
fn new_stdin() -> DirEntry { fn new_stdin() -> DirEntry {
DirEntry { DirEntry { dent: DirEntryInner::Stdin, err: None }
dent: DirEntryInner::Stdin,
err: None,
}
} }
fn new_walkdir(dent: walkdir::DirEntry, err: Option<Error>) -> DirEntry { fn new_walkdir(dent: walkdir::DirEntry, err: Option<Error>) -> DirEntry {
DirEntry { DirEntry { dent: DirEntryInner::Walkdir(dent), err }
dent: DirEntryInner::Walkdir(dent),
err,
}
} }
fn new_raw(dent: DirEntryRaw, err: Option<Error>) -> DirEntry { fn new_raw(dent: DirEntryRaw, err: Option<Error>) -> DirEntry {
DirEntry { DirEntry { dent: DirEntryInner::Raw(dent), err }
dent: DirEntryInner::Raw(dent),
err,
}
} }
} }
@ -187,9 +180,11 @@ impl DirEntryInner {
)); ));
Err(err.with_path("<stdin>")) Err(err.with_path("<stdin>"))
} }
Walkdir(ref x) => x Walkdir(ref x) => x.metadata().map_err(|err| {
.metadata() Error::Io(io::Error::from(err))
.map_err(|err| Error::Io(io::Error::from(err)).with_path(x.path())), .with_depth(x.depth())
.with_path(x.path())
}),
Raw(ref x) => x.metadata(), Raw(ref x) => x.metadata(),
} }
} }
@ -308,7 +303,9 @@ impl DirEntryRaw {
} else { } else {
fs::symlink_metadata(&self.path) 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 { fn file_type(&self) -> FileType {
@ -316,9 +313,7 @@ impl DirEntryRaw {
} }
fn file_name(&self) -> &OsStr { fn file_name(&self) -> &OsStr {
self.path self.path.file_name().unwrap_or_else(|| self.path.as_os_str())
.file_name()
.unwrap_or_else(|| self.path.as_os_str())
} }
fn depth(&self) -> usize { fn depth(&self) -> usize {
@ -330,13 +325,13 @@ impl DirEntryRaw {
self.ino 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 ty = ent.file_type().map_err(|err| {
let err = Error::Io(io::Error::from(err)).with_path(ent.path()); let err = Error::Io(err).with_depth(depth).with_path(ent.path());
Error::WithDepth { Error::WithDepth { depth, err: Box::new(err) }
depth,
err: Box::new(err),
}
})?; })?;
DirEntryRaw::from_entry_os(depth, ent, ty) DirEntryRaw::from_entry_os(depth, ent, ty)
} }
@ -348,11 +343,8 @@ impl DirEntryRaw {
ty: fs::FileType, ty: fs::FileType,
) -> Result<DirEntryRaw, Error> { ) -> Result<DirEntryRaw, Error> {
let md = ent.metadata().map_err(|err| { let md = ent.metadata().map_err(|err| {
let err = Error::Io(io::Error::from(err)).with_path(ent.path()); let err = Error::Io(err).with_depth(depth).with_path(ent.path());
Error::WithDepth { Error::WithDepth { depth, err: Box::new(err) }
depth,
err: Box::new(err),
}
})?; })?;
Ok(DirEntryRaw { Ok(DirEntryRaw {
path: ent.path(), path: ent.path(),
@ -395,8 +387,13 @@ impl DirEntryRaw {
} }
#[cfg(windows)] #[cfg(windows)]
fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result<DirEntryRaw, Error> { fn from_path(
let md = fs::metadata(&pb).map_err(|err| Error::Io(err).with_path(&pb))?; 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 { Ok(DirEntryRaw {
path: pb, path: pb,
ty: md.file_type(), ty: md.file_type(),
@ -407,10 +404,15 @@ impl DirEntryRaw {
} }
#[cfg(unix)] #[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; 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 { Ok(DirEntryRaw {
path: pb, path: pb,
ty: md.file_type(), ty: md.file_type(),
@ -423,7 +425,11 @@ impl DirEntryRaw {
// Placeholder implementation to allow compiling on non-standard platforms // Placeholder implementation to allow compiling on non-standard platforms
// (e.g. wasm32). // (e.g. wasm32).
#[cfg(not(any(windows, unix)))] #[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( Err(Error::Io(io::Error::new(
io::ErrorKind::Other, io::ErrorKind::Other,
"unsupported platform", "unsupported platform",
@ -502,7 +508,8 @@ pub struct WalkBuilder {
/// ///
/// When `None`, the CWD is fetched from `std::env::current_dir()`. If /// When `None`, the CWD is fetched from `std::env::current_dir()`. If
/// that fails, then global gitignores are ignored (an error is logged). /// 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)] #[derive(Clone)]
@ -544,8 +551,16 @@ impl WalkBuilder {
/// is better to call `add` on this builder than to create multiple /// is better to call `add` on this builder than to create multiple
/// `Walk` values. /// `Walk` values.
pub fn new<P: AsRef<Path>>(path: P) -> WalkBuilder { 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 { WalkBuilder {
paths: vec![path.as_ref().to_path_buf()], paths: vec![],
ig_builder: IgnoreBuilder::new(), ig_builder: IgnoreBuilder::new(),
max_depth: None, max_depth: None,
min_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. /// Build a new `Walk` iterator.
pub fn build(&self) -> Walk { pub fn build(&self) -> Walk {
let follow_links = self.follow_links; let follow_links = self.follow_links;
@ -585,10 +615,14 @@ impl WalkBuilder {
if let Some(ref sorter) = sorter { if let Some(ref sorter) = sorter {
match sorter.clone() { match sorter.clone() {
Sorter::ByName(cmp) => { 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) => { 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<_>>() .collect::<Vec<_>>()
.into_iter(); .into_iter();
let ig_root = self let ig_root = self.build_ignore();
.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 { Walk {
its, its,
it: None, it: None,
ig_root: ig_root.clone(), ig_root: ig_root.clone(),
ig: ig_root.clone(), ig: ig_root.clone(),
max_depth: self.max_depth,
max_filesize: self.max_filesize, max_filesize: self.max_filesize,
skip: self.skip.clone(), skip: self.skip.clone(),
filter: self.filter.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. /// Build a new `WalkParallel` iterator.
/// ///
/// Note that this *doesn't* return something that implements `Iterator`. /// Note that this *doesn't* return something that implements `Iterator`.
/// Instead, the returned value must be run with a closure. e.g., /// Instead, the returned value must be run with a closure. e.g.,
/// `builder.build_parallel().run(|| |path| { println!("{path:?}"); WalkState::Continue })`. /// `builder.build_parallel().run(|| |path| { println!("{path:?}"); WalkState::Continue })`.
pub fn build_parallel(&self) -> WalkParallel { pub fn build_parallel(&self) -> WalkParallel {
let ig_root = self let ig_root = self.build_ignore();
.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());
WalkParallel { WalkParallel {
paths: self.paths.clone().into_iter(), paths: self.paths.clone().into_iter(),
ig_root, ig_root,
@ -651,7 +730,10 @@ impl WalkBuilder {
/// The default, `None`, imposes no depth restriction. /// The default, `None`, imposes no depth restriction.
pub fn max_depth(&mut self, depth: Option<usize>) -> &mut WalkBuilder { pub fn max_depth(&mut self, depth: Option<usize>) -> &mut WalkBuilder {
self.max_depth = depth; 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.max_depth = self.min_depth;
} }
self self
@ -662,7 +744,10 @@ impl WalkBuilder {
/// The default, `None`, imposes no minimum depth restriction. /// The default, `None`, imposes no minimum depth restriction.
pub fn min_depth(&mut self, depth: Option<usize>) -> &mut WalkBuilder { pub fn min_depth(&mut self, depth: Option<usize>) -> &mut WalkBuilder {
self.min_depth = depth; 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.min_depth = self.max_depth;
} }
self self
@ -705,7 +790,12 @@ impl WalkBuilder {
/// An error will also occur if this walker could not get the current /// An error will also occur if this walker could not get the current
/// working directory (and `WalkBuilder::current_dir` isn't set). /// working directory (and `WalkBuilder::current_dir` isn't set).
pub fn add_ignore<P: AsRef<Path>>(&mut self, path: P) -> Option<Error> { 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 path = path.as_ref();
// let Some(cwd) = self.get_or_set_current_dir() else { // let Some(cwd) = self.get_or_set_current_dir() else {
// let err = std::io::Error::other(format!( // let err = std::io::Error::other(format!(
@ -729,7 +819,11 @@ impl WalkBuilder {
errs.into_error_option() 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) { pub fn add_gitignore(&mut self, gi: Gitignore) {
self.ig_builder.add_ignore(gi); self.ig_builder.add_ignore(gi);
} }
@ -982,7 +1076,10 @@ impl WalkBuilder {
/// ///
/// Global gitignore files come from things like a user's git configuration /// Global gitignore files come from things like a user's git configuration
/// or from gitignore files added via [`WalkBuilder::add_ignore`]. /// 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(); let cwd = cwd.into();
self.ig_builder.current_dir(cwd.clone()); self.ig_builder.current_dir(cwd.clone());
if let Err(cwd) = self.global_gitignores_relative_to.set(Ok(cwd)) { 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); let result = std::env::current_dir().map_err(Arc::new);
match result { match result {
Ok(ref path) => { Ok(ref path) => {
log::trace!("automatically discovered CWD: {}", path.display()); log::trace!(
"automatically discovered CWD: {}",
path.display()
);
} }
Err(ref err) => { Err(ref err) => {
log::debug!( log::debug!(
@ -1016,6 +1116,13 @@ impl WalkBuilder {
}); });
result.as_ref().ok().map(|path| &**path) 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 /// Walk is a recursive directory iterator over file paths in one or more
@ -1029,6 +1136,7 @@ pub struct Walk {
it: Option<WalkEventIter>, it: Option<WalkEventIter>,
ig_root: Ignore, ig_root: Ignore,
ig: Ignore, ig: Ignore,
max_depth: Option<usize>,
max_filesize: Option<u64>, max_filesize: Option<u64>,
skip: Option<Arc<Handle>>, skip: Option<Arc<Handle>>,
filter: Option<Filter>, filter: Option<Filter>,
@ -1044,6 +1152,17 @@ impl Walk {
WalkBuilder::new(path).build() 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> { fn skip_entry(&self, ent: &DirEntry) -> Result<bool, Error> {
if ent.depth() == 0 { if ent.depth() == 0 {
return Ok(false); return Ok(false);
@ -1128,12 +1247,17 @@ impl Iterator for Walk {
self.it.as_mut().unwrap().it.skip_current_dir(); self.it.as_mut().unwrap().it.skip_current_dir();
// Still need to push this on the stack because // Still need to push this on the stack because
// we'll get a WalkEvent::Exit event for this dir. // we'll get a WalkEvent::Exit event for this dir.
// We don't care if it errors though. // Its ignore files cannot apply to any visited entry.
let (igtmp, _) = self.ig.add_child(ent.path()); let (igtmp, _) =
self.ig.add_child_with_entries(ent.path(), &[]);
self.ig = igtmp; self.ig = igtmp;
continue; 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; self.ig = igtmp;
ent.err = err; ent.err = err;
return Some(Ok(ent)); return Some(Ok(ent));
@ -1175,11 +1299,7 @@ enum WalkEvent {
impl From<WalkDir> for WalkEventIter { impl From<WalkDir> for WalkEventIter {
fn from(it: WalkDir) -> WalkEventIter { fn from(it: WalkDir) -> WalkEventIter {
WalkEventIter { WalkEventIter { depth: 0, it: it.into_iter(), next: None }
depth: 0,
it: it.into_iter(),
next: None,
}
} }
} }
@ -1252,7 +1372,9 @@ pub trait ParallelVisitorBuilder<'s> {
fn build(&mut self) -> Box<dyn ParallelVisitor + '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> { fn build(&mut self) -> Box<dyn ParallelVisitor + 's> {
(**self).build() (**self).build()
} }
@ -1273,14 +1395,17 @@ struct FnBuilder<F> {
builder: 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> { fn build(&mut self) -> Box<dyn ParallelVisitor + 's> {
let visitor = (self.builder)(); let visitor = (self.builder)();
Box::new(FnVisitorImp { visitor }) 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> { struct FnVisitorImp<'s> {
visitor: FnVisitor<'s>, visitor: FnVisitor<'s>,
@ -1370,7 +1495,9 @@ impl WalkParallel {
} }
}; };
match DirEntryRaw::from_path(0, path, false) { 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) => { Err(err) => {
if visitor.visit(Err(err)).is_quit() { if visitor.visit(Err(err)).is_quit() {
return; return;
@ -1394,8 +1521,11 @@ impl WalkParallel {
let quit_now = Arc::new(AtomicBool::new(false)); let quit_now = Arc::new(AtomicBool::new(false));
let active_workers = Arc::new(AtomicUsize::new(threads)); let active_workers = Arc::new(AtomicUsize::new(threads));
let stacks = Stack::new_for_each_thread(threads, stack); let stacks = Stack::new_for_each_thread(threads, stack);
std::thread::scope(|s| { // Collect all of the workers first. In the case that
let handles: Vec<_> = stacks // `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() .into_iter()
.map(|stack| Worker { .map(|stack| Worker {
visitor: builder.build(), visitor: builder.build(),
@ -1409,6 +1539,10 @@ impl WalkParallel {
skip: self.skip.clone(), skip: self.skip.clone(),
filter: self.filter.clone(), filter: self.filter.clone(),
}) })
.collect();
std::thread::scope(|s| {
let handles: Vec<_> = workers
.into_iter()
.map(|worker| s.spawn(|| worker.run())) .map(|worker| s.spawn(|| worker.run()))
.collect(); .collect();
for handle in handles { for handle in handles {
@ -1419,9 +1553,7 @@ impl WalkParallel {
fn threads(&self) -> usize { fn threads(&self) -> usize {
if self.threads == 0 { if self.threads == 0 {
std::thread::available_parallelism() std::thread::available_parallelism().map_or(1, |n| n.get()).min(12)
.map_or(1, |n| n.get())
.min(12)
} else { } else {
self.threads self.threads
} }
@ -1452,6 +1584,12 @@ struct Work {
root_device: Option<u64>, root_device: Option<u64>,
} }
#[derive(Default)]
struct ReadDirResult {
entries: Vec<fs::DirEntry>,
errors: Vec<Error>,
}
impl Work { impl Work {
/// Returns true if and only if this work item is a directory. /// Returns true if and only if this work item is a directory.
fn is_dir(&self) -> bool { fn is_dir(&self) -> bool {
@ -1478,6 +1616,13 @@ impl Work {
err 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 /// Reads the directory contents of this work item and adds ignore
/// rules for this directory. /// rules for this directory.
/// ///
@ -1485,7 +1630,7 @@ impl Work {
/// an error is returned. If there was a problem reading the ignore /// an error is returned. If there was a problem reading the ignore
/// rules for this directory, then the error is attached to this /// rules for this directory, then the error is attached to this
/// work item's directory entry. /// 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()) { let readdir = match fs::read_dir(self.dent.path()) {
Ok(readdir) => readdir, Ok(readdir) => readdir,
Err(err) => { Err(err) => {
@ -1495,10 +1640,24 @@ impl Work {
return Err(err); 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.ignore = ig;
self.dent.err = err; 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 // breadth-first. We do depth-first because a breadth first traversal
// on wide directories with a lot of gitignores is disastrous (for // on wide directories with a lot of gitignores is disastrous (for
// example, searching a directory tree containing all of crates.io). // example, searching a directory tree containing all of crates.io).
let deques: Vec<Deque<Message>> = std::iter::repeat_with(Deque::new_lifo) let deques: Vec<Deque<Message>> =
.take(threads) std::iter::repeat_with(Deque::new_lifo).take(threads).collect();
.collect(); let stealers = Arc::<[Stealer<Message>]>::from(
let stealers = deques.iter().map(Deque::stealer).collect::<Vec<_>>(),
Arc::<[Stealer<Message>]>::from(deques.iter().map(Deque::stealer).collect::<Vec<_>>()); );
let stacks: Vec<Stack> = deques let stacks: Vec<Stack> = deques
.into_iter() .into_iter()
.enumerate() .enumerate()
@ -1668,8 +1827,13 @@ impl<'s> Worker<'s> {
// have sufficient read permissions to list the directory. // have sufficient read permissions to list the directory.
// In that case we still want to provide the closure with a valid // In that case we still want to provide the closure with a valid
// entry before passing the error value. // entry before passing the error value.
let readdir = work.read_dir();
let depth = work.dent.depth(); 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 { if should_visit {
let state = self.visitor.visit(Ok(work.dent)); let state = self.visitor.visit(Ok(work.dent));
if !state.is_continue() { if !state.is_continue() {
@ -1680,6 +1844,10 @@ impl<'s> Worker<'s> {
return WalkState::Skip; return WalkState::Skip;
} }
let readdir = match readdir {
Some(readdir) => readdir,
None => return WalkState::Skip,
};
let readdir = match readdir { let readdir = match readdir {
Ok(readdir) => readdir, Ok(readdir) => readdir,
Err(err) => { Err(err) => {
@ -1687,11 +1855,19 @@ impl<'s> Worker<'s> {
} }
}; };
if self.max_depth.map_or(false, |max| depth >= max) { for result in readdir.entries {
return WalkState::Skip; 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() { if state.is_quit() {
return state; return state;
} }
@ -1717,14 +1893,8 @@ impl<'s> Worker<'s> {
ig: &Ignore, ig: &Ignore,
depth: usize, depth: usize,
root_device: Option<u64>, root_device: Option<u64>,
result: Result<fs::DirEntry, io::Error>, fs_dent: fs::DirEntry,
) -> WalkState { ) -> 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) { let mut dent = match DirEntryRaw::from_entry(depth, &fs_dent) {
Ok(dent) => DirEntry::new_raw(dent, None), Ok(dent) => DirEntry::new_raw(dent, None),
Err(err) => { Err(err) => {
@ -1760,7 +1930,8 @@ impl<'s> Worker<'s> {
return WalkState::Continue; return WalkState::Continue;
} }
} }
let should_skip_filesize = if self.max_filesize.is_some() && !dent.is_dir() { let should_skip_filesize =
if self.max_filesize.is_some() && !dent.is_dir() {
skip_filesize( skip_filesize(
self.max_filesize.unwrap(), self.max_filesize.unwrap(),
dent.path(), dent.path(),
@ -1769,17 +1940,14 @@ impl<'s> Worker<'s> {
} else { } else {
false false
}; };
let should_skip_filtered = if let Some(Filter(predicate)) = &self.filter { let should_skip_filtered =
if let Some(Filter(predicate)) = &self.filter {
!predicate(&dent) !predicate(&dent)
} else { } else {
false false
}; };
if !should_skip_filesize && !should_skip_filtered { if !should_skip_filesize && !should_skip_filtered {
self.send(Work { self.send(Work { dent, ignore: ig.clone(), root_device });
dent,
ignore: ig.clone(),
root_device,
});
} }
WalkState::Continue WalkState::Continue
} }
@ -1820,6 +1988,9 @@ impl<'s> Worker<'s> {
} }
// Wait for next `Work` or `Quit` message. // Wait for next `Work` or `Quit` message.
loop { loop {
if self.is_quit_now() {
return None;
}
if let Some(v) = self.recv() { if let Some(v) = self.recv() {
self.activate_worker(); self.activate_worker();
value = Some(v); 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( fn check_symlink_loop(
ig_parent: &Ignore, ig_parent: &Ignore,
child_path: &Path, child_path: &Path,
child_depth: usize, child_depth: usize,
) -> Result<(), Error> { ) -> Result<(), Error> {
let hchild = Handle::from_path(child_path).map_err(|err| { let hchild = Handle::from_path(child_path).map_err(|err| {
Error::from(err) Error::from(err).with_path(child_path).with_depth(child_depth)
.with_path(child_path)
.with_depth(child_depth)
})?; })?;
for ig in ig_parent for ig in ig_parent.parents().take_while(|ig| !ig.is_absolute_parent()) {
.parents()
.take_while(|ig| !ig.is_absolute_parent())
{
let h = Handle::from_path(ig.path()).map_err(|err| { let h = Handle::from_path(ig.path()).map_err(|err| {
Error::from(err) Error::from(err).with_path(child_path).with_depth(child_depth)
.with_path(child_path)
.with_depth(child_depth)
})?; })?;
if hchild == h { if hchild == h {
return Err(Error::Loop { return Err(Error::Loop {
@ -1905,7 +2077,11 @@ fn check_symlink_loop(
// Before calling this function, make sure that you ensure that is really // Before calling this function, make sure that you ensure that is really
// necessary as the arguments imply a file stat. // 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 { let filesize = match *ent {
Some(ref md) => Some(md.len()), Some(ref md) => Some(md.len()),
None => None, None => None,
@ -1977,9 +2153,9 @@ fn path_equals(dent: &DirEntry, handle: &Handle) -> Result<bool, Error> {
if dent.is_stdin() || never_equal(dent, handle) { if dent.is_stdin() || never_equal(dent, handle) {
return Ok(false); return Ok(false);
} }
Handle::from_path(dent.path()) Handle::from_path(dent.path()).map(|h| &h == handle).map_err(|err| {
.map(|h| &h == handle) Error::Io(err).with_depth(dent.depth()).with_path(dent.path())
.map_err(|err| Error::Io(err).with_path(dent.path())) })
} }
/// Returns true if the given walkdir entry corresponds to a directory. /// 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 { if !dent.file_type().is_symlink() || dent.depth() > 0 {
return false; return false;
} }
dent.path() dent.path().metadata().ok().map_or(false, |md| md.file_type().is_dir())
.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 /// Returns true if and only if the given path is on the same device as the
/// given root device. /// given root device.
fn is_same_file_system(root_device: u64, path: &Path) -> Result<bool, Error> { 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) Ok(root_device == dent_device)
} }
@ -2065,11 +2239,7 @@ mod tests {
} }
fn normal_path(unix: &str) -> String { fn normal_path(unix: &str) -> String {
if cfg!(windows) { if cfg!(windows) { unix.replace("\\", "/") } else { unix.to_string() }
unix.replace("\\", "/")
} else {
unix.to_string()
}
} }
fn walk_collect(prefix: &Path, builder: &WalkBuilder) -> Vec<String> { fn walk_collect(prefix: &Path, builder: &WalkBuilder) -> Vec<String> {
@ -2089,7 +2259,10 @@ mod tests {
paths 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![]; let mut paths = vec![];
for dent in walk_collect_entries_parallel(builder) { for dent in walk_collect_entries_parallel(builder) {
let path = dent.path().strip_prefix(prefix).unwrap(); 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] #[test]
fn min_depth() { fn min_depth() {
let td = tmpdir(); let td = tmpdir();
@ -2388,7 +2582,9 @@ mod tests {
assert_eq!(1, dents.len()); assert_eq!(1, dents.len());
assert!(!dents[0].path_is_symlink()); 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_eq!(1, dents.len());
assert!(!dents[0].path_is_symlink()); assert!(!dents[0].path_is_symlink());
} }
@ -2474,8 +2670,88 @@ mod tests {
assert_paths( assert_paths(
td.path(), 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"], &["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}; 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 { fn get_gitignore() -> Gitignore {
let mut builder = GitignoreBuilder::new("ROOT"); let mut builder = GitignoreBuilder::new("ROOT");
@ -23,7 +24,9 @@ fn test_path_should_be_under_root() {
#[test] #[test]
fn test_files_in_root() { fn test_files_in_root() {
let gitignore = get_gitignore(); 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 // 0x
assert!(m("ROOT/file_root_00").is_ignore()); assert!(m("ROOT/file_root_00").is_ignore());
@ -53,7 +56,9 @@ fn test_files_in_root() {
#[test] #[test]
fn test_files_in_deep() { fn test_files_in_deep() {
let gitignore = get_gitignore(); 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 // 0x
assert!(m("ROOT/parent_dir/file_deep_00").is_ignore()); assert!(m("ROOT/parent_dir/file_deep_00").is_ignore());
@ -83,8 +88,9 @@ fn test_files_in_deep() {
#[test] #[test]
fn test_dirs_in_root() { fn test_dirs_in_root() {
let gitignore = get_gitignore(); let gitignore = get_gitignore();
let m = let m = |path: &str, is_dir: bool| {
|path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir); gitignore.matched_path_or_any_parents(Path::new(path), is_dir)
};
// 00 // 00
assert!(m("ROOT/dir_root_00", true).is_ignore()); assert!(m("ROOT/dir_root_00", true).is_ignore());
@ -186,20 +192,25 @@ fn test_dirs_in_root() {
#[test] #[test]
fn test_dirs_in_deep() { fn test_dirs_in_deep() {
let gitignore = get_gitignore(); let gitignore = get_gitignore();
let m = let m = |path: &str, is_dir: bool| {
|path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir); gitignore.matched_path_or_any_parents(Path::new(path), is_dir)
};
// 00 // 00
assert!(m("ROOT/parent_dir/dir_deep_00", true).is_ignore()); 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/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", 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 // 01
assert!(m("ROOT/parent_dir/dir_deep_01", true).is_ignore()); 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/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", 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 // 02
assert!(m("ROOT/parent_dir/dir_deep_02", true).is_none()); 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", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_20/file", false).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", 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 // 21
assert!(m("ROOT/parent_dir/dir_deep_21", true).is_ignore()); 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/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", 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 // 22
// dir itself doesn't match // dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_22", true).is_none()); 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/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", 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 // 23
// dir itself doesn't match // dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_23", true).is_none()); 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/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", 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 // 30
assert!(m("ROOT/parent_dir/dir_deep_30", true).is_ignore()); 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/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", 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 // 31
assert!(m("ROOT/parent_dir/dir_deep_31", true).is_ignore()); 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/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", 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 // 32
// dir itself doesn't match // dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_32", true).is_none()); 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/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", 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 // 33
// dir itself doesn't match // dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_33", true).is_none()); 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/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", 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 browser.js
tailwindcss-oxide.wasi-browser.js tailwindcss-oxide.wasi-browser.js
tailwindcss-oxide.wasi.cjs tailwindcss-oxide.wasi.cjs
tailwindcss-oxide.wasi.d.cts
wasi-worker-browser.mjs wasi-worker-browser.mjs
wasi-worker.mjs wasi-worker.mjs

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +0,0 @@
node-linker=hoisted

View file

@ -1,3 +1,3 @@
# `@tailwindcss/oxide-wasm32-wasi` # `@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", "name": "@tailwindcss/oxide-wasm32-wasi",
"version": "4.2.2", "version": "4.3.3",
"cpu": [
"wasm32"
],
"main": "tailwindcss-oxide.wasi.cjs", "main": "tailwindcss-oxide.wasi.cjs",
"files": [ "files": [
"tailwindcss-oxide.wasm32-wasi.wasm", "tailwindcss-oxide.wasm32-wasi.wasm",
@ -14,7 +11,7 @@
], ],
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
}, },
"publishConfig": { "publishConfig": {
"provenance": true, "provenance": true,
@ -27,11 +24,11 @@
}, },
"browser": "tailwindcss-oxide.wasi-browser.js", "browser": "tailwindcss-oxide.wasi-browser.js",
"dependencies": { "dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1", "@napi-rs/wasm-runtime": "^1.2.2",
"@emnapi/core": "^1.8.1", "@emnapi/core": "^1.11.3",
"@emnapi/runtime": "^1.8.1", "@emnapi/runtime": "^1.11.3",
"@tybys/wasm-util": "^0.10.1", "@tybys/wasm-util": "^0.10.3",
"@emnapi/wasi-threads": "^1.1.0", "@emnapi/wasi-threads": "^1.2.3",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"bundledDependencies": [ "bundledDependencies": [

View file

@ -1,75 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@emnapi/core':
specifier: ^1.5.0
version: 1.5.0
'@emnapi/runtime':
specifier: ^1.5.0
version: 1.5.0
'@emnapi/wasi-threads':
specifier: ^1.1.0
version: 1.1.0
'@napi-rs/wasm-runtime':
specifier: ^1.0.5
version: 1.0.5
'@tybys/wasm-util':
specifier: ^0.10.1
version: 0.10.1
tslib:
specifier: ^2.4.0
version: 2.8.1
packages:
'@emnapi/core@1.5.0':
resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==}
'@emnapi/runtime@1.5.0':
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
'@napi-rs/wasm-runtime@1.0.5':
resolution: {integrity: sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
snapshots:
'@emnapi/core@1.5.0':
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
'@emnapi/runtime@1.5.0':
dependencies:
tslib: 2.8.1
'@emnapi/wasi-threads@1.1.0':
dependencies:
tslib: 2.8.1
'@napi-rs/wasm-runtime@1.0.5':
dependencies:
'@emnapi/core': 1.5.0
'@emnapi/runtime': 1.5.0
'@tybys/wasm-util': 0.10.1
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
tslib@2.8.1: {}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -908,6 +908,18 @@ mod tests {
); );
} }
// https://github.com/tailwindlabs/tailwindcss/issues/20233
#[test]
fn test_template_toolkit_syntax() {
assert_extract_candidates_contains(
&pre_process_input(
r#"<div class="[% IF $is_open %]bg-white/40[% ELSE %]bg-white/10[% END %]"></div>"#,
"tx",
),
vec!["bg-white/40", "bg-white/10"],
);
}
// https://github.com/tailwindlabs/tailwindcss/issues/17050 // https://github.com/tailwindlabs/tailwindcss/issues/17050
#[test] #[test]
fn test_haml_syntax() { fn test_haml_syntax() {

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 // Replace following characters with spaces if they are not inside of brackets
b'#' | b'=' if bracket_stack.is_empty() => { b'#' | b'=' if bracket_stack.is_empty() => {
result[cursor.pos] = b' '; 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 // https://github.com/tailwindlabs/tailwindcss/pull/17051#issuecomment-2711181352
#[test] #[test]
fn test_haml_full_file_17051() { fn test_haml_full_file_17051() {

View file

@ -10,6 +10,8 @@ pub mod ruby;
pub mod rust; pub mod rust;
pub mod slim; pub mod slim;
pub mod svelte; pub mod svelte;
pub mod template_toolkit;
pub mod twig;
pub mod vue; pub mod vue;
pub use clojure::*; pub use clojure::*;
@ -24,4 +26,6 @@ pub use ruby::*;
pub use rust::*; pub use rust::*;
pub use slim::*; pub use slim::*;
pub use svelte::*; pub use svelte::*;
pub use template_toolkit::*;
pub use twig::*;
pub use vue::*; pub use vue::*;

View file

@ -34,8 +34,9 @@ impl PreProcessor for Ruby {
// Extract embedded template languages // Extract embedded template languages
// https://viewcomponent.org/guide/templates.html#interpolations // https://viewcomponent.org/guide/templates.html#interpolations
let content_as_str = std::str::from_utf8(content).unwrap(); // Only process if content is valid UTF-8, otherwise skip HEREDOC extraction
// but still perform the byte-level Ruby processing below
if let Ok(content_as_str) = std::str::from_utf8(content) {
let starts = TEMPLATE_START_REGEX let starts = TEMPLATE_START_REGEX
.captures_iter(content_as_str) .captures_iter(content_as_str)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -75,6 +76,7 @@ impl PreProcessor for Ruby {
break; break;
} }
} }
}
// Ruby extraction // Ruby extraction
while cursor.pos < len { while cursor.pos < len {
@ -156,6 +158,15 @@ impl PreProcessor for Ruby {
continue; 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(); cursor.advance_twice();
// Boundary character // Boundary character
@ -163,8 +174,13 @@ impl PreProcessor for Ruby {
b'[' => b']', b'[' => b']',
b'(' => b')', b'(' => b')',
b'{' => b'}', b'{' => b'}',
b'#' => b'#', b'<' => b'>',
b' ' => b'\n', 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(); cursor.advance();
continue; continue;
@ -181,8 +197,8 @@ impl PreProcessor for Ruby {
while cursor.pos < len { while cursor.pos < len {
match cursor.curr() { match cursor.curr() {
// Skip escaped characters // Skip escaped characters, unless the backslash is the delimiter itself
b'\\' => { b'\\' if boundary != b'\\' => {
// Use backslash to embed spaces in the strings. // Use backslash to embed spaces in the strings.
if cursor.next() == b' ' { if cursor.next() == b' ' {
result[cursor.pos] = b' '; result[cursor.pos] = b' ';
@ -196,6 +212,11 @@ impl PreProcessor for Ruby {
bracket_stack.push(cursor.curr()); 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 // End of a nested bracket
b']' | b')' | b'}' if !bracket_stack.is_empty() => { b']' | b')' | b'}' if !bracket_stack.is_empty() => {
if !bracket_stack.pop(cursor.curr()) { if !bracket_stack.pop(cursor.curr()) {
@ -204,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 // End of the pattern, replace the boundary character with a space
_ if cursor.curr() == boundary => { _ if cursor.curr() == boundary => {
if boundary != b'\n' { if boundary != b'\n' {
@ -251,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 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 …\n
("%w flex px-2.5\n", "%w flex px-2.5\n"), ("%w flex px-2.5\n", "%w flex px-2.5\n"),
@ -328,6 +375,20 @@ mod tests {
"%w(flex data-[state=pending]:bg-(--my-color) flex-col)", "%w(flex data-[state=pending]:bg-(--my-color) flex-col)",
vec!["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]", "# test\n# test\n# {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!]\n%w[flex px-2.5]",
@ -444,4 +505,24 @@ mod tests {
vec!["text-amber-600", "text-sky-500", "text-green-500"], vec!["text-amber-600", "text-sky-500", "text-green-500"],
); );
} }
#[test]
fn test_invalid_utf8_does_not_panic() {
// Invalid UTF-8 sequence: 0x80 is a continuation byte without a leading byte
let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82];
let processor = Ruby::default();
// Should not panic, just return the input unchanged
let result = processor.process(invalid_utf8);
assert_eq!(result, invalid_utf8);
}
#[test]
fn test_valid_utf8_with_multibyte_chars() {
// Test that valid UTF-8 with multi-byte characters (like em-dashes) works
let input = "# Comment with em—dash\n%w[flex px-2.5]";
Ruby::test_extract_contains(input, vec!["flex", "px-2.5"]);
}
} }

View file

@ -104,6 +104,12 @@ impl Rust {
b'[' => { b'[' => {
bracket_stack.push(cursor.curr()); bracket_stack.push(cursor.curr());
// Handle `p.flex[condition]`. If there is a `-` before it, it will likely be an
// arbitrary value e.g. `text-[red]`
if !matches!(cursor.prev(), b'-') {
result[cursor.pos] = b' ';
}
} }
b']' if !bracket_stack.is_empty() => { b']' if !bracket_stack.is_empty() => {
@ -213,4 +219,20 @@ mod tests {
let input = r#"html! { \x.px-4.text-black { } }"#; let input = r#"html! { \x.px-4.text-black { } }"#;
Rust::test(input, r#"html! { \x px-4 text-black { } }"#); Rust::test(input, r#"html! { \x px-4 text-black { } }"#);
} }
// https://github.com/tailwindlabs/tailwindcss/issues/20233
#[test]
fn test_maud_template_extraction_with_conditional_classes() {
let input = r#"
use maud::{html, Markup};
pub fn main() -> Markup {
html! {
p.text-black[cuteness > 50] { "Squee!" }
}
}
"#;
Rust::test_extract_contains(input, vec!["text-black"]);
}
} }

View file

@ -65,17 +65,74 @@ impl PreProcessor for Slim {
// class=%w[bg-blue-500 w-10 h-10] // 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') 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 `%` result[cursor.pos] = b' '; // Replace `%`
cursor.advance(); cursor.advance();
result[cursor.pos] = b' '; // Replace `w` result[cursor.pos] = b' '; // Replace `w`
cursor.advance(); cursor.advance();
result[cursor.pos] = b' '; // Replace `[` or `(` or `{` result[cursor.pos] = b' '; // Replace the opening delimiter
bracket_stack.push(cursor.curr()); cursor.advance();
cursor.advance(); // Move past the bracket
continue; // 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. // Any `[` preceded by an alphanumeric value will not be part of a candidate.
@ -316,16 +373,77 @@ mod tests {
] ]
"#; "#;
let expected = r#" let expected = "
div div \n class= bg-blue-500 w-10 h-10 \n ]
class= bg-blue-500 w-10 h-10] div \n class= w-10 bg-green-500 h-10 \n ]
] ";
div
class= w-10 bg-green-500 h-10]
]
"#;
Slim::test(input, expected); Slim::test(input, expected);
Slim::test_extract_contains(input, vec!["bg-blue-500", "bg-green-500", "w-10", "h-10"]); 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

@ -0,0 +1,52 @@
use crate::cursor;
use crate::extractor::pre_processors::pre_processor::PreProcessor;
#[derive(Debug, Default)]
pub struct TemplateToolkit;
impl PreProcessor for TemplateToolkit {
fn process(&self, content: &[u8]) -> Vec<u8> {
let len = content.len();
let mut result = content.to_vec();
let mut cursor = cursor::Cursor::new(content);
while cursor.pos < len {
match (cursor.curr(), cursor.next()) {
(b'[', b'%') => result[cursor.pos] = b' ',
(b'%', b']') => result[cursor.pos + 1] = b' ',
_ => {}
}
cursor.advance();
}
result
}
}
#[cfg(test)]
mod tests {
use super::TemplateToolkit;
use crate::extractor::pre_processors::pre_processor::PreProcessor;
#[test]
fn test_template_toolkit_pre_processor() {
for (input, expected) in [
(
"[% IF $is_open %]bg-white/40[% ELSE %]bg-white/10[% END %]",
" % IF $is_open % bg-white/40 % ELSE % bg-white/10 % END % ",
),
("[% WRAPPER %]flex[% END %]", " % WRAPPER % flex % END % "),
] {
TemplateToolkit::test(input, expected);
}
}
#[test]
fn test_extraction_between_template_tags_works() {
TemplateToolkit::test_extract_contains(
r#"<div class="[% IF $is_open %]bg-white/40[% ELSE %]bg-white/10[% END %]"></div>"#,
vec!["bg-white/40", "bg-white/10"],
);
}
}

View file

@ -74,8 +74,8 @@
^^^^^^^^^^ ^^^^^^ ^^^^^^^^^^ ^^^^^^
libraries, our components are designed to be fast, flexible, and easy to libraries, our components are designed to be fast, flexible, and easy to
^^^ ^^^^^^^^^^ ^^^ ^^^^^^^^ ^^ ^^ ^^^ ^^^^ ^^ ^^^ ^^^^^^^^^^ ^^^ ^^^^^^^^ ^^ ^^ ^^^ ^^^^ ^^
use <i>directy</i> in Ruby on Rails! use <i>directly</i> in Ruby on Rails!
^^^ ^^^^^^^ ^^ ^^ ^^^ ^^^^^^^^ ^^ ^^
= doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do = doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do
^^^ ^^^^ ^^^^^^^ ^^^^^^^^^^^^ ^^^^ ^^ ^^^ ^^^^ ^^^^^^^ ^^^^^^^^^^^^ ^^^^ ^^
@ -101,7 +101,7 @@
%p.mt-2 %p.mt-2
^^^^ ^^^^
Utlize Utilize
= daisy_link("HAML", "https://haml.info/", target: "_blank") = daisy_link("HAML", "https://haml.info/", target: "_blank")
^^^^^^ ^^^^^^
so your views are simple, concise, and easy to understand. so your views are simple, concise, and easy to understand.
@ -119,8 +119,8 @@
%p.mt-2 %p.mt-2
^^^^ ^^^^
:markdown :markdown
**PLUS!** You can utlize filters like _Markdown_, CoffeeScript, **PLUS!** You can utilize filters like _Markdown_, CoffeeScript,
^^^ ^^^^^^ ^^^^^^^ ^^^^ ^^^ ^^^^^^^ ^^^^^^^ ^^^^
Textile, and many more! Textile, and many more!
^^^ ^^^^ ^^^^^ ^^^ ^^^^ ^^^^^

View file

@ -42,7 +42,7 @@
= daisy_link "DaisyUI", "https://daisyui.com/", target: "_blank" = daisy_link "DaisyUI", "https://daisyui.com/", target: "_blank"
= daisy_link "TailwindCSS", "https://tailwindcss.com/", target: "_blank" = daisy_link "TailwindCSS", "https://tailwindcss.com/", target: "_blank"
libraries, our components are designed to be fast, flexible, and easy to libraries, our components are designed to be fast, flexible, and easy to
use <i>directy</i> in Ruby on Rails! use <i>directly</i> in Ruby on Rails!
= doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do = doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do
.flex.flex-col.sm:flex-row.items-center.gap-4 .flex.flex-col.sm:flex-row.items-center.gap-4
@ -58,7 +58,7 @@
Simple, Concise Views Simple, Concise Views
%p.mt-2 %p.mt-2
Utlize Utilize
= daisy_link("HAML", "https://haml.info/", target: "_blank") = daisy_link("HAML", "https://haml.info/", target: "_blank")
so your views are simple, concise, and easy to understand. so your views are simple, concise, and easy to understand.
@ -69,7 +69,7 @@
%p.mt-2 %p.mt-2
:markdown :markdown
**PLUS!** You can utlize filters like _Markdown_, CoffeeScript, **PLUS!** You can utilize filters like _Markdown_, CoffeeScript,
Textile, and many more! Textile, and many more!
.mt-8.xl:mt-0 .mt-8.xl:mt-0

View file

@ -0,0 +1,179 @@
use crate::cursor;
use crate::extractor::pre_processors::pre_processor::PreProcessor;
#[derive(Debug, Default)]
pub struct Twig;
impl PreProcessor for Twig {
fn process(&self, content: &[u8]) -> Vec<u8> {
let len = content.len();
let mut result = content.to_vec();
let mut cursor = cursor::Cursor::new(content);
let mut bracket_stack = vec![];
const ADD_CLASS: &[u8] = b"addClass";
const REMOVE_CLASS: &[u8] = b"removeClass";
while cursor.pos < len {
match (!bracket_stack.is_empty(), cursor.curr()) {
// addClass(
// ^
(false, b'(')
if cursor.pos >= ADD_CLASS.len()
&& matches!(
&content[cursor.pos - ADD_CLASS.len()..cursor.pos],
ADD_CLASS
) =>
{
bracket_stack.push(cursor.curr());
result[cursor.pos] = b' ';
}
// removeClass(
// ^
(false, b'(')
if cursor.pos >= REMOVE_CLASS.len()
&& matches!(
&content[cursor.pos - REMOVE_CLASS.len()..cursor.pos],
REMOVE_CLASS
) =>
{
bracket_stack.push(cursor.curr());
result[cursor.pos] = b' ';
}
(true, b'(') => {
bracket_stack.push(cursor.curr());
}
(true, b')') => {
bracket_stack.pop();
if bracket_stack.is_empty() {
result[cursor.pos] = b' ';
}
}
_ => {}
}
cursor.advance();
}
result
}
}
#[cfg(test)]
mod tests {
use super::Twig;
use crate::extractor::pre_processors::pre_processor::PreProcessor;
#[test]
fn test_twig_pre_processor() {
for (input, expected) in [
(
// Ensure we don't crash when we encounter an `(`
"(p-(--value))",
"(p-(--value))",
),
(
// addClass with single argument
"addClass(p-(--value))",
"addClass p-(--value) ",
),
(
// addClass with single argument
"addClass(m-4 p-8 w-full)",
"addClass m-4 p-8 w-full ",
),
(
// removeClass with single argument
"removeClass(p-(--value))",
"removeClass p-(--value) ",
),
(
// removeClass with single argument
"removeClass(m-4 p-8 w-full)",
"removeClass m-4 p-8 w-full ",
),
(
// Combined with single arguments
"addClass(m-(--value))|removeClass(p-(--value))",
"addClass m-(--value) |removeClass p-(--value) ",
),
] {
Twig::test(input, expected);
}
}
// https://github.com/tailwindlabs/tailwindcss/issues/19458
#[test]
fn test_extraction_in_add_class_and_remove_class_works() {
// Inside normal HTML
let input = r#"
<div data-loading="addClass(opacity-50)">
<!-- -->
</div>
"#;
let expected = r#"
<div data-loading="addClass opacity-50 ">
<!-- -->
</div>
"#;
Twig::test(input, expected);
Twig::test_extract_contains(input, vec!["opacity-50"]);
// Inside a component
let input = r#"
<twig:d:Card
{{ ...attributes.defaults({
as: "a",
class: "cursor-pointer bg-base-200 card-sm md:card-md lg:card-lg",
role: "button",
tabindex: "0",
"data-action": "live#$render",
"data-loading": "addAttribute(disabled)",
"data-poll": "delay(60000)|$render",
"data-loading": "addClass(border border-red-500 opacity-75)",
})
}}
>
"#;
let expected = r#"
<twig:d:Card
{{ ...attributes.defaults({
as: "a",
class: "cursor-pointer bg-base-200 card-sm md:card-md lg:card-lg",
role: "button",
tabindex: "0",
"data-action": "live#$render",
"data-loading": "addAttribute(disabled)",
"data-poll": "delay(60000)|$render",
"data-loading": "addClass border border-red-500 opacity-75 ",
})
}}
>
"#;
Twig::test(input, expected);
Twig::test_extract_contains(
input,
vec![
// class
"cursor-pointer",
"bg-base-200",
"card-sm",
"md:card-md",
"lg:card-lg",
// data-loading
"border",
"border-red-500",
"opacity-75",
],
);
}
}

View file

@ -14,7 +14,9 @@ pub struct Vue;
impl PreProcessor for Vue { impl PreProcessor for Vue {
fn process(&self, content: &[u8]) -> Vec<u8> { fn process(&self, content: &[u8]) -> Vec<u8> {
let mut result = content.to_vec(); let mut result = content.to_vec();
let content_as_str = std::str::from_utf8(content).unwrap();
// Only process template tags if content is valid UTF-8
if let Ok(content_as_str) = std::str::from_utf8(content) {
for (_, [lang, body]) in TEMPLATE_REGEX for (_, [lang, body]) in TEMPLATE_REGEX
.captures_iter(content_as_str) .captures_iter(content_as_str)
.map(|c| c.extract()) .map(|c| c.extract())
@ -22,6 +24,7 @@ impl PreProcessor for Vue {
let replaced = pre_process_input(body.as_bytes().to_vec(), lang); let replaced = pre_process_input(body.as_bytes().to_vec(), lang);
result = result.replace(body, replaced); result = result.replace(body, replaced);
} }
}
result result
} }
@ -42,4 +45,16 @@ mod tests {
Vue::test_extract_contains(input, vec!["bg-neutral-900", "text-red-500"]); Vue::test_extract_contains(input, vec!["bg-neutral-900", "text-red-500"]);
} }
#[test]
fn test_invalid_utf8_does_not_panic() {
// Invalid UTF-8 sequence: 0x80 is a continuation byte without a leading byte
let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82];
let processor = Vue::default();
// Should not panic, just return the input unchanged
let result = processor.process(invalid_utf8);
assert_eq!(result, invalid_utf8);
}
} }

View file

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

View file

@ -2,3 +2,5 @@ package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
bun.lockb bun.lockb
.gitignore .gitignore
.env
.env.*

View file

@ -37,20 +37,52 @@ pub fn init_tracing() {
return; return;
} }
let file_path = format!("tailwindcss-{}.log", std::process::id()); let root = Path::new(".tailwindcss");
let file = OpenOptions::new() 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) .create(true)
.append(true) .append(true)
.open(&file_path) .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(|_| file_path.clone());
let absolute_file_path = dunce::canonicalize(file_path)
.unwrap_or_else(|_| panic!("Failed to canonicalize {file_path:?}"));
eprintln!( eprintln!(
"{} Writing debug info to: {}\n", "{} Writing debug info to: {}\n",
dim("[DEBUG]"), 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)); let file = Arc::new(Mutex::new(file));

View file

@ -17,7 +17,6 @@ use fxhash::{FxHashMap, FxHashSet};
use ignore::{gitignore::GitignoreBuilder, WalkBuilder}; use ignore::{gitignore::GitignoreBuilder, WalkBuilder};
use init_tracing::{init_tracing, SHOULD_TRACE}; use init_tracing::{init_tracing, SHOULD_TRACE};
use rayon::prelude::*; use rayon::prelude::*;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
@ -79,11 +78,12 @@ pub struct Scanner {
/// Track unique set of candidates /// Track unique set of candidates
candidates: FxHashSet<String>, candidates: FxHashSet<String>,
/// Track mtimes for files so re-scans can skip unchanged files. /// Track mtimes for files so incremental scans can skip unchanged files.
/// Only populated after the first scan completes (to avoid unnecessary
/// metadata calls on initial build).
mtimes: FxHashMap<PathBuf, SystemTime>, 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 /// Whether we've completed at least one full scan. When false, we skip
/// mtime tracking entirely so the initial build stays fast. /// mtime tracking entirely so the initial build stays fast.
has_scanned_once: bool, has_scanned_once: bool,
@ -123,9 +123,10 @@ impl Scanner {
pub fn scan(&mut self) -> Vec<String> { pub fn scan(&mut self) -> Vec<String> {
self.sources_scanned = false; 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.extract_candidates(scanned_blobs, css_files);
self.scanned_files = files;
// Return all candidates sorted // Return all candidates sorted
let mut result = self.candidates.iter().cloned().collect::<Vec<_>>(); let mut result = self.candidates.iter().cloned().collect::<Vec<_>>();
@ -180,6 +181,11 @@ impl Scanner {
continue; 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![]; let mut drop_file_indexes = vec![];
for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() { for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() {
let ChangedContent::File(file, _) = changed_file else { let ChangedContent::File(file, _) = changed_file else {
@ -188,7 +194,15 @@ impl Scanner {
// When the file is found on disk it means that all the rules pass. We can // 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. // 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 self.files.insert(path.to_path_buf()); // Track for future use
content_to_scan.push(changed_file.clone()); // Track for parsing content_to_scan.push(changed_file.clone()); // Track for parsing
drop_file_indexes.push(idx); drop_file_indexes.push(idx);
@ -257,6 +271,11 @@ impl Scanner {
.collect() .collect()
} }
#[tracing::instrument(skip_all)]
pub fn get_scanned_files(&self) -> Vec<String> {
self.scanned_files.clone()
}
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn get_globs(&mut self) -> Vec<GlobEntry> { pub fn get_globs(&mut self) -> Vec<GlobEntry> {
if let Some(globs) = &self.globs { if let Some(globs) = &self.globs {
@ -337,15 +356,14 @@ impl Scanner {
let i = s.as_ptr() as usize - offset; let i = s.as_ptr() as usize - offset;
let original = &original_content[i..i + s.len()]; let original = &original_content[i..i + s.len()];
if original.contains_str("-[]") { if original.contains_str("-[]") {
return Some(unsafe { return String::from_utf8(original.to_vec())
(String::from_utf8_unchecked(original.to_vec()), i) .ok()
}); .map(|candidate| (candidate, i));
} }
// SAFETY: When we parsed the candidates, we already guaranteed that the byte String::from_utf8(s.to_vec())
// slices are valid, therefore we don't have to re-check here when we want to .ok()
// convert it back to a string. .map(|candidate| (candidate, i))
Some(unsafe { (String::from_utf8_unchecked(s.to_vec()), i) })
} }
_ => None, _ => None,
@ -354,14 +372,14 @@ impl Scanner {
} }
#[tracing::instrument(skip_all)] #[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 { if self.sources_scanned {
return (vec![], vec![]); return (vec![], vec![], vec![]);
} }
self.sources_scanned = true; self.sources_scanned = true;
let Some(walker) = &mut self.walker else { 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 // Use synchronous walk for the initial build (lower overhead) and parallel
@ -373,27 +391,74 @@ impl Scanner {
}; };
let mut css_files: Vec<PathBuf> = vec![]; 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 seen_files: FxHashSet<PathBuf> = FxHashSet::default(); let mut changed_files = vec![];
for (path, is_dir, extension) in all_entries { // Fresh state
if is_dir { self.files.clear();
self.dirs.clear();
self.extensions.clear();
self.globs = None;
// 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.dirs.insert(path);
} else { }
WalkEntry::File {
path,
mtime,
is_symlink,
} => {
// Deduplicate: parallel walk can visit the same file from multiple threads // Deduplicate: parallel walk can visit the same file from multiple threads
if !seen_files.insert(path.clone()) { if !self.files.insert(path.clone()) {
continue; continue;
} }
// On re-scans, check mtime to skip unchanged files. // Track canonicalized paths in addition to potentially symlinked file paths
// On the first scan we skip this entirely to avoid extra let canonical = if is_symlink {
// metadata syscalls. dunce::canonicalize(&path).ok()
let changed = if self.has_scanned_once { } else {
let current_mtime = std::fs::metadata(&path) path.parent().and_then(|parent| {
.ok() // Perf: cache the canonicalized parent path such that sibling files don't
.and_then(|m| m.modified().ok()); // 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 current_mtime { 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) => { Some(mtime) => {
let prev = self.mtimes.insert(path.clone(), mtime); let prev = self.mtimes.insert(path.clone(), mtime);
prev.is_none_or(|prev| prev != mtime) prev.is_none_or(|prev| prev != mtime)
@ -401,28 +466,33 @@ impl Scanner {
None => true, None => true,
} }
} else { } else {
if let Some(mtime) = mtime {
self.mtimes.insert(path.clone(), mtime);
}
true true
}; };
if !changed {
continue;
}
if let Ok(file) = path.clone().into_os_string().into_string() {
changed_files.push(file);
}
match extension.as_str() { match extension.as_str() {
// Special handing for CSS files, we don't want to extract candidates from // Special handing for CSS files, we don't want to extract candidates from
// these files, but we do want to extract used CSS variables. // these files, but we do want to extract used CSS variables.
"css" => { "css" => css_files.push(path),
if changed { _ => content_paths.push((path, extension)),
css_files.push(path.clone());
} }
} }
_ => {
if changed {
content_paths.push((path.clone(), extension.clone()));
}
} }
} }
self.extensions.insert(extension); // Ensure `mtimes` don't include stale files
self.files.insert(path); self.mtimes.retain(|path, _| self.files.contains(path));
}
}
// Read + preprocess all discovered files in parallel // Read + preprocess all discovered files in parallel
let scanned_blobs: Vec<Vec<u8>> = content_paths let scanned_blobs: Vec<Vec<u8>> = content_paths
@ -443,7 +513,9 @@ impl Scanner {
self.has_scanned_once = true; self.has_scanned_once = true;
} }
(scanned_blobs, css_files) changed_files.par_sort_unstable();
(scanned_blobs, css_files, changed_files)
} }
} }
@ -474,13 +546,15 @@ pub fn pre_process_input(content: Vec<u8>, extension: &str) -> Vec<u8> {
"heex" | "eex" | "ex" | "exs" => Elixir.process(&content), "heex" | "eex" | "ex" | "exs" => Elixir.process(&content),
"cshtml" | "razor" => Razor.process(&content), "cshtml" | "razor" => Razor.process(&content),
"haml" => Haml.process(&content), "haml" => Haml.process(&content),
"json" => Json.process(&content), "json" | "jsonl" | "ndjson" => Json.process(&content),
"md" | "mdx" => Markdown.process(&content), "md" | "mdx" => Markdown.process(&content),
"pug" => Pug.process(&content), "pug" => Pug.process(&content),
"rb" | "erb" => Ruby.process(&content), "rb" | "erb" => Ruby.process(&content),
"slim" | "slang" => Slim.process(&content), "slim" | "slang" => Slim.process(&content),
"svelte" => Svelte.process(&content), "svelte" => Svelte.process(&content),
"rs" => Rust.process(&content), "rs" => Rust.process(&content),
"tt" | "tt2" | "tx" => TemplateToolkit.process(&content),
"twig" => Twig.process(&content),
"vue" => Vue.process(&content), "vue" => Vue.process(&content),
_ => content, _ => content,
} }
@ -542,35 +616,50 @@ where
a a
}) })
.into_iter() .into_iter()
.map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) }) .filter_map(|s| String::from_utf8(s.to_vec()).ok())
.collect() .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 /// Walk the file system synchronously. Used for the initial build where the overhead of spawning
/// parallel walker threads is not worth it. /// parallel walker threads is not worth it.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn walk_synchronous(walker: &mut WalkBuilder) -> Vec<WalkEntry> { fn walk_synchronous(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
let mut entries = Vec::new(); walker
.build()
for entry in walker.build().filter_map(Result::ok) { .filter_map(Result::ok)
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); .map(WalkEntry::from)
let path = entry.into_path(); .collect()
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
} }
/// Walk the file system in parallel. Used in watch mode where the parallel walker overhead is /// Walk the file system in parallel. Used in watch mode where the parallel walker overhead is
@ -590,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(|| { walker.build_parallel().run(|| {
let mut buf = FlushOnDrop { let mut buf = FlushOnDrop {
@ -603,19 +692,7 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
return ignore::WalkState::Continue; return ignore::WalkState::Continue;
}; };
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); buf.local.push(WalkEntry::from(entry));
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));
}
if buf.local.len() >= 256 { if buf.local.len() >= 256 {
buf.shared.lock().unwrap().append(&mut buf.local); buf.shared.lock().unwrap().append(&mut buf.local);
@ -636,7 +713,16 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
fn create_walker(sources: &Sources) -> Option<WalkBuilder> { fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default(); let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default();
let mut first_root: Option<&PathBuf> = None; let mut first_root: Option<&PathBuf> = None;
let mut ignores: BTreeMap<&PathBuf, BTreeSet<String>> = Default::default();
let mut ignores: Vec<(&PathBuf, Vec<String>)> = Default::default();
let mut emit = |base, pattern| match ignores.last_mut() {
Some((prev_base, patterns)) if *prev_base == base => {
patterns.push(pattern);
}
_ => {
ignores.push((base, vec![pattern]));
}
};
for source in sources.iter() { for source in sources.iter() {
match source { match source {
@ -648,7 +734,7 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
} }
} }
SourceEntry::Pattern { base, pattern } => { SourceEntry::Pattern { base, pattern } => {
let mut pattern = pattern.to_string(); let pattern = pattern.to_owned();
if first_root.is_none() { if first_root.is_none() {
first_root = Some(base); first_root = Some(base);
@ -657,35 +743,19 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
} }
if !pattern.contains("**") { if !pattern.contains("**") {
// Ensure that the pattern is pinned to the base path.
if !pattern.starts_with("/") {
pattern = format!("/{pattern}");
}
// Specific patterns should take precedence even over git-ignored files: // Specific patterns should take precedence even over git-ignored files:
ignores emit(base, format!("!{}", pattern));
.entry(base)
.or_default()
.insert(format!("!{}", pattern));
} else { } else {
// Assumption: the pattern we receive will already be brace expanded. So // Assumption: the pattern we receive will already be brace expanded. So
// `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`. // `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`.
if let Some(extension) = Path::new(&pattern).extension() { if let Some(extension) = Path::new(&pattern).extension() {
// Extend auto source detection to include the extension // Extend auto source detection to include the extension
ignores emit(base, format!("!*.{}", extension.to_string_lossy()));
.entry(base)
.or_default()
.insert(format!("!*.{}", extension.to_string_lossy()));
} }
} }
} }
SourceEntry::Ignored { base, pattern } => { SourceEntry::Ignored { base, pattern } => {
let mut pattern = pattern.to_string(); emit(base, pattern.to_owned());
// Ensure that the pattern is pinned to the base path.
if !pattern.starts_with("/") {
pattern = format!("/{pattern}");
}
ignores.entry(base).or_default().insert(pattern);
} }
SourceEntry::External { base } => { SourceEntry::External { base } => {
if first_root.is_none() { if first_root.is_none() {
@ -695,16 +765,10 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
} }
// External sources should take precedence even over git-ignored files: // External sources should take precedence even over git-ignored files:
ignores emit(base, "!/**/*".to_owned());
.entry(base)
.or_default()
.insert(format!("!{}", "/**/*"));
// External sources should still disallow binary extensions: // External sources should still disallow binary extensions:
ignores emit(base, BINARY_EXTENSIONS_GLOB.clone());
.entry(base)
.or_default()
.insert(BINARY_EXTENSIONS_GLOB.clone());
} }
} }
} }
@ -793,14 +857,7 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
let pattern_sources: Vec<(PathBuf, String)> = sources let pattern_sources: Vec<(PathBuf, String)> = sources
.iter() .iter()
.filter_map(|source| match source { .filter_map(|source| match source {
SourceEntry::Pattern { base, pattern } => { SourceEntry::Pattern { base, pattern } => Some((base.into(), pattern.into())),
let normalized = if pattern.starts_with("/") {
pattern.to_string()
} else {
format!("/{pattern}")
};
Some((base.clone(), normalized))
}
_ => None, _ => None,
}) })
.collect(); .collect();

View file

@ -1,7 +1,8 @@
use crate::glob::split_pattern;
use crate::GlobEntry; use crate::GlobEntry;
use bexpand::Expression; use bexpand::Expression;
use std::path::PathBuf; use fxhash::{FxHashMap, FxHashSet};
use ignore::gitignore::Gitignore;
use std::path::{Component, Path, PathBuf};
use tracing::{event, Level}; use tracing::{event, Level};
use super::auto_source_detection::IGNORED_CONTENT_DIRS; use super::auto_source_detection::IGNORED_CONTENT_DIRS;
@ -49,13 +50,14 @@ pub enum SourceEntry {
/// ``` /// ```
Ignored { base: PathBuf, pattern: String }, Ignored { base: PathBuf, pattern: String },
/// External sources are sources outside of your git root which should not /// External sources are directories that are ignored (by us or .gitignore rules), but should be
/// follow gitignore rules. /// included bypassing the default ignore rules.
/// ///
/// Represented by: /// Represented by:
/// ///
/// ```css /// ```css
/// @source "../node_modules/my-lib";` /// @source "../node_modules/my-lib";`
/// @source "../node_modules/my-lib/**/*";`
/// ``` /// ```
External { base: PathBuf }, External { base: PathBuf },
} }
@ -75,6 +77,164 @@ impl Sources {
} }
} }
/// When dealing with a pattern, then it could be that we end up with:
///
/// ```json
/// { base: '/some/folder', pattern: 'foo.ts' }
/// ```
///
/// If we just emit `!foo.ts` for the `/some/folder` path, then _everything_ else in that folder
/// would still be walked (but the result will be ignored).
///
/// Instead, we have to ensure that we ignore everything in that folder _except_ for the `foo.ts`
/// pattern.
///
/// This should be equivalent to:
/// ```gitignore
/// *
/// !foo.ts
/// ```
///
/// However, we have to be careful that we don't start ignoring files/folders that already exist.
/// ```css
/// @source "./some/folder/foo.ts";
/// @source "./some/folder/bar.ts";
/// ```
/// Would result in:
/// ```json
/// { base: '/some/folder', pattern: 'foo.ts' }
/// { base: '/some/folder', pattern: 'bar.ts' }
/// ```
///
/// If we were to blindly emit `*` for each pattern, then the `.gitignore` equivalent would look like
/// this:
/// ```gitignore
/// *
/// !foo.ts
/// *
/// !bar.ts
/// ```
///
/// This would result in ignoring the `foo.ts` file as well. Therefore we only want to insert
/// this `*` pattern when nothing else exists yet.
///
/// There is another problem that we need to solve. Let's say you have a pattern that contains a `*`
/// in the pattern:
/// ```css
/// @source './src/ba*/*.html';
/// ```
///
/// This would result in
/// ```json
/// { base: '/src', pattern: '/ba*/*.html' }
/// ```
///
/// If we now inject the `*` pattern for the `/src` folder, then we wouldn't scan any `ba*` folders
/// (e.g. `bar` or `baz`). For this, we have to make sure that we add inverse patterns for these
/// folders. This would essentially result in:
/// ```gitignore
/// * ← ignore everything
/// !/ba*/ ← except for the `ba*/` pattern, so we scan these folders
/// !/ba*/*.html ← then ensure we scan the `*.html` files in it as well
/// ```
///
fn expand_restricted_patterns(sources: Vec<SourceEntry>) -> Vec<SourceEntry> {
let unrestricted_roots = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Auto { base } | SourceEntry::External { base } => Some(base.clone()),
SourceEntry::Pattern { base, pattern } if pattern.contains("**") => Some(base.clone()),
_ => None,
})
.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![];
for source in sources {
let SourceEntry::Pattern { base, pattern } = &source else {
expanded.push(source);
continue;
};
// `base` is already included by another `@source` that we know should be walked. This
// includes the case where `base` is _nested_ inside such a root, because everything under
// an unrestricted root is walked already. Restricting it would incorrectly hide siblings
// that the broader source is supposed to pick up.
if unrestricted_roots.iter().any(|root| base.starts_with(root)) {
expanded.push(source);
continue;
}
// Ignore everything in the directory. We will later add the specific patterns we are
// interested in.
if restricted_roots.insert(base.clone()) {
// 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(),
pattern: pattern.to_owned(),
});
}
// Ensure to _include_ parent paths, otherwise the `*` from above would block walking the
// folders that need to be walked.
//
// ```css
// @source './src/ba*/*.html';
// ```
//
// ```gitignore
// * ← added by the above rule
// !/ba*/ ← this is what we're focusing on in this block
// !/ba*/*.html ← this is added later
// ```
{
let mut dir = PathBuf::new();
let mut components = Path::new(pattern).components().peekable();
while let Some(component) = components.next() {
if components.peek().is_none() {
break;
}
match component {
Component::Prefix(_) | Component::RootDir | Component::CurDir => continue,
Component::ParentDir | Component::Normal(_) => dir.push(component),
}
expanded.push(SourceEntry::Ignored {
base: base.clone(),
pattern: format!("!/{}/", path_to_posix_string(&dir).trim_start_matches('/')),
});
}
}
// Track the original source
expanded.push(source);
}
expanded
}
impl PublicSourceEntry { impl PublicSourceEntry {
/// Optimize the PublicSourceEntry by trying to move all the static parts of the pattern to the /// Optimize the PublicSourceEntry by trying to move all the static parts of the pattern to the
/// base of the PublicSourceEntry. /// base of the PublicSourceEntry.
@ -102,72 +262,456 @@ impl PublicSourceEntry {
/// resolved path. /// resolved path.
pub fn optimize(&mut self) { pub fn optimize(&mut self) {
// Resolve base path immediately // Resolve base path immediately
let Ok(base) = dunce::canonicalize(&self.base) else { let Ok(mut base) = dunce::canonicalize(&self.base) else {
event!(Level::ERROR, "Failed to resolve base: {:?}", self.base); event!(Level::ERROR, "Failed to resolve base: {:?}", self.base);
return; return;
}; };
self.base = base.to_string_lossy().to_string();
// No dynamic part, figure out if we are dealing with a file or a directory. let mut new_pattern = PathBuf::new();
if !self.pattern.contains('*') { enum ComponentStage {
let combined_path = if self.pattern.starts_with("/") { Base,
PathBuf::from(&self.pattern) Pattern,
}
let mut stage = ComponentStage::Base;
let mut components = Path::new(&self.pattern).components().peekable();
while let Some(component) = components.next() {
match stage {
ComponentStage::Base => {
match component {
// Ignore the current dir, e.g. `.`
Component::CurDir => {}
// Go up a directory, e.g. `..`
Component::ParentDir => {
base.pop();
}
// Once we hit a component that contains a wildcard character, then we
// can't change the base anymore and we must move to the pattern part.
Component::Normal(part) if part.to_string_lossy().contains("*") => {
new_pattern.push(component);
stage = ComponentStage::Pattern;
}
// File or folder, but not the last component
Component::Normal(part) if components.peek().is_some() => {
base.push(part);
}
// Last file or folder. If it's a folder, we move it to the base,
// otherwise we move it to the pattern.
Component::Normal(part) => {
let full_path = base.join(part);
if full_path.is_dir() {
base.push(part);
} else { } else {
PathBuf::from(&self.base).join(&self.pattern) new_pattern.push(part);
}
}
// When we're dealing with an absolute path, then we have to bypass the
// `base` entirely.
Component::Prefix(_) => {
base.clear();
base.push(component);
}
Component::RootDir => {
#[cfg(not(windows))]
base.clear();
base.push(component);
}
}
}
ComponentStage::Pattern => {
new_pattern.push(component);
}
}
}
self.base = base.to_string_lossy().to_string();
self.pattern = path_to_posix_string(&new_pattern);
// Ensure we have `**/*` when the base is a folder and we don't have a pattern at all
if self.pattern == "" {
self.pattern = "/**/*".to_owned();
}
// Ensure that the pattern is pinned to the base path.
else if !self.pattern.starts_with("/") {
self.pattern = format!("/{}", self.pattern);
}
}
}
fn path_to_posix_string(path: &Path) -> String {
let mut parts = Vec::new();
let mut is_rooted = false;
for component in path.components() {
match component {
Component::Prefix(prefix) => {
parts.push(prefix.as_os_str().to_string_lossy().to_string());
}
Component::RootDir => {
is_rooted = true;
if parts.is_empty() {
parts.push(String::new());
}
}
Component::CurDir => {
parts.push(".".to_string());
}
Component::ParentDir => {
parts.push("..".to_string());
}
Component::Normal(part) => {
parts.push(part.to_string_lossy().to_string());
}
}
}
let result = parts.join("/");
if result.is_empty() && is_rooted {
"/".to_string()
} else {
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::tempdir;
#[test]
fn path_to_posix_string_serializes_relative_paths() {
let path = PathBuf::from("src").join("**").join("*.html");
assert_eq!(path_to_posix_string(&path), "src/**/*.html");
}
#[test]
fn path_to_posix_string_serializes_rooted_paths() {
let path = PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
.join("src")
.join("**")
.join("*.html");
assert_eq!(path_to_posix_string(&path), "/src/**/*.html");
}
#[test]
fn path_to_posix_string_serializes_empty_paths() {
assert_eq!(path_to_posix_string(&PathBuf::new()), "");
}
#[test]
fn optimize_hoists_static_directories_and_keeps_files_in_the_pattern() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src").join("examples")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/examples/index.html".to_string(),
negated: false,
}; };
match dunce::canonicalize(combined_path) { source.optimize();
Ok(resolved_path) if resolved_path.is_dir() => {
self.base = resolved_path.to_string_lossy().to_string(); assert_eq!(
self.pattern = "**/*".to_owned(); source.base,
} dunce::canonicalize(dir.path().join("src").join("examples"))
Ok(resolved_path) if resolved_path.is_file() => {
self.base = resolved_path
.parent()
.unwrap() .unwrap()
.to_string_lossy() .to_string_lossy()
.to_string(); );
// Ensure leading slash, otherwise it will match against all files in all folders/ assert_eq!(source.pattern, "/index.html");
self.pattern =
format!("/{}", resolved_path.file_name().unwrap().to_string_lossy());
}
_ => {}
}
return;
} }
// Contains dynamic part #[test]
let (static_part, dynamic_part) = split_pattern(&self.pattern); fn optimize_hoists_folder_patterns() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src").join("examples")).unwrap();
let base: PathBuf = self.base.clone().into(); let mut source = PublicSourceEntry {
let base = match static_part { base: dir.path().to_string_lossy().to_string(),
Some(static_part) => { pattern: "src/examples".to_string(),
// TODO: If the base does not exist on disk, try removing the last slash and try negated: false,
// again.
match dunce::canonicalize(base.join(static_part)) {
Ok(base) => base,
Err(err) => {
event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err);
return;
}
}
}
None => base,
}; };
let pattern = match dynamic_part { source.optimize();
Some(dynamic_part) => dynamic_part,
None => { assert_eq!(
if base.is_dir() { source.base,
"**/*".to_owned() dunce::canonicalize(dir.path().join("src").join("examples"))
} else { .unwrap()
"".to_owned() .to_string_lossy()
} );
assert_eq!(source.pattern, "/**/*");
} }
#[test]
fn optimize_keeps_wildcards_in_the_pattern() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/**/*.html".to_string(),
negated: false,
}; };
self.base = base.to_string_lossy().to_string(); source.optimize();
self.pattern = pattern;
assert_eq!(
source.base,
dunce::canonicalize(dir.path().join("src"))
.unwrap()
.to_string_lossy()
);
assert_eq!(source.pattern, "/**/*.html");
}
#[test]
fn concrete_patterns_are_expanded_to_restrict_their_base() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/foo.html".to_string(),
negated: false,
}]);
assert_eq!(
sources,
vec![
SourceEntry::Ignored {
base: base.clone(),
pattern: "*".to_string(),
},
SourceEntry::Pattern {
base,
pattern: "/foo.html".to_string(),
},
]
);
}
#[test]
fn restricted_patterns_include_parent_directory_allow_rules() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/ef*/*.html".to_string(),
negated: false,
}]);
assert_eq!(
sources,
vec![
SourceEntry::Ignored {
base: base.clone(),
pattern: "*".to_string(),
},
SourceEntry::Ignored {
base: base.clone(),
pattern: "!/ef*/".to_string(),
},
SourceEntry::Pattern {
base,
pattern: "/ef*/*.html".to_string(),
},
]
);
}
#[test]
fn unrestricted_sources_do_not_expand_patterns_for_the_same_base() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![
PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src".to_string(),
negated: false,
},
PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/foo.html".to_string(),
negated: false,
},
]);
assert_eq!(
sources,
vec![
SourceEntry::Auto { base: base.clone() },
SourceEntry::Pattern {
base,
pattern: "/foo.html".to_string(),
},
]
);
}
#[test]
fn restricted_parent_bases_do_not_open_unrelated_siblings() {
let dir = tempdir().unwrap();
let project = dir.path().join("Users").join("robin").join("docus-test");
fs::create_dir_all(&project).unwrap();
let users = dunce::canonicalize(dir.path().join("Users")).unwrap();
let project = dunce::canonicalize(project).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![
PublicSourceEntry {
base: project.to_string_lossy().to_string(),
pattern: "**/*".to_string(),
negated: false,
},
PublicSourceEntry {
base: project.to_string_lossy().to_string(),
pattern: "../../app.config.ts".to_string(),
negated: false,
},
]);
assert_eq!(
sources,
vec![
SourceEntry::Auto {
base: project.clone(),
},
SourceEntry::Ignored {
base: users.clone(),
pattern: "/*".to_string(),
},
SourceEntry::Pattern {
base: users,
pattern: "/app.config.ts".to_string(),
},
]
);
}
#[test]
fn restricted_patterns_preserve_source_order() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![
PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/foo.html".to_string(),
negated: false,
},
PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/foo.html".to_string(),
negated: true,
},
]);
assert_eq!(
sources,
vec![
SourceEntry::Ignored {
base: base.clone(),
pattern: "*".to_string(),
},
SourceEntry::Pattern {
base: base.clone(),
pattern: "/foo.html".to_string(),
},
SourceEntry::Ignored {
base,
pattern: "/foo.html".to_string(),
},
]
);
}
/// Run the public-to-private conversion for an auto-detected source pointing at `base` and
/// return the resulting entry.
fn auto_source_entry(base: &Path) -> SourceEntry {
public_source_entries_to_private_source_entries(vec![PublicSourceEntry {
base: base.to_string_lossy().to_string(),
pattern: "**/*".to_string(),
negated: false,
}])
.into_iter()
.next()
.unwrap()
}
#[test]
fn auto_detected_folders_become_auto_sources() {
let dir = tempdir().unwrap();
let base = dir.path().join("src");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base });
}
#[test]
fn folders_ignored_by_default_become_external_sources() {
let dir = tempdir().unwrap();
let base = dir.path().join("node_modules").join("my-lib");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
fn folders_ignored_by_gitignore_become_external_sources() {
let dir = tempdir().unwrap();
// Pretend this is a git repository so the `.gitignore` search is bounded to it.
fs::create_dir_all(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap();
let base = dir.path().join("dist");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
fn folders_ignored_by_a_parent_gitignore_become_external_sources() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
// A `.gitignore` higher up in the tree should still apply to nested directories.
fs::write(dir.path().join(".gitignore"), "generated/\n").unwrap();
let base = dir.path().join("packages").join("app").join("generated");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
fn folders_not_ignored_by_gitignore_stay_auto_sources() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap();
let base = dir.path().join("src");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base });
} }
} }
@ -218,18 +762,111 @@ pub fn public_source_entries_to_private_source_entries(
}) })
.collect::<Vec<_>>(); .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.
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).
let cwd = std::env::current_dir()
.map(|cwd| dunce::canonicalize(&cwd).unwrap_or(cwd))
.ok();
// Convert from public SourceEntry to private SourceEntry // Convert from public SourceEntry to private SourceEntry
expanded_globs let sources = expanded_globs
.into_iter() .into_iter()
.map(Into::into) .map(|public_source| {
.collect::<Vec<_>>() let mut source: SourceEntry = public_source.into();
// Promote auto-sources to external sources if they were gitignored
if let SourceEntry::Auto { ref base } = source {
let inside_git_repo = base.ancestors().any(|dir| dir.join(".git").exists());
// Walk up from the folder, applying each `.gitignore` relative to the directory
// that contains it (matching git), and stop at the git repository root so
// `.gitignore` files outside of the repo are not considered.
for dir in base.ancestors() {
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.
path.is_file().then(|| Gitignore::new(&path).0)
});
// 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;
}
}
}
// Stop at the git repository root.
if dir.join(".git").exists() {
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.
if !inside_git_repo && cwd.as_ref().is_some_and(|cwd| cwd.starts_with(dir)) {
break;
}
}
}
source
})
.collect::<Vec<SourceEntry>>();
expand_restricted_patterns(sources)
} }
/// Convert a public source entry to a source entry /// Convert a public source entry to a source entry
impl From<PublicSourceEntry> for SourceEntry { impl From<PublicSourceEntry> for SourceEntry {
fn from(value: PublicSourceEntry) -> Self { fn from(value: PublicSourceEntry) -> Self {
let auto = value.pattern.ends_with("**/*") if value.negated {
|| PathBuf::from(&value.base).join(&value.pattern).is_dir(); return SourceEntry::Ignored {
base: value.base.into(),
pattern: value.pattern,
};
}
let auto =
value.pattern == "/**/*" || PathBuf::from(&value.base).join(&value.pattern).is_dir();
if !auto {
return SourceEntry::Pattern {
base: value.base.into(),
pattern: value.pattern,
};
}
let inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| { let inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| {
value.base.contains(&format!( value.base.contains(&format!(
@ -239,24 +876,16 @@ impl From<PublicSourceEntry> for SourceEntry {
std::path::MAIN_SEPARATOR std::path::MAIN_SEPARATOR
)) || value )) || value
.base .base
.ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir,)) .ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir))
}); });
match (value.negated, auto, inside_ignored_content_dir) { match inside_ignored_content_dir {
(false, true, false) => SourceEntry::Auto { false => SourceEntry::Auto {
base: value.base.into(), base: value.base.into(),
}, },
(false, true, true) => SourceEntry::External { true => SourceEntry::External {
base: value.base.into(), base: value.base.into(),
}, },
(false, false, _) => SourceEntry::Pattern {
base: value.base.into(),
pattern: value.pattern,
},
(true, _, _) => SourceEntry::Ignored {
base: value.base.into(),
pattern: value.pattern,
},
} }
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -126,6 +126,72 @@ test(
}, },
) )
// https://github.com/tailwindlabs/tailwindcss/issues/19706
test(
'Config files (TS, valid types)',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
`,
'tsconfig.json': json`
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"composite": true,
"outDir": "./.build",
"skipLibCheck": true
},
"include": ["tailwind.config.ts"]
}
`,
'index.html': html`
<div class="text-primary"></div>
`,
'tailwind.config.ts': ts`
import type { Config } from 'tailwindcss'
function defineConfig(config: Config): Config {
return config
}
export default defineConfig({
theme: {
extend: {
colors: {
primary: 'blue',
},
},
},
})
`,
'src/index.css': css`
@import 'tailwindcss';
@config '../tailwind.config.ts';
`,
},
},
async ({ fs, exec }) => {
// We expect that these commands don't crash:
await exec('pnpm tsc -b')
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [candidate`text-primary`])
},
)
test( test(
'Config files (CJS, watch mode)', 'Config files (CJS, watch mode)',
{ {

View file

@ -375,6 +375,120 @@ 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",
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.html': html`
<div class="underline"></div>
`,
'src/index.css': css`
@import 'tailwindcss';
@source "unknown-folder/**/*";
`,
},
},
async ({ fs, spawn }) => {
let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`)
await process.onStderr((m) => m.includes('Done in'))
await fs.expectFileToContain('dist/out.css', [candidate`underline`])
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/17632
test(
'watch mode rebuilds when the input file is in an ignored folder',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'assets/tailwind.config.js': js`
module.exports = {
content: {
relative: true,
files: ['html/*.html'],
},
}
`,
'assets/html/index.html': html`
<div class="underline"></div>
`,
'assets/css/index.css': css`
@import 'tailwindcss' source(none);
@config '../tailwind.config.js';
`,
},
},
async ({ fs, spawn }) => {
let process = await spawn(
`${command} --input assets/css/index.css --output dist/out.css --watch`,
)
await process.onStderr((m) => m.includes('Done in'))
await fs.expectFileToContain('dist/out.css', [candidate`underline`])
await fs.write(
'assets/css/index.css',
css`
@import 'tailwindcss' source(none);
@config '../tailwind.config.js';
@source inline("flex");
`,
)
await fs.expectFileToContain('dist/out.css', [candidate`flex`])
},
)
test( test(
'production build (stdin)', 'production build (stdin)',
{ {
@ -559,6 +673,15 @@ describe.each([
'git ignore files outside of a repo are not considered', 'git ignore files outside of a repo are not considered',
{ {
fs: { fs: {
// Make the test root a pnpm workspace that includes `home/project` so
// the install below resolves the transitive dependency overrides that
// the test harness injects into this `pnpm-workspace.yaml`.
'pnpm-workspace.yaml': yaml`
#
packages:
- home/project
`,
// Ignore everything in the "home" directory // Ignore everything in the "home" directory
'home/.gitignore': '*', 'home/.gitignore': '*',
@ -596,7 +719,7 @@ describe.each([
installDependencies: false, installDependencies: false,
}, },
async ({ fs, root, exec }) => { async ({ fs, root, exec }) => {
await exec(`pnpm install --ignore-workspace`, { await exec(`pnpm install`, {
cwd: path.join(root, 'home/project'), cwd: path.join(root, 'home/project'),
}) })
@ -644,7 +767,7 @@ describe.each([
} }
} }
`, `,
'ssrc/index.html': html` 'src/index.html': html`
<div class="flex"></div> <div class="flex"></div>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -705,7 +828,7 @@ describe.each([
} }
} }
`, `,
'ssrc/index.html': html` 'src/index.html': html`
<div class="flex"></div> <div class="flex"></div>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -771,7 +894,7 @@ describe.each([
} }
} }
`, `,
'ssrc/index.html': html` 'src/index.html': html`
<div class="flex"></div> <div class="flex"></div>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -832,7 +955,7 @@ describe.each([
} }
} }
`, `,
'ssrc/index.html': html` 'src/index.html': html`
<div class="flex"></div> <div class="flex"></div>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -1065,7 +1188,7 @@ describe.each([
} }
} }
`, `,
'ssrc/index.html': html` 'src/index.html': html`
<div class="flex"></div> <div class="flex"></div>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -1294,6 +1417,86 @@ describe.each([
}) })
}, },
) )
test(
'watch mode should trigger a full rebuild when a dependency is removed',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'src/index.html': html`
<div class="flex text-primary"></div>
`,
'src/index.css': css`
@import 'tailwindcss/utilities';
@config '../tailwind.config.js';
`,
'tailwind.config.js': js`
const myColor = require('./my-color')
module.exports = {
theme: {
extend: {
colors: {
primary: myColor,
},
},
},
}
`,
'my-color.js': js`
//
module.exports = 'blue'
`,
},
},
async ({ spawn, fs, expect }) => {
let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`)
await process.onStderr((m) => m.includes('Done in'))
expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
"
--- dist/out.css ---
.flex {
display: flex;
}
.text-primary {
color: blue;
}
"
`)
// Remove the dependency of the tailwind.config.js file
await fs.delete('my-color.js')
// We expect an error
await process.onStderr((m) => m.includes('Error'))
await process.onStderr((m) => m.includes('Done in'))
// Re-create the file to resolve the issue
await fs.write('my-color.js', js`module.exports = 'red'`)
await process.onStderr((m) => m.includes('Done in'))
// Expect a full rebuild
expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
"
--- dist/out.css ---
.flex {
display: flex;
}
.text-primary {
color: red;
}
"
`)
},
)
}) })
test( test(
@ -1858,7 +2061,7 @@ test(
`, `,
}, },
}, },
async ({ fs, exec, spawn, root, expect }) => { async ({ fs, exec, root, expect }) => {
await exec('pnpm tailwindcss --input ./index.css --output dist/out.css', { cwd: root }) await exec('pnpm tailwindcss --input ./index.css --output dist/out.css', { cwd: root })
let content = await fs.dumpFiles('./dist/*.css') let content = await fs.dumpFiles('./dist/*.css')
@ -1869,6 +2072,335 @@ test(
}, },
) )
test(
'@source order is important (referencing sibling project)',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'project-a/src/index.css': css`
@import 'tailwindcss/utilities' source(none);
@source '../../project-b';
@source not '../../project-b/ignored';
@source '../../project-b/ignored/except.html';
`,
'project-b/keep/keep.html': html`<div class="content-['GOOD-1']"></div>`,
'project-b/ignored/ignored.html': html`<div class="content-['BAD-1']"></div>`,
'project-b/ignored/except.html': html`<div class="content-['GOOD-2']"></div>`,
},
},
async ({ fs, root, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', {
cwd: path.join(root, 'project-a'),
})
expect(await fs.dumpFiles('./project-a/dist/*.css')).toMatchInlineSnapshot(`
"
--- ./project-a/dist/out.css ---
@layer properties;
.content-\\[\\'GOOD-1\\'\\] {
--tw-content: 'GOOD-1';
content: var(--tw-content);
}
.content-\\[\\'GOOD-2\\'\\] {
--tw-content: 'GOOD-2';
content: var(--tw-content);
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
"
`)
},
)
test(
'@source works with symlinks (referencing sibling project)',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'project-a/src/index.css': css`
@import 'tailwindcss/utilities' source(none);
/* Same as the previous test, but using symlinks instead */
@source '../../project-b';
@source not '../../project-b/ignored';
@source '../../project-b/ignored/except.html';
`,
'project-b/keep/keep.html': html`<div class="content-['GOOD-1']"></div>`,
'project-c/ignored/ignored.html': html`<div class="content-['BAD-1']"></div>`,
'project-c/ignored/except.html': html`<div class="content-['GOOD-2']"></div>`,
// Symlink the ignored folder to another project
'project-b/ignored': 'symlink:../../project-c/ignored/',
},
},
async ({ fs, root, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', {
cwd: path.join(root, 'project-a'),
})
expect(await fs.dumpFiles('./project-a/dist/*.css')).toMatchInlineSnapshot(`
"
--- ./project-a/dist/out.css ---
@layer properties;
.content-\\[\\'GOOD-1\\'\\] {
--tw-content: 'GOOD-1';
content: var(--tw-content);
}
.content-\\[\\'GOOD-2\\'\\] {
--tw-content: 'GOOD-2';
content: var(--tw-content);
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
"
`)
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/19844
test(
'@source scans directories ignored by allow-list .gitignore files',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'.gitignore': txt`
*
!/app
!/app/design
!/app/design/**
`,
'src/index.css': css`
@import 'tailwindcss/utilities' source(none);
@source '../vendor/acme/theme';
`,
// 1. Ignored by the `*` in `.gitignore`
// 2. Included by the `!` pattern in `.gitignore`
// 3. Ignored by `source(none)`
//
// → Should be ignored
'app/design/frontend/theme/templates/component.phtml': html`
<div
class="content-['app/design/frontend/theme/templates/component.phtml']"
></div>
`,
// 1. Ignored by the `*` in `.gitignore`
// 2. Included by the `!` pattern in `.gitignore`
// 3. Ignored by `source(none)`
// 4. Included by the `@source` directive
//
// → Should be included
'vendor/acme/theme/module/templates/component.phtml': html`
<div
class="content-['vendor/acme/theme/module/templates/component.phtml']"
></div>
`,
},
},
async ({ fs, exec }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
// 1. Ignored by the `*` in `.gitignore`
// 2. Included by the `!` pattern in `.gitignore`
// 3. Ignored by `source(none)`
//
// → Should be ignored
await fs.expectFileNotToContain('dist/out.css', [
candidate`content-['app/design/frontend/theme/templates/component.phtml']`,
])
// 1. Ignored by the `*` in `.gitignore`
// 2. Included by the `!` pattern in `.gitignore`
// 3. Ignored by `source(none)`
// 4. Included by the `@source` directive
//
// → Should be included
await fs.expectFileToContain('dist/out.css', [
candidate`content-['vendor/acme/theme/module/templates/component.phtml']`,
])
},
)
// 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)',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'src/index.css': css`
@import 'tailwindcss/utilities' source(none);
/* Same as the previous test, but using symlinks instead */
@source '../lib';
@source not '../lib/ignored';
@source '../lib/ignored/except.html';
`,
'lib/keep/keep.html': html`<div class="content-['GOOD-1']"></div>`,
'vendor/ignored/ignored.html': html`<div class="content-['BAD-1']"></div>`,
'vendor/ignored/except.html': html`<div class="content-['GOOD-2']"></div>`,
// Symlink the ignored folder to another folder
'lib/ignored': 'symlink:../../vendor/ignored/',
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/out.css ---
@layer properties;
.content-\\[\\'GOOD-1\\'\\] {
--tw-content: 'GOOD-1';
content: var(--tw-content);
}
.content-\\[\\'GOOD-2\\'\\] {
--tw-content: 'GOOD-2';
content: var(--tw-content);
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
"
`)
},
)
test( test(
'auto source detection disabled', 'auto source detection disabled',
{ {
@ -2217,6 +2749,49 @@ test(
}, },
) )
test(
'suppress output by using the --silent flag',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.css': css` @import 'tailwindcss/utilities';`,
'index.html': html`<div class="flex"></div>`,
},
},
async ({ fs, exec, expect }) => {
let output = await exec('pnpm tailwindcss --input index.css --output dist/loud.css')
expect(output).toContain('Done in')
let silentOutput = await exec(
'pnpm tailwindcss --input index.css --output dist/silent.css --silent',
)
expect(silentOutput).not.toContain('Done in')
expect(silentOutput).not.toContain('tailwindcss v')
expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/loud.css ---
.flex {
display: flex;
}
--- ./dist/silent.css ---
.flex {
display: flex;
}
"
`)
},
)
test( test(
'changes to CSS files should pick up new CSS variables (if any)', 'changes to CSS files should pick up new CSS variables (if any)',
{ {
@ -2320,8 +2895,8 @@ test(
@layer theme, base, components, utilities; @layer theme, base, components, utilities;
@layer theme { @layer theme {
:root, :host { :root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial,
'Noto Color Emoji'; 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', --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace; monospace;
--color-red-500: oklch(63.7% 0.237 25.331); --color-red-500: oklch(63.7% 0.237 25.331);
@ -2340,7 +2915,7 @@ test(
line-height: 1.5; line-height: 1.5;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
tab-size: 4; 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-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@ -2392,7 +2967,7 @@ test(
border-color: inherit; border-color: inherit;
border-collapse: collapse; border-collapse: collapse;
} }
:-moz-focusring { :-moz-focusring:where(:not(iframe)) {
outline: auto; outline: auto;
} }
progress { progress {

View file

@ -1,4 +1,4 @@
import { candidate, css, html, json, test } from '../utils' import { candidate, css, html, json, test, ts } from '../utils'
test( test(
'builds the `@tailwindcss/typography` plugin utilities', 'builds the `@tailwindcss/typography` plugin utilities',
@ -48,6 +48,80 @@ test(
}, },
) )
test(
'builds the `@tailwindcss/typography` plugin utilities with `@variant` usages',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/typography": "^0.5.14",
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.html': html`
<div className="prose prose-custom">
<h1>Headline</h1>
<p>
Until now, trying to style an article, document, or blog post with Tailwind has been a
tedious task that required a keen eye for typography and a lot of complex custom CSS.
</p>
</div>
`,
'src/index.css': css`
@import 'tailwindcss/utilities';
@theme {
--breakpoint-sm: 640px;
}
@plugin '@tailwindcss/typography';
@config '../tailwind.config.js';
@custom-variant custom (&.custom);
`,
'tailwind.config.js': ts`
module.exports = {
theme: {
typography: ({ theme }) => ({
custom: {
css: {
hr: {
'--x': '1',
'@variant sm:custom': {
'--x': '2',
},
},
},
},
}),
},
}
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
// We don't want to see `@variant` in the output
let contents = await fs.read('dist/out.css')
expect(contents).not.toContain('@variant')
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;
}
}
}
"
`)
},
)
test( test(
'builds the `@tailwindcss/forms` plugin utilities', 'builds the `@tailwindcss/forms` plugin utilities',
{ {
@ -207,3 +281,88 @@ test(
]) ])
}, },
) )
// https://github.com/tailwindlabs/tailwindcss/issues/15844
test(
'builds CSS with a custom plugin compiled from TypeScript',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}
`,
'tsconfig.json': json`
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"composite": true,
"rootDir": "./src",
"outDir": "./.build",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
`,
'index.html': html`
<div class="test-red"></div>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin '../.build/plugin.js';
`,
'src/plugin.ts': ts`
import plugin from 'tailwindcss/plugin'
export const typedPlugin = plugin(() => {
return ({ matchComponents }) => {
matchComponents(
{
test: (content: string) => ({
color: content,
}),
},
{
values: {
red: 'red',
},
},
)
}
})
export default plugin(({ matchComponents }) => {
matchComponents(
{
test: (content: string) => ({
color: content,
}),
},
{
values: {
red: 'red',
},
},
)
})
`,
},
},
async ({ fs, exec }) => {
// We expect that these commands don't crash:
await exec('pnpm tsc -b')
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [candidate`test-red`])
},
)

View file

@ -1,6 +1,7 @@
import nodeFs from 'node:fs/promises'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { candidate, css, html, js, json, test, ts } from '../utils' import { candidate, css, html, IS_WINDOWS, js, json, test, ts } from '../utils'
const STANDALONE_BINARY = (() => { const STANDALONE_BINARY = (() => {
switch (os.platform()) { switch (os.platform()) {
@ -15,6 +16,41 @@ const STANDALONE_BINARY = (() => {
} }
})() })()
test(
'does not scan itself for candidates',
{
fs: {
'package.json': json`
{
"dependencies": {}
}
`,
'src/index.css': css` @import 'tailwindcss'; `,
},
},
async ({ root, fs, exec }) => {
let sourceBinary = path.resolve(
__dirname,
`../../packages/@tailwindcss-standalone/dist/${STANDALONE_BINARY}`,
)
let binary = IS_WINDOWS ? 'tailwindcss.exe' : 'tailwindcss'
let localBinary = path.join(root, binary)
await nodeFs.copyFile(sourceBinary, localBinary)
if (!IS_WINDOWS) {
await nodeFs.chmod(localBinary, 0o755)
}
await exec(`${IS_WINDOWS ? binary : `./${binary}`} --input src/index.css --output dist/out.css`)
await fs.expectFileNotToContain('dist/out.css', [
candidate`flex`,
candidate`grid`,
candidate`underline`,
])
},
)
test( test(
'includes first-party plugins', 'includes first-party plugins',
{ {

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. // 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

@ -3,8 +3,9 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"dedent": "1.7.1", "dedent": "catalog:",
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1",
"yaml": "^2.9.0"
} }
} }

View file

@ -50,7 +50,7 @@ describe.each(Object.keys(variantConfig))('%s', (variant) => {
async ({ exec, expect }) => { async ({ exec, expect }) => {
await expect( await expect(
exec('pnpm postcss src/index.css --output dist/out.css', undefined, { ignoreStdErr: true }), exec('pnpm postcss src/index.css --output dist/out.css', undefined, { ignoreStdErr: true }),
).rejects.toThrowError( ).rejects.toThrow(
`It looks like you're trying to use \`tailwindcss\` directly as a PostCSS plugin. The PostCSS plugin has moved to a separate package, so to continue using Tailwind CSS with PostCSS you'll need to install \`@tailwindcss/postcss\` and update your PostCSS configuration.`, `It looks like you're trying to use \`tailwindcss\` directly as a PostCSS plugin. The PostCSS plugin has moved to a separate package, so to continue using Tailwind CSS with PostCSS you'll need to install \`@tailwindcss/postcss\` and update your PostCSS configuration.`,
) )
}, },

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

@ -1,6 +1,6 @@
import path from 'node:path' import path from 'node:path'
import { isRepoDirty } from '../../packages/@tailwindcss-upgrade/src/utils/git' import { isRepoDirty } from '../../packages/@tailwindcss-upgrade/src/utils/git'
import { candidate, css, html, js, json, test, ts, yaml } from '../utils' import { candidate, css, html, js, json, test, ts, txt, yaml } from '../utils'
test( test(
'error when no CSS file with @tailwind is used', 'error when no CSS file with @tailwind is used',
@ -28,7 +28,7 @@ test(
}, },
}, },
async ({ fs, exec, expect }) => { async ({ fs, exec, expect }) => {
let output = await exec('npx @tailwindcss/upgrade') let output = await exec('pnpm exec upgrade')
expect(output).toContain('Cannot find any CSS files that reference Tailwind CSS.') expect(output).toContain('Cannot find any CSS files that reference Tailwind CSS.')
// Files should not be modified // Files should not be modified
@ -100,7 +100,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -158,7 +158,7 @@ test(
}) })
// Ensure the v4 project compiles correctly // Ensure the v4 project compiles correctly
await exec('npx tailwindcss --input src/input.css --output dist/out.css') await exec('pnpm exec tailwindcss --input src/input.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [ await fs.expectFileToContain('dist/out.css', [
candidate`flex!`, candidate`flex!`,
@ -171,6 +171,57 @@ test(
}, },
) )
test(
'only migrates files matched by `config.content` when upgrading from v3 to v4',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.html'],
}
`,
'src/index.html': html`
<div class="order-[0] bg-[--my-red]"></div>
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'templates/email.php': html`
<div class="order-[0] bg-[--my-red]"></div>
`,
'notes/unrelated.txt': `order-[0] bg-[--my-red]`,
},
retry: 0,
},
async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./**/*.{html,php,txt}')).toMatchInlineSnapshot(`
"
--- notes/unrelated.txt ---
order-[0] bg-[--my-red]
--- src/index.html ---
<div class="order-0 bg-(--my-red)"></div>
--- templates/email.php ---
<div class="order-[0] bg-[--my-red]"></div>
"
`)
},
)
test( test(
`upgrades a v3 project with prefixes to v4`, `upgrades a v3 project with prefixes to v4`,
{ {
@ -212,7 +263,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -284,7 +335,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -356,7 +407,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -433,7 +484,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -536,7 +587,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -644,7 +695,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
await fs.expectFileToContain( await fs.expectFileToContain(
'postcss.config.js', 'postcss.config.js',
@ -715,7 +766,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
let packageJsonContent = await fs.read('package.json') let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent) let packageJson = JSON.parse(packageJsonContent)
@ -762,7 +813,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
let packageJsonContent = await fs.read('package.json') let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent) let packageJson = JSON.parse(packageJsonContent)
@ -814,7 +865,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain( await fs.expectFileToContain(
@ -890,7 +941,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain( await fs.expectFileToContain(
@ -957,7 +1008,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(`
" "
@ -1004,7 +1055,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(`
" "
@ -1048,7 +1099,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1118,9 +1169,10 @@ test(
} }
`, `,
}, },
retry: 0,
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1229,9 +1281,10 @@ test(
} }
`, `,
}, },
retry: 0,
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1364,7 +1417,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
let output = await exec('npx @tailwindcss/upgrade --force') let output = await exec('pnpm exec upgrade --force')
expect(output).toMatch( expect(output).toMatch(
/You have one or more stylesheets that are imported into a utility layer and non-utility layer./, /You have one or more stylesheets that are imported into a utility layer and non-utility layer./,
@ -1488,7 +1541,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
" "
@ -1713,8 +1766,8 @@ test(
}, },
}, },
async ({ exec, expect }) => { async ({ exec, expect }) => {
let output = await exec('npx @tailwindcss/upgrade --force', {}, { ignoreStdErr: true }).catch( let output = await exec('pnpm exec upgrade --force', {}, { ignoreStdErr: true }).catch((e) =>
(e) => e.toString(), e.toString(),
) )
expect(output).toMatch('Could not determine configuration file for:') expect(output).toMatch('Could not determine configuration file for:')
@ -1785,7 +1838,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
" "
@ -1921,7 +1974,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
" "
@ -2041,7 +2094,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
" "
@ -2121,7 +2174,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -2203,9 +2256,10 @@ test(
} }
`, `,
}, },
retry: 0,
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -2259,7 +2313,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
let pkg = JSON.parse(await fs.read('package.json')) let pkg = JSON.parse(await fs.read('package.json'))
@ -2338,7 +2392,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
// Files should not be modified // Files should not be modified
expect(await fs.dumpFiles('./*.{js,css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./*.{js,css,html}')).toMatchInlineSnapshot(`
@ -2436,7 +2490,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force') await exec('pnpm exec upgrade --force')
// Files should not be modified // Files should not be modified
expect(await fs.dumpFiles('./*.{js,css,html,tsx}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./*.{js,css,html,tsx}')).toMatchInlineSnapshot(`
@ -2503,7 +2557,7 @@ test(
@import './utilities.css'; @import './utilities.css';
@import './generated/ignore-me.css'; @import './generated/ignore-me.css';
`, `,
'src/generated/.gitignore': ` 'src/generated/.gitignore': txt`
* *
!.gitignore !.gitignore
`, `,
@ -2543,7 +2597,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade ./src/index.css') await exec('pnpm exec upgrade ./src/index.css')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -2669,7 +2723,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -2682,8 +2736,8 @@ test(
text-red-500/10 text-red-500/10
text-red-500/semitransparent text-red-500/semitransparent
text-red-500/transparent text-red-500/transparent
text-red-500/50 text-red-500/semitransparent
text-red-500/50.5 text-red-500/50.50
text-red-500/50.50 text-red-500/50.50
text-red-500/50% text-red-500/50%
text-red-500/100%" text-red-500/100%"
@ -2755,7 +2809,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -2763,7 +2817,7 @@ test(
<div class="ring-3"></div> <div class="ring-3"></div>
--- ./src/input.css --- --- ./src/input.css ---
@import 'tailwindcss'; @import 'tailwindcss';
/* /*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4, The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
@ -2823,7 +2877,7 @@ test(
}, },
}, },
async ({ exec, root, fs, expect }) => { async ({ exec, root, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
let before = await fs.dumpFiles('./src/**/*.{css,html}') let before = await fs.dumpFiles('./src/**/*.{css,html}')
expect(before).toMatchInlineSnapshot(` expect(before).toMatchInlineSnapshot(`
@ -2865,7 +2919,7 @@ test(
} }
// Run the upgrade again // Run the upgrade again
let output = await exec('npx @tailwindcss/upgrade') let output = await exec('pnpm exec upgrade')
expect(output).toContain('No changes were made to your repository') expect(output).toContain('No changes were made to your repository')
let after = await fs.dumpFiles('./src/**/*.{css,html}') let after = await fs.dumpFiles('./src/**/*.{css,html}')
@ -2944,7 +2998,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -2972,6 +3026,202 @@ test(
}, },
) )
test(
'v4 ignores .env files during template migration',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/app.css': css`@import 'tailwindcss';`,
'src/index.html': html`
<div class="order-[0]"></div>
`,
'src/.env': `TW_TEST_CLASS=order-[0]`,
'src/.env.production': `TW_TEST_CLASS=order-[0]`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/{*,.env,.env.*}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div class="order-0"></div>
--- ./src/.env ---
TW_TEST_CLASS=order-[0]
--- ./src/.env.production ---
TW_TEST_CLASS=order-[0]
--- ./src/app.css ---
@import 'tailwindcss';
"
`)
},
)
test(
'v4 linked configs respect `content` and still ignore gitignored files',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/*.html', './src/*.less'],
}
`,
'src/app.css': css`
@import 'tailwindcss';
@config '../tailwind.config.js';
`,
// Ignore all .html files
'src/.gitignore': txt`
*.html
`,
// HTML files are in .gitignore, even though they are explicitly mentioned
// in the `content` array. Still ignore them
'src/do-not-migrate-me.html': html`
<div class="order-[0]"></div>
`,
// Should be picked up by auto-content detection
'templates/migrate-me.php': html`
<div class="order-[0]"></div>
`,
// Does not get picked up by auto content detection (because it's a less
// file), but was explicitly listed in the `content` array.
//
// A bit of a hacky way, I admit, but it allows us to differentiate
// between git ignored files, auto content detection and explicitly listed
// files.
'src/migrate-me.less': html`
<div class="order-[0]"></div>
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./{src,templates}/**/*')).toMatchInlineSnapshot(`
"
--- ./src/app.css ---
@import 'tailwindcss';
@config '../tailwind.config.js';
--- ./src/do-not-migrate-me.html ---
<div class="order-[0]"></div>
--- ./src/migrate-me.less ---
<div class="order-0"></div>
--- ./templates/migrate-me.php ---
<div class="order-0"></div>
"
`)
},
)
test(
'interrupting template migration does not truncate files',
{
timeout: 180_000,
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/app.css': css` @import 'tailwindcss'; `,
'src/index.html': html`
<div class="order-[0]"></div>
`,
'hook.cjs': js`
let fs = require('node:fs/promises')
let path = require('node:path')
let originalWriteFile = fs.writeFile.bind(fs)
fs.writeFile = async (file, contents, ...rest) => {
// Mimic a bad write
await originalWriteFile(file, '') // As-if we truncated first
console.error('__TRUNCATED_TARGET__')
await new Promise((r) => setTimeout(r, 50)) // Wait 50ms to allow us to kill the process
await originalWriteFile(file, contents, ...rest) // Write the actual contents
}
`,
'src/keep.php': `
<?php
return [
'keep' => 'this file should never be truncated',
];
`,
},
},
async ({ spawn, fs, expect }) => {
let repeatedCandidates = Array.from(
{ length: 250 },
() => '<div class="order-[0]"></div>',
).join('\n')
for (let i = 0; i < 100; i++) {
await fs.write(
`src/templates/template-${i}.php`,
`<?php\n\n${repeatedCandidates}\n\nreturn ['template' => ${i}];\n`,
)
}
let originalKeepFile = await fs.read('src/keep.php')
let originalTemplate = await fs.read('src/templates/template-0.php')
// NOTE: This test intentionally keeps `npx` instead of `pnpm exec`. It uses
// a `NODE_OPTIONS` hook that overrides `fs.writeFile` to simulate a bad
// write, and `pnpm exec` may write a bin shim on startup which would trip
// that hook before template migration begins.
let process = await spawn('npx @tailwindcss/upgrade --force', {
env: {
NODE_OPTIONS: '--require=./hook.cjs',
},
})
// We're only interested once we start migrating the templates
await process.onStderr((message) => message.includes('Migrating templates'))
// Wait for the trigger that we are mid-write
await process.onStderr((message) => message === '__TRUNCATED_TARGET__')
// Kill the process
await process.dispose()
expect(await fs.read('src/keep.php')).toBe(originalKeepFile)
expect(await fs.read('src/templates/template-0.php')).toBe(originalTemplate)
for (let [file, contents] of await fs.glob('src/**/*.{html,php,css}')) {
expect(contents.trim(), `${file} should not be empty after interruption`).not.toBe('')
}
},
)
test( test(
'upgrades can run in a pnpm workspace', 'upgrades can run in a pnpm workspace',
{ {
@ -3015,7 +3265,7 @@ test(
}, },
}, },
async ({ root, exec, fs, expect }) => { async ({ root, exec, fs, expect }) => {
let stdout = await exec('npx @tailwindcss/upgrade', { let stdout = await exec('pnpm exec upgrade', {
cwd: path.join(root, 'project-a'), cwd: path.join(root, 'project-a'),
}) })
@ -3089,7 +3339,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
let stdout = await exec('npx @tailwindcss/upgrade') let stdout = await exec('pnpm exec upgrade')
expect(stdout).not.toContain( expect(stdout).not.toContain(
'Running this command will add the dependency to the workspace root', 'Running this command will add the dependency to the workspace root',
@ -3121,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( test(
'upgrade <style> blocks carefully', 'upgrade <style> blocks carefully',
{ {
@ -3165,7 +3470,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,vue}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,vue}')).toMatchInlineSnapshot(`
" "

View file

@ -1,6 +1,6 @@
import path from 'node:path' import path from 'node:path'
import { describe } from 'vitest' import { describe } from 'vitest'
import { css, html, json, test, ts } from '../utils' import { css, html, json, test, ts, txt } from '../utils'
test( test(
`upgrade JS config files with flat theme values, darkMode, and content fields`, `upgrade JS config files with flat theme values, darkMode, and content fields`,
@ -164,7 +164,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.{css,js,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.{css,js,html}')).toMatchInlineSnapshot(`
" "
@ -322,6 +322,105 @@ test(
}, },
) )
test(
'skips gitignored template files even when they are explicitly referenced in `content`',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'.gitignore': txt`
node_modules
src/ignored
`,
'tailwind.config.ts': ts`
import { type Config } from 'tailwindcss'
module.exports = {
content: [
'./src/migrate-me.html',
'./src/ignored/do-not-migrate-me.html',
'./node_modules/my-external-lib/template.html',
],
theme: {},
plugins: [],
} satisfies Config
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'src/migrate-me.html': html`
<div class="order-[0]"></div>
`,
'src/ignored/do-not-migrate-me.html': html`
<div class="order-[0]"></div>
`,
'node_modules/my-external-lib/template.html': html`
<div
class="order-[0]"
></div>
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade')
expect(
await fs.dumpFiles(
'{src/**/*.{css,html},node_modules/my-external-lib/template.html,.gitignore}',
),
).toMatchInlineSnapshot(`
"
--- .gitignore ---
node_modules
src/ignored
--- src/input.css ---
@import 'tailwindcss';
@source './ignored/do-not-migrate-me.html';
@source '../node_modules/my-external-lib/template.html';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- src/migrate-me.html ---
<div class="order-0"></div>
--- node_modules/my-external-lib/template.html ---
<div
class="order-[0]"
></div>
--- src/ignored/do-not-migrate-me.html ---
<div class="order-[0]"></div>
"
`)
},
)
test( test(
'upgrades JS config files with plugins', 'upgrades JS config files with plugins',
{ {
@ -385,7 +484,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -487,7 +586,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.{css,ts}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.{css,ts}')).toMatchInlineSnapshot(`
" "
@ -495,17 +594,17 @@ test(
@import 'tailwindcss'; @import 'tailwindcss';
@theme { @theme {
--color-gray-50: oklch(98.5% 0 0); --color-gray-50: oklch(98.5% 0 none);
--color-gray-100: oklch(97% 0 0); --color-gray-100: oklch(97% 0 none);
--color-gray-200: oklch(92.2% 0 0); --color-gray-200: oklch(92.2% 0 none);
--color-gray-300: oklch(87% 0 0); --color-gray-300: oklch(87% 0 none);
--color-gray-400: oklch(70.8% 0 0); --color-gray-400: oklch(70.8% 0 none);
--color-gray-500: oklch(55.6% 0 0); --color-gray-500: oklch(55.6% 0 none);
--color-gray-600: oklch(43.9% 0 0); --color-gray-600: oklch(43.9% 0 none);
--color-gray-700: oklch(37.1% 0 0); --color-gray-700: oklch(37.1% 0 none);
--color-gray-800: oklch(26.9% 0 0); --color-gray-800: oklch(26.9% 0 none);
--color-gray-900: oklch(20.5% 0 0); --color-gray-900: oklch(20.5% 0 none);
--color-gray-950: oklch(14.5% 0 0); --color-gray-950: oklch(14.5% 0 none);
} }
/* /*
@ -573,7 +672,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -651,7 +750,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -725,7 +824,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -835,7 +934,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('project-{a,b}/**/*.{css,ts}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('project-{a,b}/**/*.{css,ts}')).toMatchInlineSnapshot(`
" "
@ -933,7 +1032,7 @@ test(
}, },
}, },
async ({ root, exec, fs, expect }) => { async ({ root, exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade', { await exec('pnpm exec upgrade', {
cwd: path.join(root, 'frontend'), cwd: path.join(root, 'frontend'),
}) })
@ -1020,7 +1119,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
" "
@ -1095,7 +1194,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
" "
@ -1171,7 +1270,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
" "
@ -1247,7 +1346,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1309,7 +1408,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1371,7 +1470,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1410,7 +1509,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1481,7 +1580,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1584,7 +1683,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -1694,7 +1793,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -1796,7 +1895,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -1894,7 +1993,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1965,7 +2064,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -2042,7 +2141,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "

View file

@ -31,6 +31,7 @@ test(
@tailwind utilities; @tailwind utilities;
`, `,
}, },
retry: 0,
}, },
async ({ exec, expect }) => { async ({ exec, expect }) => {
// Ensure we are in a git repo // Ensure we are in a git repo
@ -39,7 +40,7 @@ test(
await exec('git commit -m "before migration"') await exec('git commit -m "before migration"')
// Fully upgrade to v4 // Fully upgrade to v4
await exec('npx @tailwindcss/upgrade') await exec('pnpm exec upgrade')
// Undo all changes to the current repo. This will bring the repo back to a // Undo all changes to the current repo. This will bring the repo back to a
// v3 state, but the `node_modules` will now have v4 installed. // v3 state, but the `node_modules` will now have v4 installed.
@ -47,7 +48,7 @@ test(
// Re-running the upgrade should result in an error // Re-running the upgrade should result in an error
return expect(() => { return expect(() => {
return exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) => { return exec('pnpm exec upgrade', {}, { ignoreStdErr: true }).catch((e) => {
// Replacing the current version with a hardcoded `v4` to make it stable // Replacing the current version with a hardcoded `v4` to make it stable
// when we release new minor/patch versions. // when we release new minor/patch versions.
return Promise.reject( return Promise.reject(
@ -55,7 +56,7 @@ test(
) )
}) })
}).rejects.toThrowErrorMatchingInlineSnapshot(` }).rejects.toThrowErrorMatchingInlineSnapshot(`
"Command failed: npx @tailwindcss/upgrade "Command failed: pnpm exec upgrade
≈ tailwindcss v4.0.0 ≈ tailwindcss v4.0.0
│ ↳ Upgrading from Tailwind CSS \`v4.0.0\` │ ↳ Upgrading from Tailwind CSS \`v4.0.0\`
@ -105,6 +106,7 @@ test(
@tailwind utilities; @tailwind utilities;
`, `,
}, },
retry: 0,
}, },
async ({ exec, expect }) => { async ({ exec, expect }) => {
// Use `bun` to install dependencies // Use `bun` to install dependencies
@ -192,6 +194,7 @@ test(
@tailwind utilities; @tailwind utilities;
`, `,
}, },
retry: 0,
}, },
async ({ exec, expect }) => { async ({ exec, expect }) => {
// Use `npm` to install dependencies // Use `npm` to install dependencies

View file

@ -1,22 +1,27 @@
import dedent from 'dedent' import dedent from 'dedent'
import fastGlob from 'fast-glob' import fastGlob from 'fast-glob'
import { exec, spawn } from 'node:child_process' import { exec, execFile, spawn, type ChildProcess } from 'node:child_process'
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import { createServer } from 'node:net'
import { platform, tmpdir } from 'node:os' import { platform, tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { stripVTControlCharacters } from 'node:util' import { promisify, stripVTControlCharacters } from 'node:util'
import { RawSourceMap, SourceMapConsumer } from 'source-map-js' import { RawSourceMap, SourceMapConsumer } from 'source-map-js'
import { test as defaultTest, type ExpectStatic } from 'vitest' import { test as defaultTest, type ExpectStatic } from 'vitest'
import * as Yaml from 'yaml'
import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table' import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table'
import { escape } from '../packages/tailwindcss/src/utils/escape' import { escape } from '../packages/tailwindcss/src/utils/escape'
const REPO_ROOT = path.join(__dirname, '..') const REPO_ROOT = path.join(__dirname, '..')
const ROOT_PNPM_WORKSPACE = Yaml.parse(
await fs.readFile(path.join(REPO_ROOT, 'pnpm-workspace.yaml'), 'utf8'),
)
const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) => const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) =>
name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''), name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''),
) )
interface SpawnedProcess { interface SpawnedProcess {
dispose: () => void dispose: () => Promise<void>
flush: () => void flush: () => void
onStdout: (predicate: (message: string) => boolean) => Promise<void> onStdout: (predicate: (message: string) => boolean) => Promise<void>
onStderr: (predicate: (message: string) => boolean) => Promise<void> onStderr: (predicate: (message: string) => boolean) => Promise<void>
@ -39,6 +44,7 @@ interface TestConfig {
timeout?: number timeout?: number
installDependencies?: boolean installDependencies?: boolean
retry?: number
} }
interface TestContext { interface TestContext {
root: string root: string
@ -48,15 +54,17 @@ interface TestContext {
parseSourceMap(opts: string | SourceMapOptions): SourceMap parseSourceMap(opts: string | SourceMapOptions): SourceMap
fs: { fs: {
write(filePath: string, content: string, encoding?: BufferEncoding): Promise<void> write(filePath: string, content: string, encoding?: BufferEncoding): Promise<void>
symlink(dst: string, src: string): Promise<void>
create(filePaths: string[]): Promise<void> create(filePaths: string[]): Promise<void>
read(filePath: string): Promise<string> read(filePath: string): Promise<string>
delete(filePath: string): Promise<void>
glob(pattern: string): Promise<[string, string][]> glob(pattern: string): Promise<[string, string][]>
dumpFiles(pattern: string): Promise<string> dumpFiles(pattern: string): Promise<string>
expectFileToContain( expectFileToContain(
filePath: string, filePath: string,
contents: string | RegExp | (string | RegExp)[], contents: string | RegExp | (string | RegExp)[],
): Promise<void> ): Promise<void>
expectFileNotToContain(filePath: string, contents: string | string[]): Promise<void> expectFileNotToContain(filePath: string, contents: string[]): Promise<void>
} }
} }
type TestCallback = (context: TestContext) => Promise<void> | void type TestCallback = (context: TestContext) => Promise<void> | void
@ -64,12 +72,15 @@ interface TestFlags {
only?: boolean only?: boolean
skip?: boolean skip?: boolean
debug?: boolean debug?: boolean
concurrent?: boolean
} }
type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void } type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void }
export const IS_WINDOWS = platform() === 'win32' export const IS_WINDOWS = platform() === 'win32'
const execFileAsync = promisify(execFile)
const TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000 const TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000
const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000 const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000
@ -82,16 +93,16 @@ export function test(
name: string, name: string,
config: TestConfig, config: TestConfig,
testCallback: TestCallback, testCallback: TestCallback,
{ only = false, skip = false, debug = false }: TestFlags = {}, { only = false, skip = false, debug = false, concurrent = false }: TestFlags = {},
) { ) {
return defaultTest( return defaultTest(
name, name,
{ {
timeout: config.timeout ?? TEST_TIMEOUT, timeout: config.timeout ?? TEST_TIMEOUT,
retry: process.env.CI ? 2 : 0, retry: config.retry ?? (process.env.CI ? 2 : 0),
only: only || (!process.env.CI && debug), only: only || (!process.env.CI && debug),
skip, skip,
concurrent: true, concurrent,
}, },
async (options) => { async (options) => {
let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT
@ -115,6 +126,39 @@ export function test(
execOptions: ExecOptions = {}, execOptions: ExecOptions = {},
) { ) {
let cwd = childProcessOptions.cwd ?? root let cwd = childProcessOptions.cwd ?? root
let originalCommand = command
// Avoid `pnpm exec upgrade` on Windows because the upgrader runs
// `pnpm add`, which rewrites Windows `.cmd` shims while the current
// shim is still executing.
//
// Pretty sure this is a pnpm v11 bug on Windows.
if (IS_WINDOWS && command.includes('pnpm exec upgrade')) {
for (let base = cwd; ; base = path.dirname(base)) {
let upgradeBin = path.join(
base,
'node_modules',
'@tailwindcss',
'upgrade',
'dist',
'index.mjs',
)
try {
await fs.access(upgradeBin)
command = command.replace('pnpm exec upgrade', `node "${upgradeBin}"`)
break
} catch (error: any) {
if (error?.code !== 'ENOENT') throw error
}
let parent = path.dirname(base)
if (parent === base) {
throw new Error(`Unable to resolve @tailwindcss/upgrade from ${cwd}`)
}
}
}
if (debug && cwd !== root) { if (debug && cwd !== root) {
let relative = path.relative(root, cwd) let relative = path.relative(root, cwd)
if (relative[0] !== '.') relative = `./${relative}` if (relative[0] !== '.') relative = `./${relative}`
@ -134,6 +178,11 @@ export function test(
}, },
(error, stdout, stderr) => { (error, stdout, stderr) => {
if (error) { if (error) {
if (command !== originalCommand) {
error.message = error.message.replace(command, originalCommand)
let mappedError = error as any
mappedError.cmd = originalCommand
}
if (execOptions.ignoreStdErr !== true) console.error(stderr) if (execOptions.ignoreStdErr !== true) console.error(stderr)
if (only || debug) { if (only || debug) {
console.error(stdout) console.error(stdout)
@ -170,6 +219,7 @@ export function test(
if (debug) console.log(`>& ${command}`) if (debug) console.log(`>& ${command}`)
let child = spawn(command, { let child = spawn(command, {
cwd, cwd,
detached: !IS_WINDOWS,
shell: true, shell: true,
...childProcessOptions, ...childProcessOptions,
env: { env: {
@ -178,22 +228,26 @@ export function test(
}, },
}) })
function dispose() { let disposed = false
if (!child.kill()) {
child.kill('SIGKILL')
}
let timer = setTimeout( async function dispose() {
() => if (disposed) return disposePromise
rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)), disposed = true
ASSERTION_TIMEOUT,
await killProcessTree(child)
let timer = setTimeout(() => {
forceKillProcessTree(child)
rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`))
}, ASSERTION_TIMEOUT)
disposePromise.then(
() => clearTimeout(timer),
() => clearTimeout(timer),
) )
disposePromise.finally(() => {
clearTimeout(timer)
})
return disposePromise return disposePromise
} }
disposables.push(dispose) disposables.push(dispose)
function onExit() { function onExit() {
resolveDisposal?.() resolveDisposal?.()
} }
@ -284,11 +338,7 @@ export function test(
} }
}, },
fs: { fs: {
async write( async write(filename, content, encoding = 'utf8') {
filename: string,
content: string | Uint8Array,
encoding: BufferEncoding = 'utf8',
): Promise<void> {
let full = path.join(root, filename) let full = path.join(root, filename)
let dir = path.dirname(full) let dir = path.dirname(full)
await fs.mkdir(dir, { recursive: true }) await fs.mkdir(dir, { recursive: true })
@ -299,6 +349,8 @@ export function test(
if (filename.endsWith('package.json')) { if (filename.endsWith('package.json')) {
content = await overwriteVersionsInPackageJson(content) content = await overwriteVersionsInPackageJson(content)
} else if (filename.endsWith('pnpm-workspace.yaml')) {
content = overwriteVersionsInPnpmWorkspace(content)
} }
// Ensure that files written on Windows use \r\n line ending // Ensure that files written on Windows use \r\n line ending
@ -309,7 +361,19 @@ export function test(
await fs.writeFile(full, content, encoding) await fs.writeFile(full, content, encoding)
}, },
async create(filenames: string[]): Promise<void> { async symlink(target, src) {
let targetAbsolute = path.join(root, target)
let targetParent = path.dirname(targetAbsolute)
await fs.mkdir(targetParent, { recursive: true })
let srcAbsolute = path.join(root, src)
let srcParent = path.dirname(srcAbsolute)
await fs.mkdir(srcParent, { recursive: true })
await fs.symlink(targetAbsolute, srcAbsolute)
},
async create(filenames) {
for (let filename of filenames) { for (let filename of filenames) {
let full = path.join(root, filename) let full = path.join(root, filename)
@ -319,7 +383,11 @@ export function test(
} }
}, },
async read(filePath: string) { async delete(filename) {
await fs.unlink(path.join(root, filename))
},
async read(filePath) {
let content = await fs.readFile(path.resolve(root, filePath), 'utf8') let content = await fs.readFile(path.resolve(root, filePath), 'utf8')
// Ensure that files read on Windows have \r\n line endings removed // Ensure that files read on Windows have \r\n line endings removed
@ -329,7 +397,7 @@ export function test(
return content return content
}, },
async glob(pattern: string) { async glob(pattern) {
let files = await fastGlob(pattern, { cwd: root }) let files = await fastGlob(pattern, { cwd: root })
return Promise.all( return Promise.all(
files.map(async (file) => { files.map(async (file) => {
@ -342,7 +410,7 @@ export function test(
}), }),
) )
}, },
async dumpFiles(pattern: string) { async dumpFiles(pattern) {
let files = await context.fs.glob(pattern) let files = await context.fs.glob(pattern)
return `\n${files return `\n${files
.slice() .slice()
@ -401,21 +469,44 @@ export function test(
node_modules/ node_modules/
` `
// Disable Git's automatic line ending conversion in these throwaway test
// repositories. Tools like pnpm write files (e.g. `pnpm-lock.yaml`) with
// LF endings, which on Windows (where `core.autocrlf=true` is common)
// makes Git emit noisy "LF will be replaced by CRLF" warnings when it
// touches those files.
config.fs['.gitattributes'] ??= txt`
* -text
`
// Ensure there is always a root `pnpm-workspace.yaml` so that transitive
// dependency overrides (and the build allow list) are injected into it.
// As of pnpm v10, these can no longer live in the `pnpm.overrides` field
// of `package.json`.
config.fs['pnpm-workspace.yaml'] ??= ''
for (let [filename, content] of Object.entries(config.fs)) { for (let [filename, content] of Object.entries(config.fs)) {
if (content.toString().startsWith('symlink:')) {
// The symlink path is relative to the target destination's path
let target = path.join(
filename,
content.toString().slice('symlink:'.length), // Relative path
)
await context.fs.symlink(target, filename)
} else {
await context.fs.write(filename, content) await context.fs.write(filename, content)
} }
}
let shouldInstallDependencies = config.installDependencies ?? true let shouldInstallDependencies = config.installDependencies ?? true
try { try {
// In debug mode, the directory is going to be inside the pnpm workspace // Every test project gets its own root `pnpm-workspace.yaml` (see
// of the tailwindcss package. This means that `pnpm install` will run // above). This makes the test directory its own workspace root, so even
// pnpm install on the workspace instead (expect if the root dir defines // in debug mode — where the directory lives inside this repository's
// a separate workspace). We work around this by using the // pnpm workspace — `pnpm install` won't attach to the parent workspace.
// `--ignore-workspace` flag.
if (shouldInstallDependencies) { if (shouldInstallDependencies) {
let ignoreWorkspace = debug && !config.fs['pnpm-workspace.yaml'] await context.exec(`pnpm install`)
await context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`)
} }
} catch (error: any) { } catch (error: any) {
console.error(error) console.error(error)
@ -427,11 +518,18 @@ export function test(
let disposables: (() => Promise<void>)[] = [] let disposables: (() => Promise<void>)[] = []
async function dispose() { async function dispose() {
await Promise.all(disposables.map((dispose) => dispose())) let results = await Promise.allSettled(disposables.map((dispose) => dispose()))
if (!debug) { if (!debug) {
await gracefullyRemove(root) await gracefullyRemove(root)
} }
let errors = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : [],
)
if (errors.length > 0) {
throw new AggregateError(errors, 'Failed to clean up spawned processes')
}
} }
options.onTestFinished(dispose) options.onTestFinished(dispose)
@ -441,7 +539,7 @@ export function test(
try { try {
await context.exec('git init', { cwd: root }) await context.exec('git init', { cwd: root })
await context.exec('git add --all', { cwd: root }) await context.exec('git add --all', { cwd: root })
await context.exec('git commit -m "before migration"', { cwd: root }) await context.exec('git commit -m "before migration" --allow-empty', { cwd: root })
} catch (error: any) { } catch (error: any) {
console.error(error) console.error(error)
console.error(error.stdout?.toString()) console.error(error.stdout?.toString())
@ -460,6 +558,9 @@ test.only = (name: string, config: TestConfig, testCallback: TestCallback) => {
test.skip = (name: string, config: TestConfig, testCallback: TestCallback) => { test.skip = (name: string, config: TestConfig, testCallback: TestCallback) => {
return test(name, config, testCallback, { skip: true }) return test(name, config, testCallback, { skip: true })
} }
test.concurrent = (name: string, config: TestConfig, testCallback: TestCallback) => {
return test(name, config, testCallback, { concurrent: true })
}
test.debug = (name: string, config: TestConfig, testCallback: TestCallback) => { test.debug = (name: string, config: TestConfig, testCallback: TestCallback) => {
return test(name, config, testCallback, { debug: true }) return test(name, config, testCallback, { debug: true })
} }
@ -483,28 +584,43 @@ async function overwriteVersionsInPackageJson(content: string): Promise<string>
} }
} }
return JSON.stringify(json, null, 2)
}
function overwriteVersionsInPnpmWorkspace(content: string): string {
let workspace = content.trim() === '' ? {} : Yaml.parse(content)
// Inherit information from the root workspace
workspace.allowBuilds = {
...ROOT_PNPM_WORKSPACE.allowBuilds,
...workspace.allowBuilds,
}
// Inject transitive dependency overwrite. This is necessary because // Inject transitive dependency overwrite. This is necessary because
// @tailwindcss/vite internally depends on a specific version of // @tailwindcss/vite internally depends on a specific version of
// @tailwindcss/oxide and we instead want to resolve it to the locally built // @tailwindcss/oxide and we instead want to resolve it to the locally built
// version. // version.
json.pnpm ||= {} //
json.pnpm.overrides ||= {} // As of pnpm v10, the `pnpm.overrides` field in `package.json` is no longer
// read. Overrides have to be defined in `pnpm-workspace.yaml` instead.
workspace.overrides ||= {}
for (let pkg of PUBLIC_PACKAGES) { for (let pkg of PUBLIC_PACKAGES) {
if (pkg === 'tailwindcss') { if (pkg === 'tailwindcss') {
// We want to be explicit about the `tailwindcss` package so our tests can // We want to be explicit about the `tailwindcss` package so our tests can
// also import v3 without conflicting v4 tarballs. // also import v3 without conflicting v4 tarballs.
json.pnpm.overrides['@tailwindcss/node>tailwindcss'] = resolveVersion(pkg) workspace.overrides['@tailwindcss/node>tailwindcss'] = resolveVersion(pkg)
json.pnpm.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg) workspace.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg)
json.pnpm.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg) workspace.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg)
json.pnpm.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg) workspace.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg)
json.pnpm.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg) workspace.overrides['@tailwindcss/turbopack>tailwindcss'] = resolveVersion(pkg)
json.pnpm.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg) workspace.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg)
} else { } else {
json.pnpm.overrides[pkg] = resolveVersion(pkg) workspace.overrides[pkg] = resolveVersion(pkg)
} }
} }
return JSON.stringify(json, null, 2) return Yaml.stringify(workspace)
} }
function resolveVersion(dependency: string) { function resolveVersion(dependency: string) {
@ -554,6 +670,7 @@ export async function retryAssertion<T>(
try { try {
return await fn() return await fn()
} catch (err) { } catch (err) {
Error.captureStackTrace(err, retryAssertion)
error = err error = err
await new Promise((resolve) => setTimeout(resolve, delay)) await new Promise((resolve) => setTimeout(resolve, delay))
} }
@ -606,6 +723,65 @@ export async function fetchStyles(base: string, path = '/'): Promise<string> {
}, '') }, '')
} }
export async function getRandomPort() {
return new Promise<number>((resolve, reject) => {
let server = createServer()
server.unref()
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
let address = server.address()
server.close(() => {
if (address && typeof address === 'object') {
resolve(address.port)
} else {
reject(new Error('Unable to allocate random port'))
}
})
})
})
}
async function killProcessTree(child: ChildProcess) {
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) {
return
}
if (IS_WINDOWS) {
await execFileAsync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
timeout: ASSERTION_TIMEOUT,
windowsHide: true,
}).catch(() => {})
return
}
try {
process.kill(-child.pid, 'SIGTERM')
} catch (error: any) {
if (error?.code !== 'ESRCH') {
child.kill()
}
}
}
function forceKillProcessTree(child: ChildProcess) {
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) {
return
}
if (IS_WINDOWS) {
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }, () => {})
return
}
try {
process.kill(-child.pid, 'SIGKILL')
} catch (error: any) {
if (error?.code !== 'ESRCH') {
child.kill('SIGKILL')
}
}
}
async function gracefullyRemove(dir: string) { async function gracefullyRemove(dir: string) {
// Skip removing the directory in CI because it can stall on Windows // Skip removing the directory in CI because it can stall on Windows
if (!process.env.CI) { if (!process.env.CI) {

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( test(
'watch mode', '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', 'Using Vite %s',
(version) => { (version) => {
test( test(
'external source file changes trigger a full reload', 'external source file changes update the CSS',
{ {
fs: { fs: {
'package.json': json`{}`, 'package.json': json`{}`,
@ -554,26 +661,33 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
expect(styles).toContain(candidate`content-['project-b/src/index.php']`) expect(styles).toContain(candidate`content-['project-b/src/index.php']`)
}) })
// Flush all messages so that we can be sure the next messages are from // Flush all messages so that we can be sure the next messages are
// the file changes we're about to make // from the file changes we're about to make
process.flush() 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( await fs.write(
'project-b/src/index.php', 'project-b/src/index.php',
txt`<div class="content-['updated:project-b/src/index.php']"></div>`, 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') { if (version === '^6' || version === '7.0.8') {
await process.onStdout((m) => m.includes('page reload') && m.includes('index.php')) await process.onStdout((m) => m.includes('page reload') && m.includes('index.php'))
} else { } else {
await process.onStderr( await process.onStdout((m) => m.includes('hmr update') && m.includes('index.css'))
(m) => m.includes('vite:hmr (client)') && m.includes('index.php'),
)
} }
await process.onStderr((m) => m.includes('vite:hmr (ssr)') && m.includes('index.php'))
// Ensure the styles were regenerated with the new content // Ensure the styles were regenerated with the new content
let styles = await fetchStyles(url, '/index.html') 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( test(
`source(none) disables looking at the module graph`, `source(none) disables looking at the module graph`,
{ {
@ -836,7 +1178,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
async ({ root, fs, exec, expect }) => { async ({ root, fs, exec, expect }) => {
await expect(() => await expect(() =>
exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }), exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }),
).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist') ).rejects.toThrow('The `source(../i-do-not-exist)` does not exist')
let files = await fs.glob('project-a/dist/**/*.css') let files = await fs.glob('project-a/dist/**/*.css')
expect(files).toHaveLength(0) expect(files).toHaveLength(0)
@ -1090,6 +1432,14 @@ test(
</head> </head>
`, `,
'src/index.js': js`import Plotly from 'plotly.js/lib/core'`, 'src/index.js': js`import Plotly from 'plotly.js/lib/core'`,
// `plotly.js` pulls in `es5-ext`, which has a build script. As of pnpm
// v10, build scripts only run when explicitly allowed.
'pnpm-workspace.yaml': yaml`
#
allowBuilds:
es5-ext: true
`,
}, },
}, },
async ({ exec, expect, fs }) => { async ({ exec, expect, fs }) => {
@ -1137,7 +1487,7 @@ test(
<link rel="stylesheet" href="./src/index.css" /> <link rel="stylesheet" href="./src/index.css" />
</head> </head>
<body> <body>
<div class="hover:flex">Hello, world!</div> <div class="focus:text-[black]">Hello, world!</div>
</body> </body>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -1149,16 +1499,16 @@ test(
async ({ exec, expect, fs }) => { async ({ exec, expect, fs }) => {
await exec('pnpm vite build') 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 // Should not be minified when optimize is disabled
let content = await fs.read(filename) expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'))
expect(content).toContain('.hover\\:flex {') .toMatchInlineSnapshot(`
expect(content).toContain('&:hover {') "
expect(content).toContain('@media (hover: hover) {') --- dist/assets/index-<hash>.css ---
expect(content).toContain('display: flex;') .focus\\:text-\\[black\\]:focus {
color: black;
}
"
`)
}, },
) )
@ -1192,7 +1542,7 @@ test(
<link rel="stylesheet" href="./src/index.css" /> <link rel="stylesheet" href="./src/index.css" />
</head> </head>
<body> <body>
<div class="hover:flex">Hello, world!</div> <div class="hover:text-[black]">Hello, world!</div>
</body> </body>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -1204,15 +1554,17 @@ test(
async ({ exec, expect, fs }) => { async ({ exec, expect, fs }) => {
await exec('pnpm vite build') await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css') expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'))
expect(files).toHaveLength(1) .toMatchInlineSnapshot(`
let [filename] = files[0] "
--- dist/assets/index-<hash>.css ---
// Should be optimized but not minified @media (hover: hover) {
let content = await fs.read(filename) .hover\\:text-\\[black\\]:hover {
expect(content).toContain('@media (hover: hover) {') color: #000;
expect(content).toContain('.hover\\:flex:hover {') }
expect(content).toContain('display: flex;') }
"
`)
}, },
) )
@ -1275,3 +1627,100 @@ test(
await fs.expectFileToContain(filename, [candidate`content-['index.html']`]) await fs.expectFileToContain(filename, [candidate`content-['index.html']`])
}, },
) )
// https://github.com/tailwindlabs/tailwindcss/issues/17532
test(
'deleting a file should not crash Vite',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "6.2.5"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [tailwindcss()],
})
`,
'index.html': html`
<body>
<div id="app" class="underline"></div>
<script type="module" src="./src/main.js"></script>
</body>
`,
'src/index.css': css`@import 'tailwindcss';`,
'src/main.js': js`
import iconUrl from './assets/icon.svg?url'
import './index.css'
document.querySelector('#app').innerHTML = iconUrl
`,
'src/assets/icon.svg': html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
<rect class="fill-red-500" width="1" height="1" />
</svg>
`,
},
},
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 [main, css] = await Promise.all([
fetch(`${url}/src/main.js`).then((r) => r.text()),
fetch(`${url}/src/index.css`).then((r) => r.text()),
])
expect(main).toContain('/src/assets/icon.svg?import&url')
expect(main).toContain('/src/index.css')
expect(css).toContain('.fill-red-500')
})
process.flush()
let unexpectedError = process.onStderr((m) => {
return /error|crash|panic/i.test(m)
})
await fs.write(
'src/main.js',
js`
import './index.css'
document.querySelector('#app').innerHTML = 'Hello World'
`,
)
await fs.delete('src/assets/icon.svg')
let result = await Promise.race([
unexpectedError.then(() => 'stderr'),
new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)),
])
expect(result).toBe('timeout')
await retryAssertion(async () => {
let response = await fetch(`${url}/src/index.css?t=${Date.now()}`)
expect(response.status).toBe(200)
})
},
)

View file

@ -1,4 +1,15 @@
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' import {
candidate,
css,
fetchStyles,
getRandomPort,
html,
json,
retryAssertion,
test,
ts,
yaml,
} from '../utils'
const SETUP = { const SETUP = {
fs: { fs: {
@ -11,14 +22,14 @@ const SETUP = {
"nitropack": "2.11.0", "nitropack": "2.11.0",
"tailwindcss": "workspace:^", "tailwindcss": "workspace:^",
"vue": "latest" "vue": "latest"
},
"pnpm": {
"overrides": {
"nuxi": "3.28.0"
}
} }
} }
`, `,
'pnpm-workspace.yaml': yaml`
#
overrides:
nuxi: 3.28.0
`,
'nuxt.config.ts': ts` 'nuxt.config.ts': ts`
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
@ -84,7 +95,8 @@ test('build', SETUP, async ({ spawn, exec, expect }) => {
await exec('pnpm nuxt build') await exec('pnpm nuxt build')
// The Nuxt preview server does not automatically assign a free port if 3000 // The Nuxt preview server does not automatically assign a free port if 3000
// is taken, so we use a random port instead. // is taken, so we use a random port instead.
let process = await spawn(`pnpm nuxt preview --port 8724`, { let port = await getRandomPort()
let process = await spawn(`pnpm nuxt preview --port ${port}`, {
env: { env: {
TEST: 'false', TEST: 'false',
NODE_ENV: 'development', NODE_ENV: 'development',

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

@ -1,5 +1,661 @@
import { describe } from 'vitest' import { describe } from 'vitest'
import { candidate, css, fetchStyles, html, js, retryAssertion, test, ts, txt } from '../utils' import {
candidate,
css,
fetchStyles,
html,
js,
json,
retryAssertion,
test,
ts,
txt,
yaml,
} from '../utils'
test(
'resolves tsconfig paths in production build',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'tsconfig.json': json`
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
resolve: {
tsconfigPaths: true,
},
})
`,
'index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline custom-underline">Hello, world!</div>
</body>
`,
'src/index.css': css`
@import '@/styles/base.css';
@plugin '@/plugin.js';
`,
'src/styles/base.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
'src/plugin.js': js`
export default function ({ addUtilities }) {
addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } })
}
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [candidate`underline`, candidate`custom-underline`])
},
)
test(
'resolves tsconfig paths in dev mode',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'tsconfig.json': json`
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
resolve: {
tsconfigPaths: true,
},
})
`,
'index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline custom-underline">Hello, world!</div>
</body>
`,
'src/index.css': css`
@import '@/styles/base.css';
@plugin '@/plugin.js';
`,
'src/styles/base.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
'src/plugin.js': js`
export default function ({ addUtilities }) {
addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } })
}
`,
},
},
async ({ 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, '/index.html')
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`custom-underline`)
})
},
)
test(
'resolves at-sign aliases in production build',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import { fileURLToPath } from 'node:url'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
resolve: {
alias: [{ find: '@', replacement: fileURLToPath(new URL('.', import.meta.url)) }],
},
})
`,
'index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline custom-underline">Hello, world!</div>
</body>
`,
'src/index.css': css`
@import '@/src/styles/base.css';
@plugin '@/src/plugin.js';
`,
'src/styles/base.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
'src/plugin.js': js`
export default function ({ addUtilities }) {
addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } })
}
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [candidate`underline`, candidate`custom-underline`])
},
)
test(
'resolves package plugins in production build with at-sign aliases',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"tailwind-scrollbar": "^4.0.2",
"tailwindcss-motion": "^1.1.1",
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import { fileURLToPath } from 'node:url'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
resolve: {
alias: [{ find: '@', replacement: fileURLToPath(new URL('.', import.meta.url)) }],
},
})
`,
'index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="scrollbar scrollbar-thumb-red-500 motion-preset-fade">Hello, world!</div>
</body>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin 'tailwind-scrollbar';
@plugin 'tailwindcss-motion';
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [
candidate`scrollbar`,
candidate`scrollbar-thumb-red-500`,
candidate`motion-preset-fade`,
])
},
)
test(
'resolves package plugins to JS entries in production build when browser points to CSS',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"plugin-browser-css": "workspace:*",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'pnpm-workspace.yaml': yaml`
#
packages:
- packages/*
`,
'packages/plugin-browser-css/package.json': json`
{
"name": "plugin-browser-css",
"version": "1.0.0",
"type": "module",
"main": "./index.js",
"module": "./index.js",
"browser": "./browser.css"
}
`,
'packages/plugin-browser-css/index.js': js`
export default function ({ addUtilities }) {
addUtilities({ '.browser-css-plugin': { 'border-bottom': '1px solid green' } })
}
`,
'packages/plugin-browser-css/browser.css': css`
.should-not-be-loaded-as-a-plugin {
display: none;
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="browser-css-plugin">Hello, world!</div>
</body>
</html>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin 'plugin-browser-css';
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [candidate`browser-css-plugin`])
},
)
test(
'resolves package plugins to JS entries in dev mode when browser points to CSS',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"plugin-browser-css": "workspace:*",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'pnpm-workspace.yaml': yaml`
#
packages:
- packages/*
`,
'packages/plugin-browser-css/package.json': json`
{
"name": "plugin-browser-css",
"version": "1.0.0",
"type": "module",
"main": "./index.js",
"module": "./index.js",
"browser": "./browser.css"
}
`,
'packages/plugin-browser-css/index.js': js`
export default function ({ addUtilities }) {
addUtilities({ '.browser-css-plugin': { 'border-bottom': '1px solid green' } })
}
`,
'packages/plugin-browser-css/browser.css': css`
.should-not-be-loaded-as-a-plugin {
display: none;
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="browser-css-plugin">Hello, world!</div>
</body>
</html>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin 'plugin-browser-css';
`,
},
},
async ({ 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, '/index.html')
expect(styles).toContain(candidate`browser-css-plugin`)
})
},
)
test(
'resolves package plugins in dev mode when package exports CSS files',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"daisyui": "^5",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<button class="btn btn-primary">Hello, world!</button>
</body>
</html>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin 'daisyui';
`,
},
},
async ({ 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, '/index.html')
expect(styles).toContain('.btn')
expect(styles).toContain('.btn-primary')
})
},
)
test(
'resolve relative CSS files correctly',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body></body>
</html>
`,
'src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@import './themes/glow.css';
`,
// References a file in the current folder, which names happens to match a
// file in the parent folder as well.
'src/themes/glow.css': css`@import './entry.css';`,
'src/themes/entry.css': css`
.do-include-me {
color: green;
}
`,
// Never rerefenced, so should not be included
'src/entry.css': css`
.do-not-include-me {
color: red;
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm vite build')
expect(
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
).toMatchInlineSnapshot(`
"
--- ./dist/assets/index-<hash>.css ---
.do-include-me {
color: green;
}
"
`)
},
)
test(
'resolve relative JS files correctly',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body></body>
</html>
`,
'src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@import './themes/glow.css';
`,
// References a file in the current folder, which names happens to match a
// file in the parent folder as well.
'src/themes/glow.css': css`@plugin "./my-plugin.js";`,
'src/themes/my-plugin.js': ts`
export default function ({ addBase }) {
addBase({ '.do-include-me': { color: 'green' } })
}
`,
// Never rerefenced, so should not be included
'src/my-plugin.js': css`
export default function ({ addBase }) {
addBase({ '.do-not-include-me': { 'color': 'red' } })
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm vite build')
expect(
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
).toMatchInlineSnapshot(`
"
--- ./dist/assets/index-<hash>.css ---
@layer base {
.do-include-me {
color: green;
}
}
"
`)
},
)
describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
test( test(

View file

@ -91,3 +91,67 @@ test(
}) })
}, },
) )
// https://github.com/tailwindlabs/tailwindcss/issues/19930
test(
'production build without Tailwind roots should not result in source map warnings',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^7"
}
}
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
tailwindcss(),
{
name: 'inspect-source-map-chain',
enforce: 'pre',
transform(_, id) {
if (id.includes('.css')) {
// Force Rollup to collapse the sourcemap chain during this build.
this.getCombinedSourcemap()
}
},
},
],
css: {
devSourcemap: true,
},
build: {
sourcemap: true,
},
})
`,
'index.html': html`
<body>
<script type="module" src="./src/index.js"></script>
<div>Hello, world!</div>
</body>
`,
'src/index.js': ts` import './index.css' `,
'src/index.css': css`
body {
color: red;
}
`,
},
},
async ({ exec, expect }) => {
let output = await exec('pnpm vite build')
expect(output).not.toContain('Sourcemap is likely to be incorrect')
},
)

View file

@ -0,0 +1,80 @@
import { candidate, css, html, js, json, test, ts } from '../utils'
test(
'sveltekit build resolves package plugins',
{
fs: {
'package.json': json`
{
"type": "module",
"scripts": {
"prepare": "svelte-kit sync || echo ''"
},
"dependencies": {
"svelte": "^5",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6",
"@sveltejs/kit": "^2",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/vite": "workspace:^",
"vite": "^8"
}
}
`,
'svelte.config.js': js`
import adapter from '@sveltejs/adapter-auto'
export default {
kit: {
adapter: adapter(),
},
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss(), sveltekit()],
})
`,
'src/app.html': html`
<!doctype html>
<html lang="en">
<head>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
`,
'src/routes/+layout.svelte': html`
<script>
import '../app.css'
</script>
<slot />
`,
'src/routes/+page.svelte': html`
<div class="form-input">Hello, world!</div>
`,
'src/app.css': css`
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('.svelte-kit/output/client/**/*.css')
expect(files.length).toBeGreaterThan(0)
await fs.expectFileToContain(files[0][0], [candidate`form-input`])
},
)

View file

@ -1,5 +1,5 @@
import { stripVTControlCharacters } from 'node:util' import { stripVTControlCharacters } from 'node:util'
import { candidate, html, json, test, ts } from '../utils' import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'
test( test(
'production build', 'production build',
@ -73,6 +73,110 @@ test(
}, },
) )
{
const VUE_COMPONENT_COUNT = 1_000
let vueComponentsWithReferences = Object.fromEntries(
Array.from({ length: VUE_COMPONENT_COUNT }, (_, idx) => [
`src/components/Component${idx}.vue`,
html`
<template>
<div class="content-['component-${idx}']">Component ${idx}</div>
</template>
<style>
@reference '../main.css';
.component-${idx} {
@apply text-red-500;
}
</style>
`,
]),
)
let vueComponentImports = Array.from(
{ length: VUE_COMPONENT_COUNT },
(_, idx) => `import Component${idx} from './components/Component${idx}.vue'`,
).join('\n')
let vueComponentUsages = Array.from(
{ length: VUE_COMPONENT_COUNT },
(_, idx) => `<Component${idx} class="component-${idx}" />`,
).join('\n')
test(
'production build with many Vue style blocks referencing the main stylesheet',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"vue": "^3.4.37",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
"@tailwindcss/vite": "workspace:^",
"vite": "^7"
}
}
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [vue(), tailwindcss()],
})
`,
'index.html': html`
<!doctype html>
<html>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/main.css': css`@import 'tailwindcss';`,
'src/main.ts': ts`
import { createApp } from 'vue'
import './main.css'
import App from './App.vue'
createApp(App).mount('#app')
`,
'src/App.vue': html`
<script setup>
${vueComponentImports}
</script>
<template>${vueComponentUsages}</template>
`,
...vueComponentsWithReferences,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
await fs.expectFileToContain(files[0][0], [
candidate`content-['component-0']`,
candidate`component-0`,
candidate`content-['component-99']`,
candidate`component-99`,
candidate`content-['component-999']`,
candidate`component-999`,
])
},
)
}
test( test(
'error when using `@apply` without `@reference`', 'error when using `@apply` without `@reference`',
{ {
@ -132,7 +236,7 @@ test(
expect.assertions(1) expect.assertions(1)
try { try {
await exec('pnpm vite build') await exec('pnpm vite build', {}, { ignoreStdErr: true })
} catch (error) { } catch (error) {
let [, message] = let [, message] =
/error during build:([\s\S]*?)file:/g.exec( /error during build:([\s\S]*?)file:/g.exec(
@ -144,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

@ -418,3 +418,78 @@ test(
`) `)
}, },
) )
test(
'@tailwindcss/webpack loader isolates cache by resource including query',
{
fs: {
'package.json': json`
{
"main": "./src/index.js",
"browser": "./src/index.js",
"dependencies": {
"css-loader": "^6",
"webpack": "^5",
"webpack-cli": "^5",
"mini-css-extract-plugin": "^2",
"tailwindcss": "workspace:^",
"@tailwindcss/webpack": "workspace:^"
}
}
`,
'webpack.config.js': js`
let MiniCssExtractPlugin = require('mini-css-extract-plugin')
let path = require('node:path')
module.exports = {
mode: 'development',
entry: {
a: './src/a.js',
b: './src/b.js',
},
output: {
clean: true,
},
plugins: [new MiniCssExtractPlugin()],
module: {
rules: [
{
test: /.css$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'@tailwindcss/webpack',
path.resolve(__dirname, 'query-loader.js'),
],
},
],
},
}
`,
'query-loader.js': js`
module.exports = function (source) {
if (this.resourceQuery.includes('a')) {
return '@import "tailwindcss/utilities"; @utility only-a {color: var(--color-red-500);}'
}
if (this.resourceQuery.includes('b')) {
return '@import "tailwindcss/utilities"; @utility only-b {color: var(--color-blue-500);}'
}
return source
}
`,
'src/a.js': js`import './index.css?a'`,
'src/b.js': js`import './index.css?b'`,
'src/index.css': css``,
},
},
async ({ fs, exec }) => {
await exec('pnpm webpack --mode=development')
await fs.expectFileToContain('dist/a.css', ['only-a', '--color-red-500'])
await fs.expectFileToContain('dist/b.css', ['only-b', '--color-blue-500'])
await fs.expectFileNotToContain('dist/a.css', ['only-b', '--color-blue-500'])
await fs.expectFileNotToContain('dist/b.css', ['only-a', '--color-red-500'])
},
)

View file

@ -48,23 +48,17 @@
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.0", "@playwright/test": "^1.62.1",
"@types/node": "catalog:", "@types/node": "catalog:",
"postcss": "8.5.6", "postcss": "catalog:",
"postcss-import": "^16.1.1", "postcss-import": "^16.1.1",
"prettier": "catalog:", "prettier": "catalog:",
"prettier-plugin-embed": "^0.5.1", "prettier-plugin-embed": "^0.5.1",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"turbo": "^2.7.6", "turbo": "^2.10.8",
"typescript": "^5.5.4", "typescript": "^5.9.3",
"vitest": "^4.0.18" "vitest": "^4.1.10"
}, },
"packageManager": "pnpm@9.6.0", "packageManager": "pnpm@11.9.0"
"pnpm": {
"patchedDependencies": {
"@parcel/watcher@2.5.1": "patches/@parcel__watcher@2.5.1.patch",
"lightningcss@1.32.0": "patches/lightningcss@1.32.0.patch"
}
}
} }

View file

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

View file

@ -14,7 +14,7 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: [['html', { open: 'never' }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
@ -30,10 +30,11 @@ export default defineConfig({
name: 'chromium', name: 'chromium',
use: { ...devices['Desktop Chrome'] }, use: { ...devices['Desktop Chrome'] },
}, },
{ // Playwright 1.60's Windows WebKit worker hangs on shutdown after all tests
name: 'webkit', // pass, causing CI to fail with a worker force-kill error.
use: { ...devices['Desktop Safari'] }, ...(process.platform === 'win32'
}, ? []
: [{ name: 'webkit', use: { ...devices['Desktop Safari'] } }]),
{ {
name: 'firefox', name: 'firefox',
use: { use: {

View file

@ -9,6 +9,10 @@ test.beforeAll(async ({}, info) => {
server = await createServer() server = await createServer()
}) })
test.afterAll(async () => {
await server.close()
})
test('basic', async ({ page }) => { test('basic', async ({ page }) => {
await server.render({ await server.render({
page, page,
@ -166,6 +170,7 @@ async function createServer() {
return { return {
app, app,
url: listener.url, url: listener.url,
close: listener.close,
render, render,
} }
} }

View file

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

View file

@ -1,4 +1,3 @@
import watcher from '@parcel/watcher'
import { import {
compile, compile,
env, env,
@ -21,11 +20,13 @@ import {
highlight, highlight,
println, println,
relative, relative,
wordWrap,
} from '../../utils/renderer' } from '../../utils/renderer'
import { drainStdin, outputFile } from './utils' import { drainStdin, outputFile } from './utils'
const css = String.raw const css = String.raw
const DEBUG = env.DEBUG const DEBUG = env.DEBUG
const DEFAULT_POLL_INTERVAL_MS = 250
export function options() { export function options() {
return { return {
@ -47,6 +48,12 @@ export function options() {
alias: '-w', alias: '-w',
values: ['always'], values: ['always'],
}, },
'--poll': {
type: 'boolean | number',
description: 'Use polling instead of filesystem events when watching',
default: false,
values: ['ms'],
},
'--minify': { '--minify': {
type: 'boolean', type: 'boolean',
description: 'Optimize and minify the output', description: 'Optimize and minify the output',
@ -66,23 +73,58 @@ export function options() {
description: 'Generate a source map', description: 'Generate a source map',
default: false, default: false,
}, },
'--silent': {
type: 'boolean',
description: 'Suppress non-error output',
},
} satisfies Arg } 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> { async function handleError<T>(fn: () => T): Promise<T> {
try { try {
return await fn() return await fn()
} catch (err) { } catch (err) {
if (err instanceof Error) { eprintln(formatError(err))
eprintln(err.toString())
}
process.exit(1) process.exit(1)
} }
} }
export async function handle(args: Result<ReturnType<typeof options>>) { export async function handle(args: Result<ReturnType<typeof options>>) {
eprintln(header()) if (!args['--silent']) eprintln(header())
eprintln() if (!args['--silent']) eprintln()
using I = new Instrumentation() using I = new Instrumentation()
DEBUG && I.start('[@tailwindcss/cli] (initial build)') DEBUG && I.start('[@tailwindcss/cli] (initial build)')
@ -121,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 // 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 // this is the default behavior of `{bin build} --map` to inform the user of that
if (args['--map'] === '-') { 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) process.exit(1)
} }
@ -144,6 +196,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let previous = { let previous = {
css: '', css: '',
optimizedCss: '', optimizedCss: '',
output: null as string | null,
} }
async function write( async function write(
@ -200,13 +253,18 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
} }
} }
let didChange = output !== previous.output
DEBUG && I.start('Write output') DEBUG && I.start('Write output')
if (args['--output'] && args['--output'] !== '-') { if (args['--output'] && args['--output'] !== '-') {
await outputFile(args['--output'], output) await outputFile(args['--output'], output)
} else { } else if (didChange) {
println(output) println(output)
} }
DEBUG && I.end('Write output') DEBUG && I.end('Write output')
previous.output = output
return didChange
} }
let inputFilePath = let inputFilePath =
@ -215,6 +273,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let inputBasePath = inputFilePath ? path.dirname(inputFilePath) : process.cwd() let inputBasePath = inputFilePath ? path.dirname(inputFilePath) : process.cwd()
let fullRebuildPaths: string[] = inputFilePath ? [inputFilePath] : [] let fullRebuildPaths: string[] = inputFilePath ? [inputFilePath] : []
let backupRebuildPaths = fullRebuildPaths
async function createCompiler(css: string, I: Instrumentation) { async function createCompiler(css: string, I: Instrumentation) {
DEBUG && I.start('Setup compiler') DEBUG && I.start('Setup compiler')
@ -241,6 +300,24 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
return [{ ...compiler.root, negated: false }] return [{ ...compiler.root, negated: false }]
})().concat(compiler.sources) })().concat(compiler.sources)
// Do not scan the current executable. Otherwise when using the standalone
// CLI, if the CLI lives in the current repo we would be scanning that file.
//
// This is also immune against renames of the executable file.
sources.push({
base: path.dirname(process.execPath),
pattern: path.basename(process.execPath),
negated: true,
})
if (inputFilePath !== null) {
sources.push({
base: path.dirname(inputFilePath),
pattern: path.basename(inputFilePath),
negated: false,
})
}
let scanner = new Scanner({ sources }) let scanner = new Scanner({ sources })
DEBUG && I.end('Setup compiler') DEBUG && I.end('Setup compiler')
@ -248,12 +325,16 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
} }
let [compiler, scanner] = await handleError(() => createCompiler(input, I)) let [compiler, scanner] = await handleError(() => createCompiler(input, I))
let cleanupWatchers: (() => Promise<void>)[] = []
// Watch for changes // Watch for changes
if (args['--watch']) { if (args['--watch'] && pollInterval === false) {
let cleanupWatchers: (() => Promise<void>)[] = [] // 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( cleanupWatchers.push(
await createWatchers(watchDirectories(scanner), async function handle(files) { await createWatchers(await watchDirectories(scanner), async function handle(files) {
try { try {
// If the only change happened to the output file, then we don't want to // If the only change happened to the output file, then we don't want to
// trigger a rebuild because that will result in an infinite loop. // trigger a rebuild because that will result in an infinite loop.
@ -265,36 +346,15 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Re-compile the input // Re-compile the input
let start = process.hrtime.bigint() let start = process.hrtime.bigint()
let changedFiles: ChangedContent[] = []
let rebuildStrategy: 'incremental' | 'full' = 'incremental'
let resolvedFullRebuildPaths = fullRebuildPaths let resolvedFullRebuildPaths = fullRebuildPaths
let rebuildStrategy = getRebuildStrategy(files, resolvedFullRebuildPaths)
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)
}
// Track the compiled CSS // Track the compiled CSS
let compiledCss = '' let compiledCss = ''
let compiledMap: SourceMap | null = null let compiledMap: SourceMap | null = null
// Scan the entire `base` directory for full rebuilds. // Scan the entire `base` directory for full rebuilds.
if (rebuildStrategy === 'full') { if (rebuildStrategy.kind === 'full') {
// Read the new `input`. // Read the new `input`.
let input = args['--input'] let input = args['--input']
? args['--input'] === '-' ? args['--input'] === '-'
@ -304,11 +364,23 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
@import 'tailwindcss'; @import 'tailwindcss';
` `
clearRequireCache(resolvedFullRebuildPaths) clearRequireCache(resolvedFullRebuildPaths)
// Track current rebuild paths in case something goes wrong when
// performing a full rebuild.
backupRebuildPaths = fullRebuildPaths.slice()
// The `inputFilePath`, if provided, will be the only known full
// rebuild path before the compiler is re-created.
fullRebuildPaths = inputFilePath ? [inputFilePath] : [] fullRebuildPaths = inputFilePath ? [inputFilePath] : []
// Create a new compiler, given the new `input` // Create a new compiler, given the new `input`
;[compiler, scanner] = await createCompiler(input, I) ;[compiler, scanner] = await createCompiler(input, I)
// 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()
// Scan the directory for candidates // Scan the directory for candidates
DEBUG && I.start('Scan for candidates') DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan() let candidates = scanner.scan()
@ -316,7 +388,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Setup new watchers // Setup new watchers
DEBUG && I.start('Setup new watchers') DEBUG && I.start('Setup new watchers')
let newCleanupFunction = await createWatchers(watchDirectories(scanner), handle) let newCleanupFunction = await createWatchers(await watchDirectories(scanner), handle)
DEBUG && I.end('Setup new watchers') DEBUG && I.end('Setup new watchers')
// Clear old watchers // Clear old watchers
@ -339,16 +411,16 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
} }
// Scan changed files only for incremental rebuilds. // Scan changed files only for incremental rebuilds.
else if (rebuildStrategy === 'incremental') { else if (rebuildStrategy.kind === 'incremental') {
DEBUG && I.start('Scan for candidates') DEBUG && I.start('Scan for candidates')
let newCandidates = scanner.scanFiles(changedFiles) let newCandidates = scanner.scanFiles(rebuildStrategy.changedFiles)
DEBUG && I.end('Scan for candidates') DEBUG && I.end('Scan for candidates')
// No new candidates found which means we don't need to write to // No new candidates found which means we don't need to write to
// disk, and can return early. // disk, and can return early.
if (newCandidates.length <= 0) { if (newCandidates.length <= 0) {
let end = process.hrtime.bigint() let end = process.hrtime.bigint()
eprintln(`Done in ${formatDuration(end - start)}`) if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
return return
} }
@ -366,13 +438,48 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
await write(compiledCss, compiledMap, args, I) await write(compiledCss, compiledMap, args, I)
let end = process.hrtime.bigint() let end = process.hrtime.bigint()
eprintln(`Done in ${formatDuration(end - start)}`) if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
} catch (err) { } catch (err) {
// It's important that we perform a full rebuild when any of the
// dependencies tracked in `fullRebuildPaths` has been changed.
//
// If we remove one of those files, then a subsequent build will be
// triggered, but it will fail because the dependency is gone. The
// compiler itself will be in a broken state and won't be able to
// register any dependencies therefore we want to restore all the
// dependencies from before. If we don't do that, then we won't be
// able to recover from a bug in a transitive dependency.
//
// E.g.:
// ```css
// /* input.css — known full rebuild path */
// @import 'tailwindcss';
// @config "./tailwind.config.js";
// ```
//
// ```js
// // tailwind.config.js
// const theme = require('./my-theme.js');
//
// module.exports = {
// theme
// }
// ```
// In this case `my-theme.js` is a transitive dependency of
// `input.css` via `tailwind.config.js`. Removing `my-theme.js` will
// result in an error, restoring `my-theme.js` should trigger a
// fresh build even though the compiler didn't restore.
//
// Once the build error is fixed, a fresh full rebuild will happen
// which in turn will fixup the full rebuild paths.
fullRebuildPaths = backupRebuildPaths
// Catch any errors and print them to stderr, but don't exit the process // Catch any errors and print them to stderr, but don't exit the process
// and keep watching. // and keep watching.
if (err instanceof Error) { eprintln(formatError(err))
eprintln(err.toString())
} let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
} }
}), }),
) )
@ -410,10 +517,185 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
await write(output, map, args, I) await write(output, map, args, I)
let end = process.hrtime.bigint() let end = process.hrtime.bigint()
eprintln(`Done in ${formatDuration(end - start)}`) 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) { async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
let watcher = await loadWatcher()
// Remove any directories that are children of an already watched directory. // 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 // 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. // of whether or not they are for the directory that is duplicated.
@ -475,10 +757,16 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
await Promise.all( await Promise.all(
events.map(async (event) => { events.map(async (event) => {
// We currently don't handle deleted files because it doesn't influence // When a file is deleted, a rebuild should be triggered such that we
// the CSS output. This is because we currently keep all scanned // can figure out whether this file must trigger a fresh build or not.
// candidates in a cache for performance reasons. //
if (event.type === 'delete') return // If it must trigger a fresh build, then we will temporarily end up
// in a broken state, but an error will be shown to the user. Once the
// user resolves the issue, the CLI will recover.
if (event.type === 'delete') {
files.add(event.path)
return
}
// Ignore directory changes. We only care about file changes // Ignore directory changes. We only care about file changes
let stats: Stats | null = null let stats: Stats | null = null
@ -509,6 +797,78 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
} }
} }
function watchDirectories(scanner: Scanner) { function getRebuildStrategy(
return [...new Set(scanner.normalizedSources.flatMap((globEntry) => globEntry.base))] 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(
scanner.normalizedSources.map(async (globEntry) => {
let resolvedPath = path.resolve(globEntry.base)
let realPath = await fs.realpath(resolvedPath).catch(() => resolvedPath)
return fs
.stat(realPath)
.then((stat) => (stat.isDirectory() ? [realPath] : []))
.catch(() => [])
}),
)
).flat(1)
return Array.from(new Set(directories))
}
function dim(str: string) {
return `\x1B[2m${str}\x1B[22m`
}
function red(str: string) {
return `\x1B[31m${str}\x1B[39m`
} }

View file

@ -11,7 +11,7 @@ const sharedOptions = {
} satisfies Arg } satisfies Arg
function buildUsage(command = 'tailwindcss') { 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 } = {}) { 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) { for (let word of words) {
let wordLength = stripVTControlCharacters(word).length 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) lines.push(line)
line = '' line = ''
lineLength = 0 lineLength = 0

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/node", "name": "@tailwindcss/node",
"version": "4.2.2", "version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.", "description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
@ -19,8 +19,7 @@
], ],
"publishConfig": { "publishConfig": {
"provenance": true, "provenance": true,
"access": "public" "access": "public",
},
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@ -35,13 +34,30 @@
"types": "./dist/esm-cache.loader.d.mts", "types": "./dist/esm-cache.loader.d.mts",
"default": "./dist/esm-cache.loader.mjs" "default": "./dist/esm-cache.loader.mjs"
} }
}
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"require": "./src/index.cts"
},
"./require-cache": {
"types": "./src/require-cache.ts",
"import": "./src/require-cache.ts",
"require": "./src/require-cache.cts"
},
"./esm-cache-loader": {
"types": "./src/esm-cache.loader.mts",
"default": "./src/esm-cache.loader.mts"
}
}, },
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.5", "@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0", "enhanced-resolve": "catalog:",
"jiti": "^2.6.1", "jiti": "^2.7.0",
"lightningcss": "catalog:", "lightningcss": "catalog:",
"magic-string": "^0.30.21", "magic-string": "^1.1.0",
"source-map-js": "^1.2.1", "source-map-js": "^1.2.1",
"tailwindcss": "workspace:*" "tailwindcss": "workspace:*"
} }

View file

@ -194,7 +194,10 @@ async function importModule(path: string): Promise<any> {
} }
} }
const modules = ['node_modules', ...(process.env.NODE_PATH ? [process.env.NODE_PATH] : [])] const modules = [
'node_modules',
...(process.env.NODE_PATH ? [...process.env.NODE_PATH.split(path.delimiter)] : []),
]
const cssResolver = EnhancedResolve.ResolverFactory.createResolver({ const cssResolver = EnhancedResolve.ResolverFactory.createResolver({
fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000), fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000),

View file

@ -1,8 +1,23 @@
import { isBuiltin, type ResolveHook } from 'node:module' import {
isBuiltin,
type ResolveFnOutput,
type ResolveHook,
type ResolveHookContext,
type ResolveHookSync,
} from 'node:module'
export let resolve: ResolveHook = async (specifier, context, nextResolve) => { export let resolve: ResolveHook = async (specifier, context, nextResolve) => {
let result = await nextResolve(specifier, context) let result = await nextResolve(specifier, context)
return processResolve(context, result)
}
export let resolveSync: ResolveHookSync = (specifier, context, nextResolve) => {
let result = nextResolve(specifier, context)
return processResolve(context, result)
}
function processResolve(context: ResolveHookContext, result: ResolveFnOutput) {
try {
if (result.url === import.meta.url) return result if (result.url === import.meta.url) return result
if (isBuiltin(result.url)) return result if (isBuiltin(result.url)) return result
if (!context.parentURL) return result if (!context.parentURL) return result
@ -19,4 +34,7 @@ export let resolve: ResolveHook = async (specifier, context, nextResolve) => {
...result, ...result,
url: `${url}`, url: `${url}`,
} }
} catch {
return result
}
} }

View file

@ -1,6 +1,7 @@
import * as Module from 'node:module' import * as Module from 'node:module'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import * as env from './env' import * as env from './env'
import { resolveSync } from './esm-cache.loader.mjs'
export * from './compile' export * from './compile'
export * from './instrumentation' export * from './instrumentation'
export * from './normalize-path' export * from './normalize-path'
@ -12,8 +13,14 @@ export { env }
// not necessary. // not necessary.
if (!process.versions.bun) { if (!process.versions.bun) {
// `Module#register` was added in Node v18.19.0 and v20.6.0 // `Module#register` was added in Node v18.19.0 and v20.6.0
// `Module#registerHooks` was added in Node v22.15.0 and v23.5.0 and is the preferred API since v25.9.0,
// runtime-deprecating `Module#register` since v26
// //
// Not calling it means that while ESM dependencies don't get reloaded, the // Not calling it means that while ESM dependencies don't get reloaded, the
// actual included files will because they cache bust directly via `?id=…` // actual included files will because they cache bust directly via `?id=…`
if (Module.registerHooks) {
Module.registerHooks({ resolve: resolveSync })
} else {
Module.register?.(pathToFileURL(require.resolve('@tailwindcss/node/esm-cache-loader'))) Module.register?.(pathToFileURL(require.resolve('@tailwindcss/node/esm-cache-loader')))
} }
}

View file

@ -1,6 +1,7 @@
import * as Module from 'node:module' import * as Module from 'node:module'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import * as env from './env' import * as env from './env'
import { resolveSync } from './esm-cache.loader.mjs'
export * from './compile' export * from './compile'
export * from './instrumentation' export * from './instrumentation'
export * from './normalize-path' export * from './normalize-path'
@ -11,11 +12,16 @@ export { env }
// In Bun, ESM modules will also populate `require.cache`, so the module hook is // In Bun, ESM modules will also populate `require.cache`, so the module hook is
// not necessary. // not necessary.
if (!process.versions.bun) { if (!process.versions.bun) {
let localRequire = Module.createRequire(import.meta.url)
// `Module#register` was added in Node v18.19.0 and v20.6.0 // `Module#register` was added in Node v18.19.0 and v20.6.0
// `Module#registerHooks` was added in Node v22.15.0 and v23.5.0 and is the preferred API since v25.9.0,
// runtime-deprecating `Module#register` since v26
// //
// Not calling it means that while ESM dependencies don't get reloaded, the // Not calling it means that while ESM dependencies don't get reloaded, the
// actual included files will because they cache bust directly via `?id=…` // actual included files will because they cache bust directly via `?id=…`
if (Module.registerHooks) {
Module.registerHooks({ resolve: resolveSync })
} else {
let localRequire = Module.createRequire(import.meta.url)
Module.register?.(pathToFileURL(localRequire.resolve('@tailwindcss/node/esm-cache-loader'))) Module.register?.(pathToFileURL(localRequire.resolve('@tailwindcss/node/esm-cache-loader')))
} }
}

View file

@ -2,7 +2,7 @@ import { stripVTControlCharacters } from 'util'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import { Instrumentation } from './instrumentation' import { Instrumentation } from './instrumentation'
it('should add instrumentation', () => { it('should add instrumentation using start/end markers', () => {
let I = new Instrumentation() let I = new Instrumentation()
I.start('Foo') I.start('Foo')
@ -35,6 +35,83 @@ it('should add instrumentation', () => {
}) })
}) })
it('should measure callbacks via the `span` api', () => {
let I = new Instrumentation()
I.span('Foo', () => {
let x = 1
for (let i = 0; i < 100; i++) {
I.span('Bar', () => {
x **= 2
})
}
})
expect.assertions(1)
I.report((output) => {
expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(`
"
[0.xxms] Foo
[0.xxms] ↳ Bar × 100
"
`)
})
})
it('should measure async callbacks via the `span` api', async () => {
let I = new Instrumentation()
await I.span('Foo', async () => {
let x = 1
for (let i = 0; i < 100; i++) {
I.span('Bar', () => {
x **= 2
})
}
await new Promise((r) => setTimeout(r, 500))
})
expect.assertions(2)
I.report((output) => {
expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(`
"
[0.xxms] Foo
[0.xxms] ↳ Bar × 100
"
`)
let [, duration] = stripVTControlCharacters(output).match(/\[(.*)\]/)!
expect(parseFloat(duration)).toBeGreaterThanOrEqual(500 - 2 /* Margin of error */)
})
})
it('should measure blocks until they go out of scope via `using`', () => {
let I = new Instrumentation()
{
using _ = I.track('Foo')
let x = 1
for (let i = 0; i < 100; i++) {
using _ = I.track('Bar')
x **= 2
}
}
expect.assertions(1)
I.report((output) => {
expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(`
"
[0.xxms] Foo
[0.xxms] ↳ Bar × 100
"
`)
})
})
it('should auto end pending timers when reporting', () => { it('should auto end pending timers when reporting', () => {
let I = new Instrumentation() let I = new Instrumentation()

View file

@ -13,6 +13,7 @@ export class Instrumentation implements Disposable {
#timerStack: { id: string; label: string; namespace: string; value: bigint }[] = [] #timerStack: { id: string; label: string; namespace: string; value: bigint }[] = []
constructor( constructor(
private shouldReport = env.DEBUG,
private defaultFlush = (message: string) => void process.stderr.write(`${message}\n`), private defaultFlush = (message: string) => void process.stderr.write(`${message}\n`),
) {} ) {}
@ -48,6 +49,41 @@ export class Instrumentation implements Disposable {
this.#timers.get(parent.id).value += elapsed this.#timers.get(parent.id).value += elapsed
} }
track(label: string) {
this.start(label)
let done = false
return {
[Symbol.dispose]: () => {
if (!done) {
this.end(label)
done = true
}
},
[Symbol.asyncDispose]: () => {
if (!done) {
this.end(label)
done = true
}
},
}
}
span<T>(label: string, fn: () => T): T {
this.start(label)
let isPromise = false
try {
let result = fn()
isPromise = result && typeof (result as any).then === 'function'
// @ts-expect-error — TS can't infer that result is a Promise here
return isPromise ? result.finally(() => this.end(label)) : result
} finally {
if (!isPromise) this.end(label)
}
}
reset() { reset() {
this.#hits.clear() this.#hits.clear()
this.#timers.clear() this.#timers.clear()
@ -100,7 +136,7 @@ export class Instrumentation implements Disposable {
} }
[Symbol.dispose]() { [Symbol.dispose]() {
env.DEBUG && this.report() this.shouldReport && this.report()
} }
} }

View file

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

View file

@ -0,0 +1,5 @@
export function clearRequireCache(files: string[]) {
for (let key of files) {
delete require.cache[key]
}
}

View file

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

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