introduce v4 codebase

This commit is contained in:
Robin Malfait 2024-03-05 14:23:26 +01:00
parent 32cf8aa0fb
commit a68de1df27
No known key found for this signature in database
168 changed files with 71075 additions and 0 deletions

81
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,81 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
node-version: [20]
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: ^8.15.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v3
with:
path: |
./oxide/target/
./oxide/crates/node/*.node
./oxide/crates/node/index.js
./oxide/crates/node/index.d.ts
key: ${{ runner.os }}-oxide-${{ hashFiles('./oxide/crates/**/*') }}
- name: Install dependencies
run: pnpm install
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Build
run: pnpm run build
- name: Lint
run: pnpm run lint
- name: Test
run: pnpm run test
- name: Run Playwright tests
run: npm run test:ui
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: packages/tailwindcss/playwright-report/
retention-days: 30
- name: Bench
run: pnpm run bench

214
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,214 @@
name: Release
on:
workflow_dispatch:
inputs:
release_channel:
description: 'Release channel'
required: false
default: 'internal'
type: string
permissions:
contents: read
env:
APP_NAME: tailwindcss-oxide
NODE_VERSION: 20
PNPM_VERSION: ^8.15.0
OXIDE_LOCATION: ./oxide/crates/node
jobs:
build:
strategy:
matrix:
include:
# Windows
- os: windows-latest
target: x86_64-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.
# 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@v3
- uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v3
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@v3
with:
path: |
./oxide/target/
./oxide/crates/node/*.node
./oxide/crates/node/index.js
./oxide/crates/node/index.d.ts
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./oxide/crates/**/*') }}
- name: Install Node.JS
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup cross compile toolchain
if: ${{ matrix.setup }}
run: ${{ matrix.setup }}
- 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 build --filter ${{ env.OXIDE_LOCATION }}
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@v3
with:
name: bindings-${{ matrix.target }}
path: ${{ env.OXIDE_LOCATION }}/*.node
release:
runs-on: ubuntu-latest
timeout-minutes: 15
name: Build and release Tailwind CSS
needs:
- build
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v3
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@v3
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@v3
with:
path: |
./oxide/target/
./oxide/crates/node/*.node
./oxide/crates/node/index.js
./oxide/crates/node/index.d.ts
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./oxide/crates/**/*') }}
- name: Install dependencies
run: pnpm install --ignore-scripts --filter=!./playgrounds/*
- name: Build Tailwind CSS
run: pnpm run build
- name: Download artifacts
uses: actions/download-artifact@v3
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-x86_64-apple-darwin/* ./npm/darwin-x64/
cp bindings-aarch64-apple-darwin/* ./npm/darwin-arm64/
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/
- name: Lock pre-release versions
run: node ./scripts/lock-pre-release-versions.mjs
- name: Publish
run: pnpm --recursive publish --tag ${{ inputs.release_channel }} --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
coverage/
.turbo
test-results/
playwright-report/
blob-report/
playwright/.cache/

1
.npmrc Normal file
View File

@ -0,0 +1 @@
auto-install-peers = true

8
.prettierignore Normal file
View File

@ -0,0 +1,8 @@
coverage/
node_modules/
pnpm-lock.yaml
oxide/target/
oxide/crates/node/index.d.ts
oxide/crates/node/index.js
.next
.fingerprint

1
oxide/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
target/

1200
oxide/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

6
oxide/Cargo.toml Normal file
View File

@ -0,0 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/*"]
[profile.release]
lto = true

1
oxide/README.md Normal file
View File

@ -0,0 +1 @@
## Tailwind CSS Oxide

View File

@ -0,0 +1,29 @@
[package]
name = "tailwindcss-core"
version = "0.1.0"
edition = "2021"
[dependencies]
bstr = "1.0.1"
globwalk = "0.8.1"
log = "0.4"
rayon = "1.5.3"
fxhash = "0.2.1"
crossbeam = "0.8.2"
tracing = { version = "0.1.37", features = [] }
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
walkdir = "2.3.3"
ignore = "0.4.20"
lazy_static = "1.4.0"
[dev-dependencies]
criterion = { version = "0.3", features = ['html_reports'] }
tempfile = "3.5.0"
[[bench]]
name = "parse_candidates"
harness = false
[[bench]]
name = "scan_files"
harness = false

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
<div class="sm:items-baseline sm:space-x-4 flex">
<a href="#" target="_blank" class="block sm:hidden px-4 py-2 text-sm">
This is link afe0b95d-703b-4471-b828-7fee034e18a0
</a>
</div>

View File

@ -0,0 +1,552 @@
<div class="font-semibold px-3 text-left text-gray-900 py-3.5 text-sm">
<nav class="font-medium text-gray-900">
<ul class="h-7 justify-center rounded-full items-center w-7 flex mx-auto">
<li class="h-0.5 inset-x-0 absolute bottom-0">
<a href="#" target="_blank" class="space-y-1 px-2 mt-3">
This is link 4132f37a-a03f-4776-9a8e-1b70ff626f71
</a>
<img
class="text-gray-900 font-medium text-sm ml-3.5"
alt="Profile picture of user 40faf8f0-6221-4ec4-a65e-fe22e1d9abd2"
src="https://example.org/pictures/4f7d7f80-e9cd-447a-9b35-f9d93befe025"
/>
</li>
<li class="text-indigo-700 order-1 font-semibold">
<ol class="h-24 sm:w-32 w-24 object-center rounded-md sm:h-32 object-cover">
<li class="lg:justify-center lg:gap-x-12 hidden lg:flex lg:min-w-0 lg:flex-1">
<img
class="hover:bg-gray-100 bg-gray-50 py-1.5 focus:z-10 text-gray-400"
alt="Profile picture of user d27b5a21-1622-4f3a-ba7d-6230fae487c2"
src="https://example.org/pictures/2a026d06-0e67-467d-babf-8a18614667f2"
/>
<ul class="w-6 h-6 mr-3 flex-shrink-0">
<li class="flow-root">
<img
class="bg-white focus:ring-indigo-500 group font-medium items-center inline-flex focus:ring-offset-2 focus:ring-2 focus:outline-none text-base rounded-md hover:text-gray-900"
alt="Profile picture of user 0052230e-90d8-4d84-ab64-904b87fe4622"
src="https://example.org/pictures/f7ec91ba-17a7-470f-a472-705a8b5a79ce"
/>
</li>
<li class="items-center right-0 flex pointer-events-none absolute inset-y-0">
<img
class="ml-3"
alt="Profile picture of user a7c51adf-3917-4e41-b5f1-d263768c9adf"
src="https://example.org/pictures/a56323a8-8aa3-4d39-8c79-9ee875ecd6f0"
/>
</li>
</ul>
<ul class="hover:bg-opacity-75 hover:bg-indigo-500 text-white">
<li class="font-medium hover:text-indigo-500 text-indigo-600 text-sm">
<ul class="sr-only"></ul>
<ol class="flex-col px-8 flex pt-8"></ol>
<a href="#" class="items-center sm:items-start flex">
This is link 8c59d1ad-9ef0-4a41-ab15-e9fe04d77ae2
</a>
</li>
<li class="rounded-full w-8 h-8">
<ol class="lg:grid lg:grid-cols-12"></ol>
<a
href="#"
target="_blank"
rel="noreferrer"
class="px-4 border-t py-6 space-y-6 border-gray-200"
>
This is link 4182f198-ff54-45a4-ace9-e6f46a60ec92
</a>
</li>
<li class="border-gray-700 border-t pt-4 pb-3">
<ol class="font-medium text-gray-900 p-2 block -m-2"></ol>
<ol
class="sm:py-24 to-green-400 lg:px-0 bg-gradient-to-r lg:items-center lg:justify-end sm:px-6 lg:bg-none from-cyan-600 px-4 lg:pl-8 py-16 lg:flex"
></ol>
<img
class="lg:gap-24 lg:grid-cols-2 lg:grid lg:mx-auto lg:items-start lg:max-w-7xl lg:px-8"
alt="Profile picture of user c44d18a8-a1f1-4bf4-87b0-89f37e34aba1"
src="https://example.org/pictures/527dca2c-5afe-4a5c-96cd-706396701c36"
/>
</li>
<li class="hover:bg-gray-100 bg-white focus:z-10 text-gray-900 relative py-1.5">
<a
href="#"
target="_blank"
rel="noreferrer"
class="text-sm font-medium text-gray-500"
>
This is link f6db0c8d-6409-4abd-9af1-d3e68ebbd25c
</a>
<a href="#" rel="noreferrer" class="text-sm font-medium mt-12">
This is link 51c9b242-e449-44d8-9289-1a5d474d5fbb
</a>
<ul class="h-12"></ul>
</li>
<li class="bg-gray-100">
<a href="#" rel="noreferrer" class="bg-gray-100">
This is link eb479051-0dff-4d8f-9456-bb8b56ab90af
</a>
<img
class="ml-3 text-gray-900 font-medium text-base"
alt="Profile picture of user 17d797ac-aea7-4154-b522-f0ac89762b0b"
src="https://example.org/pictures/55e31a5b-2fcb-4b35-832c-fad711717f1a"
/>
<ul class="font-medium hover:text-gray-700 text-gray-500 ml-4 text-sm"></ul>
</li>
</ul>
<ul class="space-y-6 border-t py-6 border-gray-200 px-4">
<li class="md:hidden z-40 relative">
<ol class="h-7 w-7 mx-auto items-center justify-center rounded-full flex"></ol>
<ol class="items-center flex rounded-full w-7 mx-auto h-7 justify-center"></ol>
<a
href="#"
target="_blank"
class="sm:py-32 lg:px-8 max-w-7xl mx-auto px-4 py-24 sm:px-6 relative"
>
This is link 5014471c-4f44-4696-a1f6-7817d57827eb
</a>
<img
class="h-5 group-hover:text-gray-500 w-5 ml-2"
alt="Profile picture of user 98731873-0af8-4823-9115-f1333a2cdc2e"
src="https://example.org/pictures/12ac8cbf-4540-49ff-a71f-4b0ac684b374"
/>
</li>
<li
class="focus:outline-none font-medium px-4 justify-center text-sm focus:ring-2 border hover:bg-gray-50 focus:ring-offset-2 py-2 border-gray-300 bg-white inline-flex text-gray-700 rounded-md shadow-sm focus:ring-gray-900"
>
<img
class="bg-white focus:ring-indigo-500 text-base focus:outline-none focus:ring-2 items-center font-medium focus:ring-offset-2 hover:text-gray-900 rounded-md group inline-flex"
alt="Profile picture of user 3ae3670d-c729-41d2-adee-691340673aef"
src="https://example.org/pictures/5f289b87-efc2-4cc9-9061-21883b0778db"
/>
</li>
<li class="text-base text-gray-500 mt-6 font-medium text-center">
<ol class="text-sm font-medium text-indigo-600 hover:text-indigo-500"></ol>
<ol class="rounded-md h-6 w-6 inline-block"></ol>
</li>
<li class="hidden lg:flex lg:items-center">
<ol class="border-indigo-600"></ol>
</li>
</ul>
</li>
<li class="sr-only">
<img
class="justify-center py-2 bg-white flex"
alt="Profile picture of user 89195190-9a42-4826-89ba-e7bc1d677520"
src="https://example.org/pictures/742d9c44-1c75-461f-b2d0-e52636041966"
/>
<img
class="-ml-14 sticky left-0 z-20 text-gray-400 -mt-2.5 leading-5 pr-2 w-14 text-right text-xs"
alt="Profile picture of user 77a37b14-1c0f-4ef0-85d4-a18755065ea3"
src="https://example.org/pictures/8ad2e05f-6ea6-4d94-8f82-f8aa59274b9e"
/>
</li>
</ol>
<img
class="border-t shadow-sm sm:border bg-white sm:rounded-lg border-gray-200 border-b"
alt="Profile picture of user 07935cf2-78e5-49ba-afdb-09d13c8320d6"
src="https://example.org/pictures/8f895991-6e8c-4bcb-82c3-11c2a3338eb2"
/>
<ol class="grid-cols-2 grid gap-x-8 gap-y-10">
<li
class="py-1 w-48 shadow-lg bg-white rounded-md ring-1 absolute ring-black z-10 focus:outline-none right-0 mt-2 ring-opacity-5 origin-top-right"
>
<ol class="h-6 w-6">
<li class="bg-gray-500 inset-0 transition-opacity fixed bg-opacity-75">
<ol class="flex"></ol>
<a href="#" class="mt-2 flex items-center justify-between">
This is link 1a1a3c60-a2ea-4153-acd7-12aa82f03c8d
</a>
<a href="#" target="_blank" class="text-gray-300 flex-shrink-0 w-5 h-5">
This is link bdaa695c-3fe4-4cab-8d68-dbb338601044
</a>
</li>
<li class="text-gray-500">
<ol class="py-20"></ol>
<a href="#" target="_blank" class="text-sm ml-3">
This is link 2183c71d-39ec-42d4-8b1e-eeb7e5490050
</a>
</li>
<li class="mt-10">
<ol class="group"></ol>
<a href="#" rel="noreferrer" class="bg-gray-50">
This is link c49882fd-ab55-41a4-8dac-b96fe2901bce
</a>
<ol class="bg-white h-[940px] overflow-y-auto"></ol>
</li>
<li class="flex-1 space-y-1">
<ol class="h-6 w-6"></ol>
</li>
</ol>
<ul class="z-10 flex relative items-center lg:hidden">
<li class="aspect-w-1 bg-gray-100 rounded-lg overflow-hidden aspect-h-1">
<img
class="overflow-hidden sm:rounded-md bg-white shadow"
alt="Profile picture of user 263bb89c-5b54-4247-8c82-fec829b1a895"
src="https://example.org/pictures/4c12a5c4-d117-4f81-93e1-47a45626a36e"
/>
<a href="#" class="sr-only"> This is link fc3885d8-d63f-4455-b056-f113aa3a2f23 </a>
<ol
class="border-t grid-cols-1 border-gray-200 gap-6 border-b mt-6 sm:grid-cols-2 grid py-6"
></ol>
</li>
<li class="space-x-3 items-center flex">
<a href="#" class="py-2 bg-white">
This is link 1d718cb1-05e3-4d14-b959-92f5fd475ce0
</a>
<img
class="rounded-md w-full focus:border-indigo-500 border-gray-300 focus:ring-indigo-500 block mt-1 shadow-sm sm:text-sm"
alt="Profile picture of user 1c4d6dc3-700a-4167-8ec3-3dc2f73d4ad5"
src="https://example.org/pictures/359599b3-5610-482e-bc09-025ac5283170"
/>
<ul class="max-w-3xl mx-auto divide-y-2 divide-gray-200"></ul>
<ul class="flex space-x-4"></ul>
</li>
<li class="text-gray-500 hover:text-gray-600">
<ul class="h-96 w-full relative lg:hidden"></ul>
<ol class="text-gray-500 mt-6 text-sm"></ol>
<a href="#" class="sm:col-span-6">
This is link 51cc68af-1184-4f8c-9efd-d2f855902b0b
</a>
<ol
class="left-0 inset-y-0 absolute pointer-events-none pl-3 items-center flex"
></ol>
</li>
<li class="flex-shrink-0">
<ol
class="shadow-lg rounded-lg ring-1 bg-white ring-black ring-opacity-5 divide-gray-50 divide-y-2"
></ol>
</li>
</ul>
<img
class="pl-3 sm:pr-6 py-3.5 relative pr-4"
alt="Profile picture of user 095f88c2-1892-41d1-8165-981ab47b1942"
src="https://example.org/pictures/c70d575b-353a-4360-99df-c2100a36e41b"
/>
</li>
<li class="whitespace-nowrap">
<a href="#" target="_blank" rel="noreferrer" class="h-full">
This is link c804eb7a-39ea-46e6-a79a-2d48e61d5b4e
</a>
</li>
<li class="text-gray-900 text-2xl font-bold tracking-tight">
<img
class="text-gray-900 font-medium"
alt="Profile picture of user 12190673-25cb-4175-90d7-73282e51bd02"
src="https://example.org/pictures/e55a076b-0325-4629-8e02-c9491f2f49cc"
/>
</li>
<li
class="ring-black sm:-mx-6 overflow-hidden ring-1 ring-opacity-5 mt-8 md:rounded-lg md:mx-0 -mx-4 shadow"
>
<ol class="flex items-center font-medium hover:text-gray-800 text-sm text-gray-700">
<li class="flex mt-8 flex-col">
<ol class="sm:inline hidden"></ol>
<ul class="flex items-center absolute inset-0"></ul>
<ol class="h-6 w-6"></ol>
</li>
<li class="-ml-2 rounded-md text-gray-400 p-2 bg-white">
<img
class="block ml-3 font-medium text-sm text-gray-700"
alt="Profile picture of user 2cee83e8-6405-4046-9454-7f6083db307d"
src="https://example.org/pictures/93097140-2e56-4a3f-bc41-e00c658b7767"
/>
<ul class="sm:grid font-medium hidden grid-cols-4 text-gray-600 mt-6 text-sm"></ul>
<ul class="h-64 w-64 rounded-full xl:h-80 xl:w-80"></ul>
<ul class="sr-only"></ul>
</li>
</ol>
<img
class="aspect-w-2 group sm:aspect-w-1 aspect-h-1 overflow-hidden sm:aspect-h-1 sm:row-span-2 rounded-lg"
alt="Profile picture of user bc370a72-a44e-44e1-bbec-962c234060da"
src="https://example.org/pictures/d284630d-a088-49ad-a018-b245a8d7acb7"
/>
</li>
<li class="space-y-6 mt-6">
<ul class="w-5 text-gray-400 h-5">
<li
class="hover:bg-gray-100 py-1.5 bg-gray-50 text-gray-400 focus:z-10 rounded-tl-lg"
>
<img
class="bg-gray-50 py-1.5 hover:bg-gray-100 focus:z-10 text-gray-400"
alt="Profile picture of user 4b298841-5911-4b70-ae5a-a5aa8646e575"
src="https://example.org/pictures/fd05d4f7-2b6c-4de4-b7a7-3035e7b5fe78"
/>
<img
class="px-3 py-2 bg-white relative"
alt="Profile picture of user 5406aa7d-5563-4ce1-980f-754a912cd9ff"
src="https://example.org/pictures/e979117e-580c-40a7-be74-bf1b916b9ac0"
/>
<a
href="#"
target="_blank"
rel="noreferrer"
class="mt-2 font-medium text-gray-900 text-lg"
>
This is link 95d285c9-6e4e-43c2-a97f-bb958dcce92f
</a>
<ul class="sr-only"></ul>
</li>
<li class="mt-2 text-sm text-gray-500">
<a href="#" target="_blank" class="text-sm text-blue-gray-900 font-medium block">
This is link e0ca5c13-efa5-44d7-b91e-512df7c88410
</a>
<img
class="w-5 h-5"
alt="Profile picture of user 4f5aeddc-6718-40fd-87d2-dcca7e4ef8b1"
src="https://example.org/pictures/6a2ed606-6c59-413c-911f-612c7390091f"
/>
<ol class="font-medium text-gray-900"></ol>
<ol class="justify-center rounded-full h-7 items-center mx-auto w-7 flex"></ol>
</li>
<li class="font-medium px-1 whitespace-nowrap py-4 text-sm border-b-2">
<a
href="#"
target="_blank"
class="min-h-80 rounded-md aspect-h-1 aspect-w-1 lg:aspect-none w-full group-hover:opacity-75 lg:h-80 overflow-hidden bg-gray-200"
>
This is link 4f52e535-2e46-44e2-8a5f-fce48c1a673a
</a>
<ul class="text-gray-300 h-full w-full"></ul>
<img
class="block"
alt="Profile picture of user 76dd6af3-d2b7-4a7a-8bc3-628ce95ea9b3"
src="https://example.org/pictures/c6b476c8-283c-44f7-a5c6-4cb48c5ae10b"
/>
<ol class="h-32 relative lg:hidden w-full"></ol>
</li>
<li class="bg-gray-100 z-10 sticky sm:pt-3 pl-1 pt-1 md:hidden sm:pl-3 top-0">
<ol class="z-40 relative lg:hidden"></ol>
<a href="#" class="sr-only"> This is link ec11a608-55b8-41fd-8085-325252c469af </a>
<ol class="divide-gray-200 lg:col-span-9 divide-y"></ol>
</li>
</ul>
<ul class="text-xl font-semibold ml-1">
<li class="lg:flex-1 lg:w-0">
<ol class="sm:hidden"></ol>
<img
class="block py-2 text-blue-gray-900 text-base hover:bg-blue-gray-50 font-medium px-3 rounded-md"
alt="Profile picture of user 885eb4a3-98ca-4614-b255-ec03e124c026"
src="https://example.org/pictures/6d3a9136-583e-4742-826f-e6d94f60ce99"
/>
</li>
<li class="truncate w-0 ml-2 flex-1">
<img
class="hover:text-gray-600 text-gray-500"
alt="Profile picture of user de38ffb5-607b-4ed6-87bd-dd05fde1731c"
src="https://example.org/pictures/da67f442-40c9-425d-9344-2f9772582519"
/>
<ol class="text-center mt-8 text-gray-400 text-base"></ol>
</li>
</ul>
</li>
</ol>
</li>
<li class="lg:block hidden lg:flex-1">
<a href="#" target="_blank" class="text-base ml-3 text-gray-500">
This is link 9a44892f-7ead-48b6-af94-913ec04821a1
</a>
<ol
class="shadow-sm border-gray-300 focus:ring-indigo-500 sm:text-sm focus:border-indigo-500 w-full rounded-md block"
>
<li class="bg-white hover:bg-gray-100 py-1.5 focus:z-10">
<a
href="#"
target="_blank"
class="bg-gray-200 text-gray-700 gap-px lg:flex-none border-b text-center grid-cols-7 grid text-xs leading-6 border-gray-300 font-semibold"
>
This is link 42a16c66-508d-4d65-bb1a-b252f7e78df2
</a>
<a href="#" class="h-8 w-auto"> This is link 021bda37-522f-413d-af3c-4a8c4f4d666a </a>
<img
class="max-h-12"
alt="Profile picture of user a1a859bc-ef5e-4349-99ab-a8f4ae262d94"
src="https://example.org/pictures/da93a022-cbbf-4ba0-b34c-21c090d87d74"
/>
<a
href="#"
target="_blank"
rel="noreferrer"
class="flex relative justify-center text-sm"
>
This is link fa5aded2-2906-4809-b742-26832b226f50
</a>
</li>
<li class="py-12 sm:px-6 lg:py-16 px-4 lg:px-8 mx-auto max-w-7xl">
<a href="#" rel="noreferrer" class="min-w-0 ml-3 flex-1">
This is link 715e397d-5676-42fc-9d8f-456172543c31
</a>
<img
class="bg-gray-800"
alt="Profile picture of user c02575ab-6ff1-45f9-a8e3-242c79b133fc"
src="https://example.org/pictures/3d8ecc7a-2504-4797-a644-f93d74acf853"
/>
<img
class="text-base text-gray-900 font-medium"
alt="Profile picture of user 9f457701-be79-4ff2-9dfc-9382dafb20ab"
src="https://example.org/pictures/438b713b-c9fd-4da2-a265-4b8d8f531e30"
/>
<a href="#" rel="noreferrer" class="text-sm">
This is link 4c84098a-c0ac-4044-bbba-6f10bcc315fb
</a>
</li>
<li class="w-6 flex-shrink-0 h-6 text-green-500">
<img
class="mx-auto sm:px-6 px-4 max-w-7xl"
alt="Profile picture of user 00ae83b8-845d-447e-ae1b-cd3c685e1ca0"
src="https://example.org/pictures/b9deba2b-c5b3-4b80-bf76-e0a717d310fd"
/>
<ul class="w-72">
<li class="sm:flex sm:justify-between sm:items-center">
<img
class="block font-medium text-sm text-gray-700"
alt="Profile picture of user c0de8cb0-e9d7-4639-8c3d-851ca17e665b"
src="https://example.org/pictures/897193ff-aa53-4e9e-b761-bc4f75aef572"
/>
</li>
<li class="text-sm hidden font-medium ml-3 text-gray-700 lg:block">
<img
class="h-8 w-auto"
alt="Profile picture of user 9ff9c8ac-2374-4960-9996-ec258745c91a"
src="https://example.org/pictures/e898638f-08fa-4743-aea7-66adba84bded"
/>
</li>
<li
class="mx-auto sm:px-6 px-4 lg:items-center lg:flex lg:py-16 lg:px-8 max-w-7xl py-12"
>
<img
class="lg:max-w-none px-4 max-w-2xl mx-auto lg:px-0"
alt="Profile picture of user 7c4d617d-afa2-4410-81c5-2def540d2d20"
src="https://example.org/pictures/05a7dbc1-c1cc-4f99-99e5-7b507a4108b3"
/>
<ul class="hover:text-gray-600 text-gray-500"></ul>
<ul
class="relative rounded-md border-transparent focus-within:ring-2 focus-within:ring-white -ml-2 group"
></ul>
</li>
</ul>
<img
class="h-5 text-gray-300 w-5"
alt="Profile picture of user bf2c6905-715a-4e38-9b5d-17fd8e7aec2a"
src="https://example.org/pictures/e759a4d7-5e63-4075-ba32-c6574107f401"
/>
</li>
<li class="object-cover object-center h-full w-full">
<ol class="w-full">
<li class="md:mt-0 absolute sm:-mt-32 -mt-72 inset-0">
<ul class="w-12 h-12 rounded-full"></ul>
</li>
<li class="h-1.5 rounded-full w-1.5 mb-1 mx-0.5 bg-gray-400">
<ol class="border-gray-200 border-4 rounded-lg border-dashed h-96"></ol>
<img
class="order-1 font-semibold text-gray-700"
alt="Profile picture of user 1c2cabee-08a3-4b48-ba54-5a578b2c3d30"
src="https://example.org/pictures/f5c185be-8b9d-490d-81f4-73a6e1517d98"
/>
<img
class="font-bold sm:text-4xl text-gray-900 tracking-tight text-3xl leading-8 text-center"
alt="Profile picture of user 1e8a2508-a37d-4f6b-9a8c-c6fcf0773604"
src="https://example.org/pictures/90af1bd2-30ed-45d3-917f-c685190ce56e"
/>
</li>
<li class="space-x-2 mt-4 text-sm text-gray-700 flex">
<ul
class="rounded-full translate-x-1/2 block transform border-2 absolute bottom-0 right-0 border-white translate-y-1/2"
></ul>
<ol
class="sm:hidden text-base py-2 text-gray-900 w-full placeholder-gray-500 h-full focus:outline-none border-transparent pr-3 pl-8 focus:placeholder-gray-400 focus:ring-0 focus:border-transparent"
></ol>
</li>
</ol>
<ul class="lg:mt-0 self-center flow-root mt-8">
<li
class="justify-center rounded-full bg-transparent bg-white hover:text-gray-500 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 text-gray-400 inline-flex focus:outline-none h-8 items-center w-8"
>
<ol class="block xl:inline"></ol>
<ol class="flex-shrink-0 ml-4"></ol>
<ol class="text-gray-200"></ol>
<img
class="border-gray-300 focus:relative md:w-9 rounded-r-md flex bg-white md:hover:bg-gray-50 pl-4 text-gray-400 border hover:text-gray-500 items-center pr-3 border-l-0 justify-center md:px-2 py-2"
alt="Profile picture of user b0a1e2d3-84c4-494b-91d6-194fc294b0db"
src="https://example.org/pictures/2f414511-756c-40ef-aded-22e3f4d985d7"
/>
</li>
<li class="inset-0 absolute">
<img
class="text-gray-500 text-base font-medium text-center"
alt="Profile picture of user 431f88eb-5002-43ab-b23a-37ae0ef7d424"
src="https://example.org/pictures/bc7c19bb-4ef2-46ff-b00e-da70febab926"
/>
<img
class="inset-0 absolute z-10"
alt="Profile picture of user 4a3698d0-7cea-4ba2-854d-28b2bb2d374b"
src="https://example.org/pictures/6078c4cd-db51-43af-90b3-8aeb0a8f1030"
/>
</li>
</ul>
<a href="#" rel="noreferrer" class="bg-white">
This is link ab01c689-e03a-4992-8322-37c20551cb07
</a>
</li>
<li class="flex px-4 pb-2 pt-5">
<ol
class="sm:px-6 px-4 bg-white relative pb-8 md:p-6 shadow-2xl items-center flex w-full sm:pt-8 overflow-hidden lg:p-8 pt-14"
>
<li class="py-1.5 hover:bg-gray-100 focus:z-10 text-gray-400 bg-gray-50">
<a
href="#"
rel="noreferrer"
class="sm:text-sm bg-gray-50 items-center border-gray-300 border-r-0 rounded-l-md text-gray-500 inline-flex border px-3"
>
This is link 3ef75acd-3dfc-4e82-801b-eae9ff7c4351
</a>
<ol class="absolute border-dashed border-gray-200 border-2 rounded-lg inset-0"></ol>
</li>
<li class="hover:bg-gray-50 block">
<ol class="items-center flex justify-center p-8"></ol>
<ul class="mx-auto sm:px-6 lg:px-8 pb-12 max-w-7xl px-4"></ul>
<img
class="h-6 w-6 text-green-400"
alt="Profile picture of user f5900afb-7bee-4492-b6e3-148f0afc4f5f"
src="https://example.org/pictures/1b12c3fb-9f84-4cc7-84a7-d38f7ea232ee"
/>
</li>
<li class="flex mt-4 lg:flex-grow-0 flex-grow lg:ml-4 flex-shrink-0 ml-8">
<img
class="focus:ring-indigo-500 block w-full sm:text-sm border-gray-300 focus:border-indigo-500 rounded-md shadow-sm"
alt="Profile picture of user c9dd7fa0-c1f4-477c-b24e-87d200ebb161"
src="https://example.org/pictures/1a3b2e5c-a192-47f3-a550-e18f715213a2"
/>
<a href="#" target="_blank" rel="noreferrer" class="text-gray-300 hover:text-white">
This is link 81b819f3-b2e8-41db-ab8d-abe8c6926047
</a>
</li>
</ol>
<img
class="lg:flex-1 lg:block hidden"
alt="Profile picture of user 352e9ea2-0216-4a0c-917c-1906f5ef4ed1"
src="https://example.org/pictures/ec02985c-b92d-4978-906e-7bdc70bfa54e"
/>
</li>
</ol>
</li>
<li class="sr-only">
<a href="#" target="_blank" class="text-gray-500 mt-4 text-sm">
This is link a712361b-f51e-4f0a-9d55-b64a5d53e10e
</a>
<a
href="#"
rel="noreferrer"
class="rounded-lg ring-opacity-5 shadow-lg overflow-hidden ring-1 ring-black"
>
This is link 8eb412d6-1c39-4fed-b507-cf2a65904247
</a>
</li>
</ul>
</nav>
<img
class="bg-gray-100"
alt="Profile picture of user 8825a6f0-3a41-44b8-92ec-abcd0af79bfb"
src="https://example.org/pictures/679e2a54-073e-416a-85c4-3eba0626aab3"
/>
<span class="bg-white focus:z-10 py-1.5 hover:bg-gray-100">
This is text 3d190171-53b3-4393-99ab-9bedfc964141
</span>
</div>

View File

@ -0,0 +1,6 @@
<div class="md:absolute items-center flex lg:hidden md:inset-y-0 md:right-0">
<input
type="password"
class="text-pink-800 py-0.5 bg-pink-100 font-medium text-sm items-center px-3 inline-flex rounded-full"
/>
</div>

View File

@ -0,0 +1,6 @@
<div class="text-gray-200">
<span class="lg:hidden z-40 relative"> This is text cd32f5df-af14-4332-9fd3-0e5589f8d457 </span>
<a href="#" target="_blank" rel="noreferrer" class="border-gray-200 border-t">
This is link 05289860-74df-49a7-91dd-1a3a0d215bc3
</a>
</div>

View File

@ -0,0 +1,5 @@
<div class="italic text-sm text-gray-500">
<span class="relative text-left inline-block">
This is text bec24672-f081-47d1-8130-cc06506974b5
</span>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tailwindcss_core::parser::Extractor;
pub fn criterion_benchmark(c: &mut Criterion) {
fn parse(input: &[u8]) {
// _ = Extractor::all(black_box(input), ExtractorOptions { preserve_spaces_in_arbitrary: false });
Extractor::unique(black_box(input), Default::default());
}
c.bench_function("scan_files (simple)", |b| b.iter(|| parse(b"underline")));
c.bench_function("scan_files (with variant)", |b| {
b.iter(|| parse(b"hover:underline"))
});
c.bench_function("scan_files (with stacked variants)", |b| {
b.iter(|| parse(b"focus:hover:underline"))
});
c.bench_function("scan_files (with arbitrary values)", |b| {
b.iter(|| parse(b"p-[20px]"))
});
c.bench_function("scan_files (with variant and arbitrary values)", |b| {
b.iter(|| parse(b"hover:p-[20px]"))
});
c.bench_function("scan_files (real world)", |b| {
b.iter(|| parse(include_bytes!("./fixtures/template-000.html")))
});
let mut group = c.benchmark_group("sample-size-example");
group.sample_size(10);
group.bench_function("scan_files (fast space skipping)", |b| {
let count = 10_000;
let crazy1 = format!("{}underline", " ".repeat(count));
let crazy2 = crazy1.repeat(count);
let crazy3 = crazy2.as_bytes();
b.iter(|| parse(black_box(crazy3)))
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

View File

@ -0,0 +1,63 @@
use criterion::{criterion_group, criterion_main, Criterion};
use std::path::PathBuf;
use tailwindcss_core::{scan_files, ChangedContent, Parsing, IO};
pub fn criterion_benchmark(c: &mut Criterion) {
// current_dir will be set to ./crates/core
let fixtures_path = std::env::current_dir()
.unwrap()
.join("benches")
.join("fixtures");
let mut all_files: Vec<(u64, PathBuf)> = std::fs::read_dir(fixtures_path)
.unwrap()
.filter_map(Result::ok)
.map(|dir_entry| dir_entry.path())
.filter(|path| path.is_file())
.filter(|path| match path.extension() {
Some(ext) => ext == "html",
_ => false,
})
.map(|path| (path.metadata().unwrap().len(), path))
.collect();
// Let's sort them first so that we are working with the same files in the same order every
// time.
all_files.sort_by(|a, b| a.0.cmp(&b.0));
// Let's work with the first middle X items (so that we can skip outliers and work with more
// interesting files)
let amount = 300;
let mut files: Vec<_> = all_files
.iter()
.skip((all_files.len() - amount) / 2) // Skip the first X, so that we can use the middle
// {amount} files.
.take(amount)
.map(|(_, path)| path)
.collect();
// Two (or more) files can technically have the exact same size, but the order is random, so
// now that we are scoped to the middle X files, let's sort these alphabetically to guarantee
// the same order in our benchmarks.
files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
let changed_content: Vec<ChangedContent> = files
.into_iter()
.map(|file| ChangedContent {
file: Some(file.to_path_buf()),
content: None,
})
.collect();
c.bench_function("scan_files", |b| {
b.iter(|| {
scan_files(
changed_content.clone(),
Parsing::Parallel as u8 | IO::Parallel as u8,
)
})
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

4
oxide/crates/core/fuzz/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
target
corpus
artifacts
coverage

531
oxide/crates/core/fuzz/Cargo.lock generated Normal file
View File

@ -0,0 +1,531 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0"
dependencies = [
"memchr",
]
[[package]]
name = "arbitrary"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29d47fbf90d5149a107494b15a7dc8d69b351be2db3bb9691740e88ec17fd880"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bstr"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc"
dependencies = [
"memchr",
"regex-automata 0.4.5",
"serde",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.0.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581f5dba903aac52ea3feb5ec4810848460ee833876f1f9b0fdeab1f19091574"
dependencies = [
"jobserver",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "crossbeam"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
dependencies = [
"crossbeam-channel",
"crossbeam-deque",
"crossbeam-epoch",
"crossbeam-queue",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "176dc175b78f56c0f321911d9c8eb2b77a78a4860b9c19db83835fea1a46649b"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
[[package]]
name = "either"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "globset"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata 0.4.5",
"regex-syntax 0.8.2",
]
[[package]]
name = "globwalk"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc"
dependencies = [
"bitflags",
"ignore",
"walkdir",
]
[[package]]
name = "ignore"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata 0.4.5",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "jobserver"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "068b1ee6743e4d11fb9c6a1e6064b3693a1b600e7f5f5988047d98b3dc9fb90b"
dependencies = [
"libc",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.137"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89"
[[package]]
name = "libfuzzer-sys"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fff891139ee62800da71b7fd5b508d570b9ad95e614a53c6f453ca08366038"
dependencies = [
"arbitrary",
"cc",
"once_cell",
]
[[package]]
name = "log"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
[[package]]
name = "matchers"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
dependencies = [
"regex-automata 0.1.10",
]
[[package]]
name = "memchr"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
dependencies = [
"overload",
"winapi",
]
[[package]]
name = "once_cell"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860"
[[package]]
name = "overload"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "pin-project-lite"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
[[package]]
name = "proc-macro2"
version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7237101a77a10773db45d62004a272517633fbcc3df19d96455ede1122e051"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a"
dependencies = [
"regex-syntax 0.6.28",
]
[[package]]
name = "regex-automata"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
dependencies = [
"regex-syntax 0.6.28",
]
[[package]]
name = "regex-automata"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax 0.8.2",
]
[[package]]
name = "regex-syntax"
version = "0.6.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848"
[[package]]
name = "regex-syntax"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.196"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.196"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "smallvec"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7"
[[package]]
name = "syn"
version = "2.0.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tailwindcss-core"
version = "0.1.0"
dependencies = [
"bstr",
"crossbeam",
"fxhash",
"globwalk",
"ignore",
"lazy_static",
"log",
"rayon",
"tracing",
"tracing-subscriber",
"walkdir",
]
[[package]]
name = "tailwindcss-core-fuzz"
version = "0.0.0"
dependencies = [
"libfuzzer-sys",
"tailwindcss-core",
]
[[package]]
name = "thread_local"
version = "1.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152"
dependencies = [
"cfg-if",
"once_cell",
]
[[package]]
name = "tracing"
version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "valuable"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "walkdir"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

View File

@ -0,0 +1,27 @@
[package]
name = "tailwindcss-core-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
[dependencies.tailwindcss-core]
path = ".."
# Prevent this from interfering with workspaces
[workspace]
members = ["."]
[profile.release]
debug = 1
[[bin]]
name = "parsing"
path = "fuzz_targets/parsing.rs"
test = false
doc = false

View File

@ -0,0 +1,31 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use std::path::PathBuf;
use tailwindcss_core::candidate::scan_files;
use tailwindcss_core::candidate::Candidate;
use tailwindcss_core::location::Location;
// fuzz_target!(|data: &[u8]| {
// if let Ok(s) = std::str::from_utf8(data) {
// let _ = parse_candidate_strings(s, false);
// }
// });
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let _ = scan_files(s, false)
.into_iter()
.map(|(c, _)| {
Candidate::new(
c,
Location {
file: PathBuf::new(),
start: (0, 1),
end: (0, 1),
},
)
})
.collect::<Vec<_>>();
}
});

View File

@ -0,0 +1,57 @@
use std::{path::PathBuf, time::SystemTime};
use std::fs::{self};
use fxhash::{FxHashMap, FxHashSet};
/// A cache to manage the list of candidates and the last modified time of files
/// in the project. This is used to avoid recompiling files that haven't changed.
#[derive(Default)]
pub struct Cache {
mtimes: FxHashMap<PathBuf, SystemTime>,
candidates: FxHashSet<String>,
}
impl Cache {
pub fn clear(&mut self) {
self.mtimes.clear();
self.candidates.clear();
}
pub fn add_candidates(&mut self, additional_candidates: Vec<String>) {
self.candidates.extend(additional_candidates);
}
pub fn get_candidates(&self) -> Vec<String> {
let mut result = vec![];
result.extend(self.candidates.iter().cloned());
result.sort();
result
}
pub fn find_modified_files<'a>(&mut self, paths: &'a Vec<PathBuf>) -> Vec<&'a PathBuf> {
// Get a list of the files that have been modified since the last time we checked
let mut modified: Vec<&PathBuf> = vec![];
for path in paths {
let curr = fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::now());
let prev = self.mtimes.insert(path.clone(), curr);
match prev {
// Only add the file to the modified list if the mod time has changed
Some(prev) if prev != curr => {
modified.push(path);
},
// If the file was already in the cache then we don't need to do anything
Some(_) => (),
// If the file didn't exist before then it's been modified
None => modified.push(path),
}
}
modified
}
}

View File

@ -0,0 +1,159 @@
use std::{ascii::escape_default, fmt::Display};
#[derive(Debug, Clone)]
pub struct Cursor<'a> {
// The input we're scanning
pub input: &'a [u8],
// The location of the cursor in the input
pub pos: usize,
/// Is the cursor at the start of the input
pub at_start: bool,
/// Is the cursor at the end of the input
pub at_end: bool,
/// The previously consumed character
/// If `at_start` is true, this will be NUL
pub prev: u8,
/// The current character
pub curr: u8,
/// The upcoming character (if any)
/// If `at_end` is true, this will be NUL
pub next: u8,
}
impl<'a> Cursor<'a> {
pub fn new(input: &'a [u8]) -> Self {
let mut cursor = Self {
input,
pos: 0,
at_start: true,
at_end: false,
prev: 0x00,
curr: 0x00,
next: 0x00,
};
cursor.move_to(0);
cursor
}
pub fn rewind_by(&mut self, amount: usize) {
self.move_to(self.pos.saturating_sub(amount));
}
pub fn advance_by(&mut self, amount: usize) {
self.move_to(self.pos.saturating_add(amount));
}
pub fn move_to(&mut self, pos: usize) {
let len = self.input.len();
let pos = pos.clamp(0, len);
self.pos = pos;
self.at_start = pos == 0;
self.at_end = pos + 1 >= len;
self.prev = if pos > 0 { self.input[pos - 1] } else { 0x00 };
self.curr = if pos < len { self.input[pos] } else { 0x00 };
self.next = if pos + 1 < len {
self.input[pos + 1]
} else {
0x00
};
}
}
impl<'a> Display for Cursor<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let len = self.input.len().to_string();
let pos = format!("{: >len_count$}", self.pos, len_count = len.len());
write!(f, "{}/{} ", pos, len)?;
if self.at_start {
write!(f, "S ")?;
} else if self.at_end {
write!(f, "E ")?;
} else {
write!(f, "M ")?;
}
fn to_str(c: u8) -> String {
if c == 0x00 {
"NUL".into()
} else {
format!("{:?}", escape_default(c).to_string())
}
}
write!(
f,
"[{} {} {}]",
to_str(self.prev),
to_str(self.curr),
to_str(self.next)
)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_cursor() {
let mut cursor = Cursor::new(b"hello world");
assert_eq!(cursor.pos, 0);
assert!(cursor.at_start);
assert!(!cursor.at_end);
assert_eq!(cursor.prev, 0x00);
assert_eq!(cursor.curr, b'h');
assert_eq!(cursor.next, b'e');
cursor.advance_by(1);
assert_eq!(cursor.pos, 1);
assert!(!cursor.at_start);
assert!(!cursor.at_end);
assert_eq!(cursor.prev, b'h');
assert_eq!(cursor.curr, b'e');
assert_eq!(cursor.next, b'l');
// Advancing too far should stop at the end
cursor.advance_by(10);
assert_eq!(cursor.pos, 11);
assert!(!cursor.at_start);
assert!(cursor.at_end);
assert_eq!(cursor.prev, b'd');
assert_eq!(cursor.curr, 0x00);
assert_eq!(cursor.next, 0x00);
// Can't advance past the end
cursor.advance_by(1);
assert_eq!(cursor.pos, 11);
assert!(!cursor.at_start);
assert!(cursor.at_end);
assert_eq!(cursor.prev, b'd');
assert_eq!(cursor.curr, 0x00);
assert_eq!(cursor.next, 0x00);
cursor.rewind_by(1);
assert_eq!(cursor.pos, 10);
assert!(!cursor.at_start);
assert!(cursor.at_end);
assert_eq!(cursor.prev, b'l');
assert_eq!(cursor.curr, b'd');
assert_eq!(cursor.next, 0x00);
cursor.rewind_by(10);
assert_eq!(cursor.pos, 0);
assert!(cursor.at_start);
assert!(!cursor.at_end);
assert_eq!(cursor.prev, 0x00);
assert_eq!(cursor.curr, b'h');
assert_eq!(cursor.next, b'e');
}
}

View File

@ -0,0 +1,89 @@
use crate::cursor::Cursor;
const STRIDE: usize = 16;
type Mask = [bool; STRIDE];
#[inline(always)]
pub fn fast_skip(cursor: &Cursor) -> Option<usize> {
// If we don't have enough bytes left to check then bail early
if cursor.pos + STRIDE >= cursor.input.len() {
return None;
}
if !cursor.curr.is_ascii_whitespace() {
return None;
}
let mut offset = 1;
// SAFETY: We've already checked (indirectly) that this index is valid
let remaining = unsafe { cursor.input.get_unchecked(cursor.pos..) };
// NOTE: This loop uses primitives designed to be auto-vectorized
// Do not change this loop without benchmarking the results
// And checking the generated assembly using godbolt.org
for (i, chunk) in remaining.chunks_exact(STRIDE).enumerate() {
let value = load(chunk);
let is_whitespace = is_ascii_whitespace(value);
let is_all_whitespace = all_true(is_whitespace);
if is_all_whitespace {
offset = (i + 1) * STRIDE;
} else {
break;
}
}
Some(cursor.pos + offset)
}
#[inline(always)]
fn load(input: &[u8]) -> [u8; STRIDE] {
let mut value = [0u8; STRIDE];
value.copy_from_slice(input);
value
}
#[inline(always)]
fn eq(input: [u8; STRIDE], val: u8) -> Mask {
let mut res = [false; STRIDE];
for n in 0..STRIDE {
res[n] = input[n] == val
}
res
}
#[inline(always)]
fn or(a: [bool; STRIDE], b: [bool; STRIDE]) -> [bool; STRIDE] {
let mut res = [false; STRIDE];
for n in 0..STRIDE {
res[n] = a[n] | b[n];
}
res
}
#[inline(always)]
fn all_true(a: [bool; STRIDE]) -> bool {
let mut res = true;
for item in a.iter().take(STRIDE) {
res &= item;
}
res
}
#[inline(always)]
fn is_ascii_whitespace(value: [u8; STRIDE]) -> [bool; STRIDE] {
let whitespace_1 = eq(value, b'\t');
let whitespace_2 = eq(value, b'\n');
let whitespace_3 = eq(value, b'\x0C');
let whitespace_4 = eq(value, b'\r');
let whitespace_5 = eq(value, b' ');
or(
or(
or(or(whitespace_1, whitespace_2), whitespace_3),
whitespace_4,
),
whitespace_5,
)
}

View File

@ -0,0 +1,264 @@
3dm
3ds
3g2
3gp
7z
DS_Store
a
aac
adp
ai
aif
aiff
alz
ape
apk
appimage
ar
arj
asf
au
avi
avif
bak
baml
bh
bin
bk
bmp
btif
bz2
bzip2
cab
caf
cgm
class
cmx
cpio
cr2
cur
dat
dcm
deb
dex
djvu
dll
dmg
dng
doc
docm
docx
dot
dotm
dra
dsk
dts
dtshd
dvb
dwg
dxf
ecelp4800
ecelp7470
ecelp9600
egg
eol
eot
epub
exe
f4v
fbs
fh
fla
flac
flatpak
fli
flv
fpx
fst
fvt
g3
gh
gif
graffle
gz
gzip
h261
h263
h264
icns
ico
ief
img
ipa
iso
jar
jpeg
jpg
jpgv
jpm
jxr
key
ktx
lha
lib
lockb
lvp
lz
lzh
lzma
lzo
m3u
m4a
m4v
mar
mdi
mht
mid
midi
mj2
mka
mkv
mmr
mng
mobi
mov
movie
mp3
mp4
mp4a
mpeg
mpg
mpga
mxu
nef
npx
numbers
nupkg
o
odp
ods
odt
oga
ogg
ogv
otf
ott
pages
pbm
pcx
pdb
pdf
pea
pgm
pic
png
pnm
pot
potm
potx
ppa
ppam
ppm
pps
ppsm
ppsx
ppt
pptm
pptx
psd
pya
pyc
pyo
pyv
qt
rar
ras
raw
resources
rgb
rip
rlc
rmf
rmvb
rpm
rtf
rz
s3m
s7z
scpt
sgi
shar
sil
sketch
slk
smv
snap
snk
so
sqlite
sqlite
sqlite3
sqlite3
stl
sub
suo
swf
tar
tbz
tbz2
tga
tgz
thmx
tif
tiff
tlz
ttc
ttf
txz
udf
uvh
uvi
uvm
uvp
uvs
uvu
viv
vob
war
wav
wax
wbmp
wdp
weba
webm
webp
whl
wim
wm
wma
wmv
wmx
woff
woff2
wrm
wvx
xbm
xif
xla
xlam
xls
xlsb
xlsm
xlsx
xlt
xltm
xltx
xm
xmind
xpi
xpm
xwd
xz
z
zip
zipx

View File

@ -0,0 +1,6 @@
css
less
lock
sass
scss
styl

View File

@ -0,0 +1,3 @@
package-lock.json
pnpm-lock.yaml
bun.lockb

View File

@ -0,0 +1,57 @@
# HTML
html
pug
# JS
astro
cjs
cts
jade
js
jsx
mjs
mts
svelte
ts
tsx
vue
# Markdown
md
mdx
# ASP
aspx
razor
# Handlebars
handlebars
hbs
mustache
# PHP
php
twig
# Ruby
erb
haml
liquid
rb
rhtml
slim
# Elixir / Phoenix
eex
heex
# Nunjucks
njk
nunjucks
# Python
py
tpl
# Rust
rs

View File

@ -0,0 +1,456 @@
use std::iter;
use std::path::{Path, PathBuf};
pub fn fast_glob(
base_path: &Path,
patterns: &Vec<String>,
) -> Result<impl iter::Iterator<Item = PathBuf>, std::io::Error> {
Ok(get_fast_patterns(base_path, patterns)
.into_iter()
.flat_map(|(base_path, patterns)| {
globwalk::GlobWalkerBuilder::from_patterns(base_path, &patterns)
.follow_links(true)
.build()
.unwrap()
.filter_map(Result::ok)
.map(|file| file.path().to_path_buf())
}))
}
/// This function attempts to optimize the glob patterns to improve performance. The problem is
/// that if you run the following command:
/// ```sh
/// tailwind --pwd ./project --content "{pages,components}/**/*.js"
/// ```
/// Then the globwalk library will scan every single file and folder in the `./project` folder,
/// then it will check if the file matches the glob pattern and keep it if it does. This is very
/// slow, because if you have vendor folders (like node_modules), then this will take a while...
///
/// Instead, we will optimize the pattern, and move as many directories as possible to the base
/// path. This will allow us to scope the globwalk library to only scan the directories that we
/// care about.
///
/// This means, that the following command:
/// ```sh
/// tailwind --pwd ./project --content "{pages,components}/**/*.js"
/// ```
///
/// Will now conceptually do this instead behind the scenes:
/// ```sh
/// tailwind --pwd ./project/pages --content "**/*.js"
/// tailwind --pwd ./project/components --content "**/*.js"
/// ```
fn get_fast_patterns(base_path: &Path, patterns: &Vec<String>) -> Vec<(PathBuf, Vec<String>)> {
let mut optimized_patterns: Vec<(PathBuf, Vec<String>)> = vec![];
for pattern in patterns {
let is_negated = pattern.starts_with('!');
let mut pattern = pattern.clone();
if is_negated {
pattern.remove(0);
}
let mut folders = pattern.split('/').collect::<Vec<_>>();
if folders.len() <= 1 {
// No paths we can simplify, so let's use it as-is.
optimized_patterns.push((base_path.to_path_buf(), vec![pattern]));
} else {
// We do have folders because `/` exists. Let's try to simplify the globs!
// Safety: We know that the length is greater than 1, so we can safely unwrap.
let file_pattern = folders.pop().unwrap();
let all_folders = folders.clone();
let mut temp_paths = vec![base_path.to_path_buf()];
let mut bail = false;
for (i, folder) in folders.into_iter().enumerate() {
// There is a wildcard in the folder, so we have to bail now... 😢 But this also
// means that we can skip looking at the rest of the folders, so there is at least
// this small optimization we can apply!
if folder.contains('*') {
// Get all the remaining folders, attach the existing file_pattern so that this
// can now be the final pattern we use.
let mut remaining_folders = all_folders[i..].to_vec();
remaining_folders.push(file_pattern);
let pattern = remaining_folders.join("/");
for path in &temp_paths {
optimized_patterns.push((path.to_path_buf(), vec![pattern.to_string()]));
}
bail = true;
break;
}
// The folder is very likely using an expandable pattern which we can expand!
if folder.contains('{') && folder.contains('}') {
let branches = expand_braces(folder);
let existing_paths = temp_paths;
temp_paths = branches
.iter()
.flat_map(|branch| {
existing_paths
.clone()
.into_iter()
.map(|path| path.join(branch))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
}
// The folder should just be a simple folder name without any glob magic. We should
// be able to safely add it to the existing paths.
else {
temp_paths = temp_paths
.into_iter()
.map(|path| path.join(folder))
.collect();
}
}
// As long as we didn't bail, we can now add the current expanded patterns to the
// optimized patterns.
if !bail {
for path in &temp_paths {
optimized_patterns.push((path.to_path_buf(), vec![file_pattern.to_string()]));
}
}
}
// Ensure that we re-add all the `!` signs to the patterns.
if is_negated {
for (_, patterns) in &mut optimized_patterns {
for pattern in patterns {
pattern.insert(0, '!');
}
}
}
}
optimized_patterns
}
/// Given this input: a-{b,c}-d-{e,f}
/// We will get:
/// [
/// a-b-d-e
/// a-b-d-f
/// a-c-d-e
/// a-c-d-f
/// ]
/// TODO: There is probably a way nicer way of doing this, but this works for now.
fn expand_braces(input: &str) -> Vec<String> {
let mut result: Vec<String> = vec![];
let mut in_braces = false;
let mut last_char: char = '\0';
let mut current = String::new();
// Given the input: a-{b,c}-d-{e,f}-g
// The template will look like this: ["a-", "-d-", "g"].
let mut template: Vec<String> = vec![];
// The branches will look like this: [["b", "c"], ["e", "f"]].
let mut branches: Vec<Vec<String>> = vec![];
for (i, c) in input.char_indices() {
let is_escaped = i > 0 && last_char == '\\';
last_char = c;
match c {
'{' if !is_escaped => {
// Ensure that when a new set of braces is opened, that we at least have 1
// template.
if template.is_empty() {
template.push(String::new());
}
in_braces = true;
branches.push(vec![]);
template.push(String::new());
}
'}' if !is_escaped => {
in_braces = false;
if let Some(last) = branches.last_mut() {
last.push(current.clone());
}
current.clear();
}
',' if !is_escaped && in_braces => {
if let Some(last) = branches.last_mut() {
last.push(current.clone());
}
current.clear();
}
_ if in_braces => current.push(c),
_ => {
if template.is_empty() {
template.push(String::new());
}
if let Some(last) = template.last_mut() {
last.push(c);
}
}
};
}
// Ensure we have a string that we can start adding information too.
if !template.is_empty() && !branches.is_empty() {
result.push("".to_string());
}
// Let's try to generate everything!
for (i, template) in template.into_iter().enumerate() {
// Append current template string to all existing results.
result = result.into_iter().map(|x| x + &template).collect();
// Get the results, and copy it for every single branch.
if let Some(branches) = branches.get(i) {
result = branches
.iter()
.flat_map(|branch| {
result
.clone()
.into_iter()
.map(|x| x + branch)
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
}
}
result
}
#[cfg(test)]
mod tests {
use super::get_fast_patterns;
use std::path::PathBuf;
#[test]
fn it_should_keep_globs_that_start_with_file_wildcards_as_is() {
let actual = get_fast_patterns(&PathBuf::from("/projects"), &vec!["*.html".to_string()]);
let expected = vec![(PathBuf::from("/projects"), vec!["*.html".to_string()])];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_keep_globs_that_start_with_folder_wildcards_as_is() {
let actual = get_fast_patterns(&PathBuf::from("/projects"), &vec!["**/*.html".to_string()]);
let expected = vec![(PathBuf::from("/projects"), vec!["**/*.html".to_string()])];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_move_the_starting_folder_to_the_path() {
let actual = get_fast_patterns(
&PathBuf::from("/projects"),
&vec!["example/*.html".to_string()],
);
let expected = vec![(
PathBuf::from("/projects/example"),
vec!["*.html".to_string()],
)];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_move_the_starting_folders_to_the_path() {
let actual = get_fast_patterns(
&PathBuf::from("/projects"),
&vec!["example/other/*.html".to_string()],
);
let expected = vec![(
PathBuf::from("/projects/example/other"),
vec!["*.html".to_string()],
)];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_branch_expandable_folders() {
let actual = get_fast_patterns(
&PathBuf::from("/projects"),
&vec!["{foo,bar}/*.html".to_string()],
);
let expected = vec![
(PathBuf::from("/projects/foo"), vec!["*.html".to_string()]),
(PathBuf::from("/projects/bar"), vec!["*.html".to_string()]),
];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_expand_multiple_expansions_in_the_same_folder() {
let actual = get_fast_patterns(
&PathBuf::from("/projects"),
&vec!["a-{b,c}-d-{e,f}-g/*.html".to_string()],
);
let expected = vec![
(
PathBuf::from("/projects/a-b-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-b-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-d-f-g"),
vec!["*.html".to_string()],
),
];
assert_eq!(actual, expected,);
}
#[test]
fn multiple_expansions_per_folder_starting_at_the_root() {
let actual = get_fast_patterns(
&PathBuf::from("/projects"),
&vec!["{a,b}-c-{d,e}-f/{b,c}-d-{e,f}-g/*.html".to_string()],
);
let expected = vec![
(
PathBuf::from("/projects/a-c-d-f/b-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-d-f/b-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-e-f/b-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-e-f/b-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-d-f/c-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-d-f/c-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-e-f/c-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-e-f/c-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-d-f/b-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-d-f/b-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-e-f/b-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-e-f/b-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-d-f/c-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-d-f/c-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-e-f/c-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-e-f/c-d-f-g"),
vec!["*.html".to_string()],
),
];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_stop_expanding_once_we_hit_a_wildcard() {
let actual = get_fast_patterns(
&PathBuf::from("/projects"),
&vec!["{foo,bar}/example/**/{baz,qux}/*.html".to_string()],
);
let expected = vec![
(
PathBuf::from("/projects/foo/example"),
vec!["**/{baz,qux}/*.html".to_string()],
),
(
PathBuf::from("/projects/bar/example"),
vec!["**/{baz,qux}/*.html".to_string()],
),
];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_keep_the_negation_symbol_for_all_new_patterns() {
let actual = get_fast_patterns(
&PathBuf::from("/projects"),
&vec!["!{foo,bar}/*.html".to_string()],
);
let expected = vec![
(PathBuf::from("/projects/foo"), vec!["!*.html".to_string()]),
(PathBuf::from("/projects/bar"), vec!["!*.html".to_string()]),
];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_expand_a_complex_example() {
let actual = get_fast_patterns(
&PathBuf::from("/projects"),
&vec!["a/{b,c}/d/{e,f}/g/*.html".to_string()],
);
let expected = vec![
(
PathBuf::from("/projects/a/b/d/e/g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a/c/d/e/g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a/b/d/f/g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a/c/d/f/g"),
vec!["*.html".to_string()],
),
];
assert_eq!(actual, expected,);
}
}

View File

@ -0,0 +1,489 @@
use crate::parser::Extractor;
use cache::Cache;
use fxhash::FxHashSet;
use ignore::DirEntry;
use ignore::WalkBuilder;
use lazy_static::lazy_static;
use rayon::prelude::*;
use std::cmp::Ordering;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Mutex;
use tracing::event;
use walkdir::WalkDir;
pub mod cache;
pub mod cursor;
pub mod fast_skip;
pub mod glob;
pub mod parser;
fn init_tracing() {
if !*SHOULD_TRACE {
return;
}
_ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE)
.compact()
.try_init();
}
#[derive(Debug, Clone)]
pub struct ChangedContent {
pub file: Option<PathBuf>,
pub content: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ScanOptions {
pub base: String,
pub globs: bool,
}
#[derive(Debug, Clone)]
pub struct ScanResult {
pub candidates: Vec<String>,
pub files: Vec<String>,
pub globs: Vec<GlobEntry>,
}
#[derive(Debug, Clone)]
pub struct GlobEntry {
pub base: String,
pub glob: String,
}
pub fn clear_cache() {
let mut cache = GLOBAL_CACHE.lock().unwrap();
cache.clear();
}
pub fn scan_dir(opts: ScanOptions) -> ScanResult {
init_tracing();
let root = Path::new(&opts.base);
let (files, dirs) = resolve_files(root);
let globs = if opts.globs {
resolve_globs(root, dirs)
} else {
vec![]
};
let mut cache = GLOBAL_CACHE.lock().unwrap();
let modified_files = cache.find_modified_files(&files);
let files = files.iter().map(|x| x.display().to_string()).collect();
if !modified_files.is_empty() {
let content: Vec<_> = modified_files
.into_iter()
.map(|file| ChangedContent {
file: Some(file.clone()),
content: None,
})
.collect();
let candidates = scan_files(content, IO::Parallel as u8 | Parsing::Parallel as u8);
cache.add_candidates(candidates);
}
ScanResult {
candidates: cache.get_candidates(),
files,
globs,
}
}
#[tracing::instrument(skip(root))]
fn resolve_globs(root: &Path, dirs: Vec<PathBuf>) -> Vec<GlobEntry> {
let allowed_paths = FxHashSet::from_iter(dirs);
// A list of directory names where we can't use globs, but we should track each file
// individually instead. This is because these directories are often used for both source and
// destination files.
let mut forced_static_directories = vec![root.join("public")];
// A list of known extensions + a list of extensions we found in the project.
let mut found_extensions = FxHashSet::from_iter(
include_str!("fixtures/template-extensions.txt")
.trim()
.lines()
.filter(|x| !x.starts_with('#')) // Drop commented lines
.filter(|x| !x.is_empty()) // Drop empty lines
.map(|x| x.to_string()),
);
// All root directories.
let mut root_directories = FxHashSet::from_iter(vec![root.to_path_buf()]);
// All directories where we can safely use deeply nested globs to watch all files.
// In other comments we refer to these as "deep glob directories" or similar.
//
// E.g.: `./src/**/*.{html,js}`
let mut deep_globable_directories: FxHashSet<PathBuf> = FxHashSet::default();
// All directories where we can only use shallow globs to watch all direct files but not
// folders.
// In other comments we refer to these as "shallow glob directories" or similar.
//
// E.g.: `./src/*/*.{html,js}`
let mut shallow_globable_directories: FxHashSet<PathBuf> = FxHashSet::default();
// Collect all valid paths from the root. This will already filter out ignored files, unknown
// extensions and binary files.
let mut it = WalkDir::new(root)
// Sorting to make sure that we always see the directories before the files. Also sorting
// alphabetically by default.
.sort_by(
|a, z| match (a.file_type().is_dir(), z.file_type().is_dir()) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => a.file_name().cmp(z.file_name()),
},
)
.into_iter();
loop {
// We are only interested in valid entries
let entry = match it.next() {
Some(Ok(entry)) => entry,
_ => break,
};
// Ignore known directories that we don't want to traverse into.
if entry.file_type().is_dir() && entry.file_name() == ".git" {
it.skip_current_dir();
continue;
}
if entry.file_type().is_dir() {
// If we are in a directory where we know that we can't use any globs, then we have to
// track each file individually.
if forced_static_directories.contains(&entry.path().to_path_buf()) {
forced_static_directories.push(entry.path().to_path_buf());
root_directories.insert(entry.path().to_path_buf());
continue;
}
// If we are in a directory where the parent is a forced static directory, then this
// will become a forced static directory as well.
if forced_static_directories.contains(&entry.path().parent().unwrap().to_path_buf()) {
forced_static_directories.push(entry.path().to_path_buf());
root_directories.insert(entry.path().to_path_buf());
continue;
}
// If we are in a directory, and the directory is git ignored, then we don't have to
// descent into the directory. However, we have to make sure that we mark the _parent_
// directory as a shallow glob directory because using deep globs from any of the
// parent directories will include this ignored directory which should not be the case.
//
// Another important part is that if one of the ignored directories is a deep glob
// directory, then all of its parents (until the root) should be marked as shallow glob
// directories as well.
if !allowed_paths.contains(&entry.path().to_path_buf()) {
let mut parent = entry.path().parent();
while let Some(parent_path) = parent {
// If the parent is already marked as a valid deep glob directory, then we have
// to mark it as a shallow glob directory instead, because we won't be able to
// use deep globs for this directory anymore.
if deep_globable_directories.contains(parent_path) {
deep_globable_directories.remove(parent_path);
shallow_globable_directories.insert(parent_path.to_path_buf());
}
// If we reached the root, then we can stop.
if parent_path == root {
break;
}
// Mark the parent directory as a shallow glob directory and continue with its
// parent.
shallow_globable_directories.insert(parent_path.to_path_buf());
parent = parent_path.parent();
}
it.skip_current_dir();
continue;
}
// If we are in a directory that is not git ignored, then we can mark this directory as
// a valid deep glob directory. This is only necessary if any of its parents aren't
// marked as deep glob directories already.
let mut found_deep_glob_parent = false;
let mut parent = entry.path().parent();
while let Some(parent_path) = parent {
// If we reached the root, then we can stop.
if parent_path == root {
break;
}
// If the parent is already marked as a deep glob directory, then we can stop
// because this glob will match the current directory already.
if deep_globable_directories.contains(parent_path) {
found_deep_glob_parent = true;
break;
}
parent = parent_path.parent();
}
// If we didn't find a deep glob directory parent, then we can mark this directory as a
// deep glob directory (unless it is the root).
if !found_deep_glob_parent && entry.path() != root {
deep_globable_directories.insert(entry.path().to_path_buf());
}
}
// Handle allowed content paths
if is_allowed_content_path(entry.path())
&& allowed_paths.contains(&entry.path().to_path_buf())
{
let path = entry.path();
// Collect the extension for future use when building globs.
if let Some(extension) = path.extension().and_then(|x| x.to_str()) {
found_extensions.insert(extension.to_string());
}
}
}
let extension_list = found_extensions.into_iter().collect::<Vec<_>>().join(",");
// Build the globs for all globable directories.
let shallow_globs = shallow_globable_directories.iter().map(|path| GlobEntry {
base: path.display().to_string(),
glob: format!("*/*.{{{}}}", extension_list),
});
let deep_globs = deep_globable_directories.iter().map(|path| GlobEntry {
base: path.display().to_string(),
glob: format!("**/*.{{{}}}", extension_list),
});
shallow_globs.chain(deep_globs).collect::<Vec<_>>()
}
#[tracing::instrument(skip(root))]
fn resolve_files(root: &Path) -> (Vec<PathBuf>, Vec<PathBuf>) {
let mut files: Vec<PathBuf> = vec![];
let mut dirs: Vec<PathBuf> = vec![];
for entry in resolve_allowed_paths(root) {
let Some(file_type) = entry.file_type() else {
continue;
};
if file_type.is_file() {
files.push(entry.into_path());
} else if file_type.is_dir() {
dirs.push(entry.into_path());
}
}
(files, dirs)
}
#[tracing::instrument(skip(root))]
pub fn resolve_allowed_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
WalkBuilder::new(root)
.hidden(false)
.filter_entry(|entry| match entry.file_type() {
Some(file_type) if file_type.is_dir() => match entry.file_name().to_str() {
Some(dir) => !IGNORED_CONTENT_DIRS.contains(&dir),
None => false,
},
Some(file_type) if file_type.is_file() || file_type.is_symlink() => {
is_allowed_content_path(entry.path())
}
_ => false,
})
.build()
.filter_map(Result::ok)
}
lazy_static! {
static ref BINARY_EXTENSIONS: Vec<&'static str> =
include_str!("fixtures/binary-extensions.txt")
.trim()
.lines()
.collect::<Vec<_>>();
static ref IGNORED_EXTENSIONS: Vec<&'static str> =
include_str!("fixtures/ignored-extensions.txt")
.trim()
.lines()
.collect::<Vec<_>>();
static ref IGNORED_FILES: Vec<&'static str> = include_str!("fixtures/ignored-files.txt")
.trim()
.lines()
.collect::<Vec<_>>();
static ref IGNORED_CONTENT_DIRS: Vec<&'static str> = vec![".git"];
static ref SHOULD_TRACE: bool = {
matches!(std::env::var("DEBUG"), Ok(value) if value.eq("*") || value.eq("1") || value.eq("true") || value.contains("tailwind"))
};
/// Track file modification times and cache candidates. This cache lives for the lifetime of
/// the process and simply adds candidates when files are modified. Since candidates aren't
/// removed, incremental builds may contain extra candidates.
static ref GLOBAL_CACHE: Mutex<Cache> = {
Mutex::new(Cache::default())
};
}
pub fn is_allowed_content_path(path: &Path) -> bool {
let path = PathBuf::from(path);
// Skip known ignored files
if path
.file_name()
.unwrap()
.to_str()
.map(|s| IGNORED_FILES.contains(&s))
.unwrap_or(false)
{
return false;
}
// Skip known ignored extensions
path.extension()
.map(|s| s.to_str().unwrap_or_default())
.map(|ext| !IGNORED_EXTENSIONS.contains(&ext) && !BINARY_EXTENSIONS.contains(&ext))
.unwrap_or(false)
}
#[derive(Debug)]
pub enum IO {
Sequential = 0b0001,
Parallel = 0b0010,
}
impl From<u8> for IO {
fn from(item: u8) -> Self {
match item & 0b0011 {
0b0001 => IO::Sequential,
0b0010 => IO::Parallel,
_ => unimplemented!("Unknown 'IO' strategy"),
}
}
}
#[derive(Debug)]
pub enum Parsing {
Sequential = 0b0100,
Parallel = 0b1000,
}
impl From<u8> for Parsing {
fn from(item: u8) -> Self {
match item & 0b1100 {
0b0100 => Parsing::Sequential,
0b1000 => Parsing::Parallel,
_ => unimplemented!("Unknown 'Parsing' strategy"),
}
}
}
#[tracing::instrument(skip(input, options))]
pub fn scan_files(input: Vec<ChangedContent>, options: u8) -> Vec<String> {
match (IO::from(options), Parsing::from(options)) {
(IO::Sequential, Parsing::Sequential) => parse_all_blobs_sync(read_all_files_sync(input)),
(IO::Sequential, Parsing::Parallel) => parse_all_blobs(read_all_files_sync(input)),
(IO::Parallel, Parsing::Sequential) => parse_all_blobs_sync(read_all_files(input)),
(IO::Parallel, Parsing::Parallel) => parse_all_blobs(read_all_files(input)),
}
}
#[tracing::instrument(skip(changed_content))]
fn read_all_files(changed_content: Vec<ChangedContent>) -> Vec<Vec<u8>> {
event!(
tracing::Level::INFO,
"Reading {:?} file(s)",
changed_content.len()
);
changed_content
.into_par_iter()
.map(|c| match (c.file, c.content) {
(Some(file), None) => match std::fs::read(file) {
Ok(content) => content,
Err(e) => {
event!(tracing::Level::ERROR, "Failed to read file: {:?}", e);
Default::default()
}
},
(None, Some(content)) => content.into_bytes(),
_ => Default::default(),
})
.collect()
}
#[tracing::instrument(skip(changed_content))]
fn read_all_files_sync(changed_content: Vec<ChangedContent>) -> Vec<Vec<u8>> {
event!(
tracing::Level::INFO,
"Reading {:?} file(s)",
changed_content.len()
);
changed_content
.into_iter()
.map(|c| match (c.file, c.content) {
(Some(file), None) => std::fs::read(file).unwrap(),
(None, Some(content)) => content.into_bytes(),
_ => Default::default(),
})
.collect()
}
#[tracing::instrument(skip(blobs))]
fn parse_all_blobs(blobs: Vec<Vec<u8>>) -> Vec<String> {
let input: Vec<_> = blobs.iter().map(|blob| &blob[..]).collect();
let input = &input[..];
let mut result: Vec<String> = input
.par_iter()
.map(|input| Extractor::unique(input, Default::default()))
.reduce(Default::default, |mut a, b| {
a.extend(b);
a
})
.into_iter()
.map(|s| {
// SAFETY: When we parsed the candidates, we already guaranteed that the byte slices
// are valid, therefore we don't have to re-check here when we want to convert it back
// to a string.
unsafe { String::from_utf8_unchecked(s.to_vec()) }
})
.collect();
result.sort();
result
}
#[tracing::instrument(skip(blobs))]
fn parse_all_blobs_sync(blobs: Vec<Vec<u8>>) -> Vec<String> {
let input: Vec<_> = blobs.iter().map(|blob| &blob[..]).collect();
let input = &input[..];
let mut result: Vec<String> = input
.iter()
.map(|input| Extractor::unique(input, Default::default()))
.fold(FxHashSet::default(), |mut a, b| {
a.extend(b);
a
})
.into_iter()
.map(|s| {
// SAFETY: When we parsed the candidates, we already guaranteed that the byte slices
// are valid, therefore we don't have to re-check here when we want to convert it back
// to a string.
unsafe { String::from_utf8_unchecked(s.to_vec()) }
})
.collect();
result.sort();
result
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,305 @@
#[cfg(test)]
mod auto_content {
use std::fs;
use std::process::Command;
use tailwindcss_core::*;
use tempfile::tempdir;
fn scan(paths_with_content: &[(&str, Option<&str>)]) -> (Vec<String>, Vec<String>) {
// Create a temporary working directory
let dir = tempdir().unwrap().into_path();
// Initialize this directory as a git repository
let _ = Command::new("git").arg("init").current_dir(&dir).output();
// Create the necessary files
for (path, contents) in paths_with_content {
let path = dir.join(path);
let parent = path.parent().unwrap();
if !parent.exists() {
fs::create_dir_all(parent).unwrap();
}
match contents {
Some(contents) => fs::write(path, contents).unwrap(),
None => fs::write(path, "").unwrap(),
}
}
let base = format!("{}", dir.display());
// Resolve all content paths for the (temporary) current working directory
let result = scan_dir(ScanOptions {
base: base.clone(),
globs: true,
});
let mut paths: Vec<_> = result
.files
.into_iter()
.map(|x| x.replace(&format!("{}/", &base), ""))
.collect();
for glob in result.globs {
paths.push(format!("{}/{}", glob.base, glob.glob));
}
paths = paths
.into_iter()
.map(|x| {
let parent_dir = format!("{}/", &base.to_string());
x.replace(&parent_dir, "")
})
.collect();
// Sort the output for easier comparison (depending on internal datastructure the order
// _could_ be random)
paths.sort();
(paths, result.candidates)
}
fn test(paths_with_content: &[(&str, Option<&str>)]) -> Vec<String> {
scan(paths_with_content).0
}
#[test]
fn it_should_work_with_a_set_of_root_files() {
let globs = test(&[
("index.html", None),
("a.html", None),
("b.html", None),
("c.html", None),
]);
assert_eq!(globs, vec!["a.html", "b.html", "c.html", "index.html"]);
}
#[test]
fn it_should_work_with_a_set_of_root_files_and_ignore_ignored_files() {
let globs = test(&[
(".gitignore", Some("b.html")),
("index.html", None),
("a.html", None),
("b.html", None),
("c.html", None),
]);
assert_eq!(globs, vec!["a.html", "c.html", "index.html"]);
}
#[test]
fn it_should_list_all_files_in_the_public_folder_explicitly() {
let globs = test(&[
("index.html", None),
("public/a.html", None),
("public/b.html", None),
("public/c.html", None),
]);
assert_eq!(
globs,
vec![
"index.html",
"public/a.html",
"public/b.html",
"public/c.html",
]
);
}
#[test]
fn it_should_list_nested_folders_explicitly_in_the_public_folder() {
let globs = test(&[
("index.html", None),
("public/a.html", None),
("public/b.html", None),
("public/c.html", None),
("public/nested/a.html", None),
("public/nested/b.html", None),
("public/nested/c.html", None),
("public/nested/again/a.html", None),
("public/very/deeply/nested/a.html", None),
]);
assert_eq!(
globs,
vec![
"index.html",
"public/a.html",
"public/b.html",
"public/c.html",
"public/nested/a.html",
"public/nested/again/a.html",
"public/nested/b.html",
"public/nested/c.html",
"public/very/deeply/nested/a.html",
]
);
}
#[test]
fn it_should_list_all_files_in_the_public_folder_explicitly_except_ignored_files() {
let globs = test(&[
(".gitignore", Some("public/b.html\na.html")),
("index.html", None),
("public/a.html", None),
("public/b.html", None),
("public/c.html", None),
]);
assert_eq!(globs, vec!["index.html", "public/c.html",]);
}
#[test]
fn it_should_use_a_glob_for_top_level_folders() {
let globs = test(&[
("index.html", None),
("src/a.html", None),
("src/b.html", None),
("src/c.html", None),
]);
assert_eq!(globs, vec![
"index.html",
"src/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}",
"src/a.html",
"src/b.html",
"src/c.html"
]);
}
#[test]
fn it_should_ignore_binary_files() {
let globs = test(&[
("index.html", None),
("a.mp4", None),
("b.png", None),
("c.lock", None),
]);
assert_eq!(globs, vec!["index.html"]);
}
#[test]
fn it_should_ignore_known_extensions() {
let globs = test(&[
("index.html", None),
("a.css", None),
("b.sass", None),
("c.less", None),
]);
assert_eq!(globs, vec!["index.html"]);
}
#[test]
fn it_should_ignore_known_files() {
let globs = test(&[
("index.html", None),
("package-lock.json", None),
("yarn.lock", None),
]);
assert_eq!(globs, vec!["index.html"]);
}
#[test]
fn it_should_ignore_and_expand_nested_ignored_folders() {
let globs = test(&[
// Explicitly listed root files
("foo.html", None),
("bar.html", None),
("baz.html", None),
// Nested folder A, using glob
("nested-a/foo.html", None),
("nested-a/bar.html", None),
("nested-a/baz.html", None),
// Nested folder B, with deeply nested files, using glob
("nested-b/deeply-nested/foo.html", None),
("nested-b/deeply-nested/bar.html", None),
("nested-b/deeply-nested/baz.html", None),
// Nested folder C, with ignored sub-folder
("nested-c/foo.html", None),
("nested-c/bar.html", None),
("nested-c/baz.html", None),
// Ignored folder
("nested-c/.gitignore", Some("ignored-folder/")),
("nested-c/ignored-folder/foo.html", None),
("nested-c/ignored-folder/bar.html", None),
("nested-c/ignored-folder/baz.html", None),
// Deeply nested, without issues
("nested-c/sibling-folder/foo.html", None),
("nested-c/sibling-folder/bar.html", None),
("nested-c/sibling-folder/baz.html", None),
// Nested folder D, with deeply nested ignored folder
("nested-d/foo.html", None),
("nested-d/bar.html", None),
("nested-d/baz.html", None),
("nested-d/.gitignore", Some("deep/")),
("nested-d/very/deeply/nested/deep/foo.html", None),
("nested-d/very/deeply/nested/deep/bar.html", None),
("nested-d/very/deeply/nested/deep/baz.html", None),
("nested-d/very/deeply/nested/foo.html", None),
("nested-d/very/deeply/nested/bar.html", None),
("nested-d/very/deeply/nested/baz.html", None),
("nested-d/very/deeply/nested/directory/foo.html", None),
("nested-d/very/deeply/nested/directory/bar.html", None),
("nested-d/very/deeply/nested/directory/baz.html", None),
("nested-d/very/deeply/nested/directory/again/foo.html", None),
]);
assert_eq!(
globs,
vec![
"bar.html",
"baz.html",
"foo.html",
"nested-a/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}",
"nested-a/bar.html",
"nested-a/baz.html",
"nested-a/foo.html",
"nested-b/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}",
"nested-b/deeply-nested/bar.html",
"nested-b/deeply-nested/baz.html",
"nested-b/deeply-nested/foo.html",
"nested-c/*/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}",
"nested-c/bar.html",
"nested-c/baz.html",
"nested-c/foo.html",
"nested-c/sibling-folder/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}",
"nested-c/sibling-folder/bar.html",
"nested-c/sibling-folder/baz.html",
"nested-c/sibling-folder/foo.html",
"nested-d/*/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}",
"nested-d/bar.html",
"nested-d/baz.html",
"nested-d/foo.html",
"nested-d/very/*/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}",
"nested-d/very/deeply/*/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}",
"nested-d/very/deeply/nested/*/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}",
"nested-d/very/deeply/nested/bar.html",
"nested-d/very/deeply/nested/baz.html",
"nested-d/very/deeply/nested/directory/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}",
"nested-d/very/deeply/nested/directory/again/foo.html",
"nested-d/very/deeply/nested/directory/bar.html",
"nested-d/very/deeply/nested/directory/baz.html",
"nested-d/very/deeply/nested/directory/foo.html",
"nested-d/very/deeply/nested/foo.html"
]
);
}
#[test]
fn it_should_scan_for_utilities() {
let mut ignores = String::new();
ignores.push_str("# md:font-bold\n");
ignores.push_str("foo.html\n");
let candidates = scan(&[
// The gitignore file is used to filter out files but not scanned for candidates
(".gitignore", Some(&ignores)),
// A file that should definitely be scanned
("index.html", Some("font-bold md:flex")),
// A file that should definitely not be scanned
("foo.jpg", Some("xl:font-bold")),
// A file that is ignored
("foo.html", Some("lg:font-bold")),
])
.1;
assert_eq!(candidates, vec!["font-bold", "md:flex"]);
}
}

View File

@ -0,0 +1,7 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
[target.aarch64-unknown-linux-musl]
linker = "aarch64-linux-musl-gcc"
rustflags = ["-C", "target-feature=-crt-static"]
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"

201
oxide/crates/node/.gitignore vendored Normal file
View File

@ -0,0 +1,201 @@
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# End of https://www.toptal.com/developers/gitignore/api/node
# Created by https://www.toptal.com/developers/gitignore/api/macos
# Edit at https://www.toptal.com/developers/gitignore?templates=macos
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### macOS Patch ###
# iCloud generated files
*.icloud
# End of https://www.toptal.com/developers/gitignore/api/macos
# Created by https://www.toptal.com/developers/gitignore/api/windows
# Edit at https://www.toptal.com/developers/gitignore?templates=windows
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows
#Added by cargo
/target
Cargo.lock
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
*.node
# Generated
index.d.ts
index.js

View File

@ -0,0 +1,13 @@
target
Cargo.lock
.cargo
.github
npm
.eslintrc
.prettierignore
rustfmt.toml
yarn.lock
*.node
.yarn
__test__
renovate.json

View File

@ -0,0 +1,18 @@
[package]
edition = "2021"
name = "tailwind-oxide"
version = "0.0.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
napi = { version = "2.13.1", default-features = false, features = ["napi4"] }
napi-derive = "2.13.0"
tailwindcss-core = { path = "../core" }
rayon = "1.5.3"
[build-dependencies]
napi-build = "2.0.1"

View File

@ -0,0 +1,5 @@
extern crate napi_build;
fn main() {
napi_build::setup();
}

View File

@ -0,0 +1,3 @@
# `@tailwindcss/oxide-darwin-arm64`
This is the **aarch64-apple-darwin** binary for `@tailwindcss/oxide`

View File

@ -0,0 +1,18 @@
{
"name": "@tailwindcss/oxide-darwin-arm64",
"version": "0.0.0-oxide.4",
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"main": "tailwindcss-oxide.darwin-arm64.node",
"files": [
"tailwindcss-oxide.darwin-arm64.node"
],
"license": "MIT",
"engines": {
"node": ">= 10"
}
}

View File

@ -0,0 +1,3 @@
# `@tailwindcss/oxide-darwin-x64`
This is the **x86_64-apple-darwin** binary for `@tailwindcss/oxide`

View File

@ -0,0 +1,18 @@
{
"name": "@tailwindcss/oxide-darwin-x64",
"version": "0.0.0-oxide.4",
"os": [
"darwin"
],
"cpu": [
"x64"
],
"main": "tailwindcss-oxide.darwin-x64.node",
"files": [
"tailwindcss-oxide.darwin-x64.node"
],
"license": "MIT",
"engines": {
"node": ">= 10"
}
}

View File

@ -0,0 +1,3 @@
# `@tailwindcss/oxide-freebsd-x64`
This is the **x86_64-unknown-freebsd** binary for `@tailwindcss/oxide`

View File

@ -0,0 +1,18 @@
{
"name": "@tailwindcss/oxide-freebsd-x64",
"version": "0.0.0-oxide.4",
"os": [
"freebsd"
],
"cpu": [
"x64"
],
"main": "tailwindcss-oxide.freebsd-x64.node",
"files": [
"tailwindcss-oxide.freebsd-x64.node"
],
"license": "MIT",
"engines": {
"node": ">= 10"
}
}

View File

@ -0,0 +1,3 @@
# `@tailwindcss/oxide-linux-arm-gnueabihf`
This is the **armv7-unknown-linux-gnueabihf** binary for `@tailwindcss/oxide`

View File

@ -0,0 +1,18 @@
{
"name": "@tailwindcss/oxide-linux-arm-gnueabihf",
"version": "0.0.0-oxide.4",
"os": [
"linux"
],
"cpu": [
"arm"
],
"main": "tailwindcss-oxide.linux-arm-gnueabihf.node",
"files": [
"tailwindcss-oxide.linux-arm-gnueabihf.node"
],
"license": "MIT",
"engines": {
"node": ">= 10"
}
}

View File

@ -0,0 +1,3 @@
# `@tailwindcss/oxide-linux-arm64-gnu`
This is the **aarch64-unknown-linux-gnu** binary for `@tailwindcss/oxide`

View File

@ -0,0 +1,21 @@
{
"name": "@tailwindcss/oxide-linux-arm64-gnu",
"version": "0.0.0-oxide.4",
"os": [
"linux"
],
"cpu": [
"arm64"
],
"main": "tailwindcss-oxide.linux-arm64-gnu.node",
"files": [
"tailwindcss-oxide.linux-arm64-gnu.node"
],
"license": "MIT",
"engines": {
"node": ">= 10"
},
"libc": [
"glibc"
]
}

View File

@ -0,0 +1,3 @@
# `@tailwindcss/oxide-linux-arm64-musl`
This is the **aarch64-unknown-linux-musl** binary for `@tailwindcss/oxide`

View File

@ -0,0 +1,21 @@
{
"name": "@tailwindcss/oxide-linux-arm64-musl",
"version": "0.0.0-oxide.4",
"os": [
"linux"
],
"cpu": [
"arm64"
],
"main": "tailwindcss-oxide.linux-arm64-musl.node",
"files": [
"tailwindcss-oxide.linux-arm64-musl.node"
],
"license": "MIT",
"engines": {
"node": ">= 10"
},
"libc": [
"musl"
]
}

View File

@ -0,0 +1,3 @@
# `@tailwindcss/oxide-linux-x64-gnu`
This is the **x86_64-unknown-linux-gnu** binary for `@tailwindcss/oxide`

View File

@ -0,0 +1,21 @@
{
"name": "@tailwindcss/oxide-linux-x64-gnu",
"version": "0.0.0-oxide.4",
"os": [
"linux"
],
"cpu": [
"x64"
],
"main": "tailwindcss-oxide.linux-x64-gnu.node",
"files": [
"tailwindcss-oxide.linux-x64-gnu.node"
],
"license": "MIT",
"engines": {
"node": ">= 10"
},
"libc": [
"glibc"
]
}

View File

@ -0,0 +1,3 @@
# `@tailwindcss/oxide-linux-x64-musl`
This is the **x86_64-unknown-linux-musl** binary for `@tailwindcss/oxide`

View File

@ -0,0 +1,21 @@
{
"name": "@tailwindcss/oxide-linux-x64-musl",
"version": "0.0.0-oxide.4",
"os": [
"linux"
],
"cpu": [
"x64"
],
"main": "tailwindcss-oxide.linux-x64-musl.node",
"files": [
"tailwindcss-oxide.linux-x64-musl.node"
],
"license": "MIT",
"engines": {
"node": ">= 10"
},
"libc": [
"musl"
]
}

View File

@ -0,0 +1,3 @@
# `@tailwindcss/oxide-win32-x64-msvc`
This is the **x86_64-pc-windows-msvc** binary for `@tailwindcss/oxide`

View File

@ -0,0 +1,18 @@
{
"name": "@tailwindcss/oxide-win32-x64-msvc",
"version": "0.0.0-oxide.4",
"os": [
"win32"
],
"cpu": [
"x64"
],
"main": "tailwindcss-oxide.win32-x64-msvc.node",
"files": [
"tailwindcss-oxide.win32-x64-msvc.node"
],
"license": "MIT",
"engines": {
"node": ">= 10"
}
}

View File

@ -0,0 +1,49 @@
{
"name": "@tailwindcss/oxide",
"version": "0.0.0-oxide.4",
"main": "index.js",
"types": "index.d.ts",
"napi": {
"name": "tailwindcss-oxide",
"triples": {
"additional": [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"armv7-unknown-linux-gnueabihf",
"x86_64-unknown-linux-musl",
"x86_64-unknown-freebsd",
"i686-pc-windows-msvc"
]
}
},
"license": "MIT",
"devDependencies": {
"@napi-rs/cli": "^2.17.0"
},
"engines": {
"node": ">= 10"
},
"files": [
"index.js",
"index.d.ts"
],
"scripts": {
"artifacts": "npx napi artifacts",
"build": "npx napi build --platform --release --no-const-enum",
"dev": "cargo watch --quiet --shell 'npm run build'",
"build:debug": "npx napi build --platform --no-const-enum",
"version": "npx napi version"
},
"optionalDependencies": {
"@tailwindcss/oxide-darwin-arm64": "workspace:*",
"@tailwindcss/oxide-darwin-x64": "workspace:*",
"@tailwindcss/oxide-freebsd-x64": "workspace:*",
"@tailwindcss/oxide-linux-arm-gnueabihf": "workspace:*",
"@tailwindcss/oxide-linux-arm64-gnu": "workspace:*",
"@tailwindcss/oxide-linux-arm64-musl": "workspace:*",
"@tailwindcss/oxide-linux-x64-gnu": "workspace:*",
"@tailwindcss/oxide-linux-x64-musl": "workspace:*",
"@tailwindcss/oxide-win32-x64-msvc": "workspace:*"
}
}

View File

@ -0,0 +1,2 @@
tab_spaces = 2
edition = "2021"

View File

@ -0,0 +1,89 @@
use napi::bindgen_prelude::{FromNapiValue, ToNapiValue};
use std::path::PathBuf;
#[macro_use]
extern crate napi_derive;
#[derive(Debug, Clone)]
#[napi(object)]
pub struct ChangedContent {
pub file: Option<String>,
pub content: Option<String>,
pub extension: String,
}
impl From<ChangedContent> for tailwindcss_core::ChangedContent {
fn from(changed_content: ChangedContent) -> Self {
tailwindcss_core::ChangedContent {
file: changed_content.file.map(PathBuf::from),
content: changed_content.content,
}
}
}
#[derive(Debug, Clone)]
#[napi(object)]
pub struct ScanResult {
pub globs: Vec<GlobEntry>,
pub files: Vec<String>,
pub candidates: Vec<String>,
}
#[derive(Debug, Clone)]
#[napi(object)]
pub struct GlobEntry {
pub base: String,
pub glob: String,
}
#[derive(Debug, Clone)]
#[napi(object)]
pub struct ScanOptions {
pub base: String,
pub globs: Option<bool>,
}
#[napi]
pub fn clear_cache() {
tailwindcss_core::clear_cache();
}
#[napi]
pub fn scan_dir(args: ScanOptions) -> ScanResult {
let result = tailwindcss_core::scan_dir(tailwindcss_core::ScanOptions {
base: args.base,
globs: args.globs.unwrap_or(false),
});
ScanResult {
files: result.files,
candidates: result.candidates,
globs: result
.globs
.into_iter()
.map(|g| GlobEntry {
base: g.base,
glob: g.glob,
})
.collect(),
}
}
#[derive(Debug)]
#[napi]
pub enum IO {
Sequential = 0b0001,
Parallel = 0b0010,
}
#[derive(Debug)]
#[napi]
pub enum Parsing {
Sequential = 0b0100,
Parallel = 0b1000,
}
#[napi]
pub fn scan_files(input: Vec<ChangedContent>, strategy: u8) -> Vec<String> {
tailwindcss_core::scan_files(input.into_iter().map(Into::into).collect(), strategy)
}

20
oxide/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "tailwindcss-oxide",
"private": true,
"version": "0.1.0",
"workspaces": [
"node"
],
"scripts": {
"test": "cargo test",
"install:cargo": "cargo install cargo-watch cargo-fuzz",
"build": "cargo build --release",
"build:node": "npm --prefix ./crates/node run build",
"dev": "cargo watch --clear --quiet -x 'run --quiet'",
"dev:node": "cargo watch --clear --quiet --shell 'npm --prefix ./crates/node run build:debug'",
"fuzz": "cd ./crates/core; cargo fuzz run parsing; cd -",
"bench": "cargo bench",
"postbench": "open ./target/criterion/report/index.html"
},
"license": "MIT"
}

48
package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "@tailwindcss/root",
"private": true,
"version": "1.0.0",
"prettier": {
"semi": false,
"singleQuote": true,
"printWidth": 100,
"plugins": [
"prettier-plugin-organize-imports"
],
"overrides": [
{
"files": [
"tsconfig.json"
],
"options": {
"parser": "jsonc"
}
}
]
},
"scripts": {
"format": "prettier --write .",
"lint": "prettier --check . && turbo lint",
"build": "turbo build --filter=!./playgrounds/*",
"dev": "turbo dev --filter=!./playgrounds/*",
"test": "pnpm test --prefix=oxide && vitest run",
"test:ui": "pnpm run --filter=tailwindcss test:ui",
"tdd": "vitest",
"bench": "vitest bench",
"version-packages": "node ./scripts/version-packages.mjs",
"vite": "pnpm run --filter=vite-playground dev",
"nextjs": "pnpm run --filter=nextjs-playground dev"
},
"license": "MIT",
"devDependencies": {
"@playwright/test": "^1.41.2",
"@types/node": "^20.11.19",
"@vitest/coverage-v8": "^1.2.1",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"tsup": "^8.0.1",
"turbo": "^1.12.4",
"typescript": "^5.3.3",
"vitest": "^1.1.3"
}
}

View File

@ -0,0 +1,33 @@
{
"name": "@tailwindcss/postcss",
"version": "0.0.0-oxide.4",
"description": "PostCSS plugin for Tailwind CSS, a utility-first CSS framework for rapidly building custom user interfaces",
"license": "MIT",
"repository": "https://github.com/tailwindlabs/tailwindcss.git",
"bugs": "https://github.com/tailwindlabs/tailwindcss/issues",
"homepage": "https://tailwindcss.com",
"scripts": {
"lint": "tsc --noEmit",
"build": "tsup-node ./src/index.ts --format cjs --dts --cjsInterop --splitting",
"dev": "pnpm run build -- --watch"
},
"files": [
"dist/"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js"
}
},
"dependencies": {
"@tailwindcss/oxide": "workspace:^",
"postcss-import": "^16.0.0",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@types/node": "^20.11.17",
"@types/postcss-import": "^14.0.3",
"postcss": "8.4.24"
}
}

View File

@ -0,0 +1,650 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = `
"@layer theme {
:root {
--default-transition-duration: .15s;
--default-transition-timing-function: var(--transition-timing-function-in-out);
--default-font-family: var(--font-family-sans);
--default-font-feature-settings: var(--font-family-sans--font-feature-settings);
--default-font-variation-settings: var(--font-family-sans--font-variation-settings);
--default-mono-font-family: var(--font-family-mono);
--default-mono-font-feature-settings: var(--font-family-mono--font-feature-settings);
--default-mono-font-variation-settings: var(--font-family-mono--font-variation-settings);
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
--color-black: #000;
--color-white: #fff;
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-300: #cbd5e1;
--color-slate-400: #94a3b8;
--color-slate-500: #64748b;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
--color-slate-950: #020617;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
--color-zinc-50: #fafafa;
--color-zinc-100: #f4f4f5;
--color-zinc-200: #e4e4e7;
--color-zinc-300: #d4d4d8;
--color-zinc-400: #a1a1aa;
--color-zinc-500: #71717a;
--color-zinc-600: #52525b;
--color-zinc-700: #3f3f46;
--color-zinc-800: #27272a;
--color-zinc-900: #18181b;
--color-zinc-950: #09090b;
--color-neutral-50: #fafafa;
--color-neutral-100: #f5f5f5;
--color-neutral-200: #e5e5e5;
--color-neutral-300: #d4d4d4;
--color-neutral-400: #a3a3a3;
--color-neutral-500: #737373;
--color-neutral-600: #525252;
--color-neutral-700: #404040;
--color-neutral-800: #262626;
--color-neutral-900: #171717;
--color-neutral-950: #0a0a0a;
--color-stone-50: #fafaf9;
--color-stone-100: #f5f5f4;
--color-stone-200: #e7e5e4;
--color-stone-300: #d6d3d1;
--color-stone-400: #a8a29e;
--color-stone-500: #78716c;
--color-stone-600: #57534e;
--color-stone-700: #44403c;
--color-stone-800: #292524;
--color-stone-900: #1c1917;
--color-stone-950: #0c0a09;
--color-red-50: #fef2f2;
--color-red-100: #fee2e2;
--color-red-200: #fecaca;
--color-red-300: #fca5a5;
--color-red-400: #f87171;
--color-red-500: #ef4444;
--color-red-600: #dc2626;
--color-red-700: #b91c1c;
--color-red-800: #991b1b;
--color-red-900: #7f1d1d;
--color-red-950: #450a0a;
--color-orange-50: #fff7ed;
--color-orange-100: #ffedd5;
--color-orange-200: #fed7aa;
--color-orange-300: #fdba74;
--color-orange-400: #fb923c;
--color-orange-500: #f97316;
--color-orange-600: #ea580c;
--color-orange-700: #c2410c;
--color-orange-800: #9a3412;
--color-orange-900: #7c2d12;
--color-orange-950: #431407;
--color-amber-50: #fffbeb;
--color-amber-100: #fef3c7;
--color-amber-200: #fde68a;
--color-amber-300: #fcd34d;
--color-amber-400: #fbbf24;
--color-amber-500: #f59e0b;
--color-amber-600: #d97706;
--color-amber-700: #b45309;
--color-amber-800: #92400e;
--color-amber-900: #78350f;
--color-amber-950: #451a03;
--color-yellow-50: #fefce8;
--color-yellow-100: #fef9c3;
--color-yellow-200: #fef08a;
--color-yellow-300: #fde047;
--color-yellow-400: #facc15;
--color-yellow-500: #eab308;
--color-yellow-600: #ca8a04;
--color-yellow-700: #a16207;
--color-yellow-800: #854d0e;
--color-yellow-900: #713f12;
--color-yellow-950: #422006;
--color-lime-50: #f7fee7;
--color-lime-100: #ecfccb;
--color-lime-200: #d9f99d;
--color-lime-300: #bef264;
--color-lime-400: #a3e635;
--color-lime-500: #84cc16;
--color-lime-600: #65a30d;
--color-lime-700: #4d7c0f;
--color-lime-800: #3f6212;
--color-lime-900: #365314;
--color-lime-950: #1a2e05;
--color-green-50: #f0fdf4;
--color-green-100: #dcfce7;
--color-green-200: #bbf7d0;
--color-green-300: #86efac;
--color-green-400: #4ade80;
--color-green-500: #22c55e;
--color-green-600: #16a34a;
--color-green-700: #15803d;
--color-green-800: #166534;
--color-green-900: #14532d;
--color-green-950: #052e16;
--color-emerald-50: #ecfdf5;
--color-emerald-100: #d1fae5;
--color-emerald-200: #a7f3d0;
--color-emerald-300: #6ee7b7;
--color-emerald-400: #34d399;
--color-emerald-500: #10b981;
--color-emerald-600: #059669;
--color-emerald-700: #047857;
--color-emerald-800: #065f46;
--color-emerald-900: #064e3b;
--color-emerald-950: #022c22;
--color-teal-50: #f0fdfa;
--color-teal-100: #ccfbf1;
--color-teal-200: #99f6e4;
--color-teal-300: #5eead4;
--color-teal-400: #2dd4bf;
--color-teal-500: #14b8a6;
--color-teal-600: #0d9488;
--color-teal-700: #0f766e;
--color-teal-800: #115e59;
--color-teal-900: #134e4a;
--color-teal-950: #042f2e;
--color-cyan-50: #ecfeff;
--color-cyan-100: #cffafe;
--color-cyan-200: #a5f3fc;
--color-cyan-300: #67e8f9;
--color-cyan-400: #22d3ee;
--color-cyan-500: #06b6d4;
--color-cyan-600: #0891b2;
--color-cyan-700: #0e7490;
--color-cyan-800: #155e75;
--color-cyan-900: #164e63;
--color-cyan-950: #083344;
--color-sky-50: #f0f9ff;
--color-sky-100: #e0f2fe;
--color-sky-200: #bae6fd;
--color-sky-300: #7dd3fc;
--color-sky-400: #38bdf8;
--color-sky-500: #0ea5e9;
--color-sky-600: #0284c7;
--color-sky-700: #0369a1;
--color-sky-800: #075985;
--color-sky-900: #0c4a6e;
--color-sky-950: #082f49;
--color-blue-50: #eff6ff;
--color-blue-100: #dbeafe;
--color-blue-200: #bfdbfe;
--color-blue-300: #93c5fd;
--color-blue-400: #60a5fa;
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
--color-blue-700: #1d4ed8;
--color-blue-800: #1e40af;
--color-blue-900: #1e3a8a;
--color-blue-950: #172554;
--color-indigo-50: #eef2ff;
--color-indigo-100: #e0e7ff;
--color-indigo-200: #c7d2fe;
--color-indigo-300: #a5b4fc;
--color-indigo-400: #818cf8;
--color-indigo-500: #6366f1;
--color-indigo-600: #4f46e5;
--color-indigo-700: #4338ca;
--color-indigo-800: #3730a3;
--color-indigo-900: #312e81;
--color-indigo-950: #1e1b4b;
--color-violet-50: #f5f3ff;
--color-violet-100: #ede9fe;
--color-violet-200: #ddd6fe;
--color-violet-300: #c4b5fd;
--color-violet-400: #a78bfa;
--color-violet-500: #8b5cf6;
--color-violet-600: #7c3aed;
--color-violet-700: #6d28d9;
--color-violet-800: #5b21b6;
--color-violet-900: #4c1d95;
--color-violet-950: #2e1065;
--color-purple-50: #faf5ff;
--color-purple-100: #f3e8ff;
--color-purple-200: #e9d5ff;
--color-purple-300: #d8b4fe;
--color-purple-400: #c084fc;
--color-purple-500: #a855f7;
--color-purple-600: #9333ea;
--color-purple-700: #7e22ce;
--color-purple-800: #6b21a8;
--color-purple-900: #581c87;
--color-purple-950: #3b0764;
--color-fuchsia-50: #fdf4ff;
--color-fuchsia-100: #fae8ff;
--color-fuchsia-200: #f5d0fe;
--color-fuchsia-300: #f0abfc;
--color-fuchsia-400: #e879f9;
--color-fuchsia-500: #d946ef;
--color-fuchsia-600: #c026d3;
--color-fuchsia-700: #a21caf;
--color-fuchsia-800: #86198f;
--color-fuchsia-900: #701a75;
--color-fuchsia-950: #4a044e;
--color-pink-50: #fdf2f8;
--color-pink-100: #fce7f3;
--color-pink-200: #fbcfe8;
--color-pink-300: #f9a8d4;
--color-pink-400: #f472b6;
--color-pink-500: #ec4899;
--color-pink-600: #db2777;
--color-pink-700: #be185d;
--color-pink-800: #9d174d;
--color-pink-900: #831843;
--color-pink-950: #500724;
--color-rose-50: #fff1f2;
--color-rose-100: #ffe4e6;
--color-rose-200: #fecdd3;
--color-rose-300: #fda4af;
--color-rose-400: #fb7185;
--color-rose-500: #f43f5e;
--color-rose-600: #e11d48;
--color-rose-700: #be123c;
--color-rose-800: #9f1239;
--color-rose-900: #881337;
--color-rose-950: #4c0519;
--animate-spin: spin 1s linear infinite;
--animate-ping: ping 1s cubic-bezier(0, 0, .2, 1) infinite;
--animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;
--animate-bounce: bounce 1s infinite;
--blur: 8px;
--blur-sm: 4px;
--blur-md: 12px;
--blur-lg: 16px;
--blur-xl: 24px;
--blur-2xl: 40px;
--blur-3xl: 64px;
--radius-none: 0px;
--radius-full: 9999px;
--radius-sm: .125rem;
--radius: .25rem;
--radius-md: .375rem;
--radius-lg: .5rem;
--radius-xl: .75rem;
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
--shadow: 0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;
--shadow-xs: 0 1px #0000000d;
--shadow-sm: 0 1px 2px 0 #0000000d;
--shadow-md: 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;
--shadow-lg: 0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;
--shadow-xl: 0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;
--shadow-2xl: 0 25px 50px -12px #00000040;
--shadow-inner: inset 0 2px 4px 0 #0000000d;
--inset-shadow-xs: inset 0 1px #0000000d;
--inset-shadow-sm: inset 0 1px 1px #0000000d;
--inset-shadow: inset 0 2px 4px #0000000d;
--drop-shadow: 0 1px 2px #0000001a, 0 1px 1px #0000000f;
--drop-shadow-sm: 0 1px 1px #0000000d;
--drop-shadow-md: 0 4px 3px #00000012, 0 2px 2px #0000000f;
--drop-shadow-lg: 0 10px 8px #0000000a, 0 4px 3px #0000001a;
--drop-shadow-xl: 0 20px 13px #00000008, 0 8px 5px #00000014;
--drop-shadow-2xl: 0 25px 25px #00000026;
--spacing-px: 1px;
--spacing-0: 0px;
--spacing-0_5: .125rem;
--spacing-1: .25rem;
--spacing-1_5: .375rem;
--spacing-2: .5rem;
--spacing-2_5: .625rem;
--spacing-3: .75rem;
--spacing-3_5: .875rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-7: 1.75rem;
--spacing-8: 2rem;
--spacing-9: 2.25rem;
--spacing-10: 2.5rem;
--spacing-11: 2.75rem;
--spacing-12: 3rem;
--spacing-14: 3.5rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
--spacing-28: 7rem;
--spacing-32: 8rem;
--spacing-36: 9rem;
--spacing-40: 10rem;
--spacing-44: 11rem;
--spacing-48: 12rem;
--spacing-52: 13rem;
--spacing-56: 14rem;
--spacing-60: 15rem;
--spacing-64: 16rem;
--spacing-72: 18rem;
--spacing-80: 20rem;
--spacing-96: 24rem;
--width-3xs: 16rem;
--width-2xs: 18rem;
--width-xs: 20rem;
--width-sm: 24rem;
--width-md: 28rem;
--width-lg: 32rem;
--width-xl: 36rem;
--width-2xl: 42rem;
--width-3xl: 48rem;
--width-4xl: 56rem;
--width-5xl: 64rem;
--width-6xl: 72rem;
--width-7xl: 80rem;
--width-prose: 65ch;
--font-family-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-family-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-size-xs: .75rem;
--font-size-xs--line-height: 1rem;
--font-size-sm: .875rem;
--font-size-sm--line-height: 1.25rem;
--font-size-base: 1rem;
--font-size-base--line-height: 1.5rem;
--font-size-lg: 1.125rem;
--font-size-lg--line-height: 1.75rem;
--font-size-xl: 1.25rem;
--font-size-xl--line-height: 1.75rem;
--font-size-2xl: 1.5rem;
--font-size-2xl--line-height: 2rem;
--font-size-3xl: 1.875rem;
--font-size-3xl--line-height: 2.25rem;
--font-size-4xl: 2.25rem;
--font-size-4xl--line-height: 2.5rem;
--font-size-5xl: 3rem;
--font-size-5xl--line-height: 1;
--font-size-6xl: 3.75rem;
--font-size-6xl--line-height: 1;
--font-size-7xl: 4.5rem;
--font-size-7xl--line-height: 1;
--font-size-8xl: 6rem;
--font-size-8xl--line-height: 1;
--font-size-9xl: 8rem;
--font-size-9xl--line-height: 1;
--letter-spacing-tighter: -.05em;
--letter-spacing-tight: -.025em;
--letter-spacing-normal: 0em;
--letter-spacing-wide: .025em;
--letter-spacing-wider: .05em;
--letter-spacing-widest: .1em;
--line-height-none: 1;
--line-height-tight: 1.25;
--line-height-snug: 1.375;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
--line-height-loose: 2;
--line-height-3: .75rem;
--line-height-4: 1rem;
--line-height-5: 1.25rem;
--line-height-6: 1.5rem;
--line-height-7: 1.75rem;
--line-height-8: 2rem;
--line-height-9: 2.25rem;
--line-height-10: 2.5rem;
--transition-timing-function: cubic-bezier(.4, 0, .2, 1);
--transition-timing-function-linear: linear;
--transition-timing-function-in: cubic-bezier(.4, 0, 1, 1);
--transition-timing-function-out: cubic-bezier(0, 0, .2, 1);
--transition-timing-function-in-out: cubic-bezier(.4, 0, .2, 1);
}
}
@layer base {
*, :after, :before, ::backdrop {
box-sizing: border-box;
}
::file-selector-button {
box-sizing: border-box;
}
* {
margin: 0;
}
html, :host {
-webkit-text-size-adjust: 100%;
tab-size: 4;
line-height: 1.5;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
body {
line-height: inherit;
}
hr {
color: inherit;
border: 0 solid;
border-top-width: 1px;
height: 0;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
vertical-align: baseline;
font-size: 75%;
line-height: 0;
position: relative;
}
sub {
bottom: -.25em;
}
sup {
top: -.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
button, input, optgroup, select, textarea {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
color: inherit;
background: none;
border: 1px solid;
padding: 0;
}
::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
color: inherit;
background: none;
border: 1px solid;
padding: 0;
}
button, input:where([type="button"], [type="reset"], [type="submit"]) {
appearance: button;
border: 0;
}
::file-selector-button {
appearance: button;
border: 0;
}
:-moz-focusring {
outline: auto;
}
:-moz-ui-invalid {
box-shadow: none;
}
progress {
vertical-align: baseline;
}
::-webkit-inner-spin-button {
height: auto;
}
::-webkit-outer-spin-button {
height: auto;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
summary {
display: list-item;
}
fieldset {
border: 0;
padding: 0;
}
legend {
padding: 0;
}
ol, ul, menu {
padding: 0;
list-style: none;
}
dialog {
padding: 0;
}
textarea {
resize: vertical;
}
::placeholder {
opacity: 1;
color: color-mix(in srgb, currentColor 50%, transparent);
}
:disabled {
cursor: default;
}
img, svg, video, canvas, audio, iframe, embed, object {
vertical-align: middle;
display: block;
}
img, video {
max-width: 100%;
height: auto;
}
[hidden] {
display: none !important;
}
}
@layer components;
@layer utilities {
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
.text-black\\/50 {
color: #00000080;
}
.underline {
text-decoration-line: underline;
}
@media (width >= 1536px) {
.\\32 xl\\:font-bold {
font-weight: 700;
}
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes ping {
75%, 100% {
opacity: 0;
transform: scale(2);
}
}
@keyframes pulse {
50% {
opacity: .5;
}
}
@keyframes bounce {
0%, 100% {
animation-timing-function: cubic-bezier(.8, 0, 1, 1);
transform: translateY(-25%);
}
50% {
animation-timing-function: cubic-bezier(0, 0, .2, 1);
transform: none;
}
}"
`;

View File

@ -0,0 +1 @@
<div class="underline 2xl:font-bold"></div>

View File

@ -0,0 +1 @@
const className = 'text-2xl text-black/50'

View File

@ -0,0 +1,130 @@
import { unlink, writeFile } from 'node:fs/promises'
import postcss from 'postcss'
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
import tailwindcss from './index'
// We give this file path to PostCSS for processing.
// This file doesn't exist, but the path is used to resolve imports.
// We place it in packages/ because Vitest runs in the monorepo root,
// and packages/tailwindcss must be a sub-folder for
// @import 'tailwindcss' to work.
const INPUT_CSS_PATH = `${__dirname}/fixtures/example-project/input.css`
const css = String.raw
beforeEach(async () => {
const { clearCache } = await import('@tailwindcss/oxide')
clearCache()
})
test("`@import 'tailwindcss'` is replaced with the generated CSS", async () => {
let processor = postcss([tailwindcss({ base: `${__dirname}/fixtures/example-project` })])
let result = await processor.process(`@import 'tailwindcss'`, { from: INPUT_CSS_PATH })
expect(result.css.trim()).toMatchSnapshot()
// Check for dependency messages
expect(result.messages).toContainEqual({
type: 'dependency',
file: expect.stringMatching(/index.html$/g),
parent: expect.any(String),
plugin: expect.any(String),
})
expect(result.messages).toContainEqual({
type: 'dependency',
file: expect.stringMatching(/index.js$/g),
parent: expect.any(String),
plugin: expect.any(String),
})
expect(result.messages).toContainEqual({
type: 'dir-dependency',
dir: expect.stringMatching(/example-project\/src$/g),
glob: expect.stringMatching(/^\*\*\/\*/g),
parent: expect.any(String),
plugin: expect.any(String),
})
})
test('output is optimized by Lightning CSS', async () => {
let processor = postcss([tailwindcss({ base: `${__dirname}/fixtures/example-project` })])
// `@apply` is used because Lightning is skipped if neither `@tailwind` nor
// `@apply` is used.
let result = await processor.process(
css`
@layer utilities {
.foo {
@apply text-[black];
}
}
@layer utilities {
.bar {
color: red;
}
}
`,
{ from: INPUT_CSS_PATH },
)
expect(result.css.trim()).toMatchInlineSnapshot(`
"@layer utilities {
.foo {
color: #000;
}
.bar {
color: red;
}
}"
`)
})
test('@apply can be used without emitting the theme in the CSS file', async () => {
let processor = postcss([tailwindcss({ base: `${__dirname}/fixtures/example-project` })])
// `@apply` is used because Lightning is skipped if neither `@tailwind` nor
// `@apply` is used.
let result = await processor.process(
css`
@import 'tailwindcss/theme.css' reference;
.foo {
@apply text-red-500;
}
`,
{ from: INPUT_CSS_PATH },
)
expect(result.css.trim()).toMatchInlineSnapshot(`
".foo {
color: #ef4444;
}"
`)
})
describe('processing without specifying a base path', () => {
let filepath = `${process.cwd()}/my-test-file.html`
beforeEach(() =>
writeFile(filepath, `<div class="md:[&:hover]:content-['testing_default_base_path']">`),
)
afterEach(() => unlink(filepath))
test('the current working directory is used by default', async () => {
let processor = postcss([tailwindcss()])
let result = await processor.process(`@import "tailwindcss"`, { from: INPUT_CSS_PATH })
expect(result.css).toContain(
".md\\:\\[\\&\\:hover\\]\\:content-\\[\\'testing_default_base_path\\'\\]",
)
expect(result.messages).toContainEqual({
type: 'dependency',
file: expect.stringMatching(/my-test-file.html$/g),
parent: expect.any(String),
plugin: expect.any(String),
})
})
})

View File

@ -0,0 +1,82 @@
import { scanDir } from '@tailwindcss/oxide'
import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss'
import postcssImport from 'postcss-import'
import { compile, optimizeCss } from 'tailwindcss'
type PluginOptions = {
// The base directory to scan for class candidates.
base?: string
}
function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
let base = opts.base ?? process.cwd()
return {
postcssPlugin: 'tailwindcss-v4',
plugins: [
// We need to run `postcss-import` first to handle `@import` rules.
postcssImport(),
(root, result) => {
let hasApply = false
let hasTailwind = false
root.walkAtRules((rule) => {
if (rule.name === 'apply') {
hasApply = true
} else if (rule.name === 'tailwind') {
hasApply = true
hasTailwind = true
// If we've found `@tailwind` then we already
// know we have to run a "full" build
return false
}
})
// Do nothing if neither `@tailwind` nor `@apply` is used
if (!hasTailwind && !hasApply) return
function replaceCss(css: string) {
root.removeAll()
root.append(postcss.parse(optimizeCss(css), result.opts))
}
// No `@tailwind` means we don't have to look for candidates
if (!hasTailwind) {
replaceCss(compile(root.toString(), []))
return
}
// Look for candidates used to generate the CSS
let { candidates, files, globs } = scanDir({ base, globs: true })
// Add all found files as direct dependencies
for (let file of files) {
result.messages.push({
type: 'dependency',
plugin: 'tailwindcss-v4',
file,
parent: result.opts.from,
})
}
// Register dependencies so changes in `base` cause a rebuild while
// giving tools like Vite or Parcel a glob that can be used to limit
// the files that cause a rebuild to only those that match it.
for (let { base, glob } of globs) {
result.messages.push({
type: 'dir-dependency',
plugin: 'tailwindcss-v4',
dir: base,
glob,
parent: result.opts.from,
})
}
replaceCss(compile(root.toString(), candidates))
},
],
}
}
export default Object.assign(tailwindcss, { postcss: true }) as PluginCreator<PluginOptions>

View File

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

View File

@ -0,0 +1,30 @@
{
"name": "@tailwindcss/vite",
"version": "0.0.0-oxide.4",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": "https://github.com/tailwindlabs/tailwindcss.git",
"bugs": "https://github.com/tailwindlabs/tailwindcss/issues",
"homepage": "https://tailwindcss.com",
"scripts": {
"build": "tsup-node ./src/index.ts --format esm --dts",
"dev": "pnpm run build -- --watch"
},
"files": [
"dist/"
],
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
}
},
"dependencies": {
"@tailwindcss/oxide": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@types/node": "^20.11.17",
"vite": "^5.0.11"
}
}

View File

@ -0,0 +1,177 @@
import { IO, Parsing, scanFiles } from '@tailwindcss/oxide'
import path from 'path'
import { compile, optimizeCss } from 'tailwindcss'
import type { Plugin, Update, ViteDevServer } from 'vite'
export default function tailwindcss(): Plugin[] {
let server: ViteDevServer | null = null
let candidates = new Set<string>()
let cssModules = new Set<string>()
let minify = false
function isCssFile(id: string) {
let [filename] = id.split('?', 2)
let extension = path.extname(filename).slice(1)
return extension === 'css'
}
// Trigger update to all css modules
function updateCssModules() {
// If we're building then we don't need to update anything
if (!server) return
let updates: Update[] = []
for (let id of cssModules) {
let cssModule = server.moduleGraph.getModuleById(id)
if (!cssModule) {
console.log('Could not find css module', id)
continue
}
server.moduleGraph.invalidateModule(cssModule)
updates.push({
type: `${cssModule.type}-update`,
path: cssModule.url,
acceptedPath: cssModule.url,
timestamp: Date.now(),
})
}
if (updates.length > 0) {
server.hot.send({ type: 'update', updates })
}
}
function scan(src: string, extension: string) {
let updated = false
// Parse all candidates given the resolved files
for (let candidate of scanFiles(
[{ content: src, extension }],
IO.Sequential | Parsing.Sequential,
)) {
// On an initial or full build, updated becomes true immediately so we
// won't be making extra checks.
if (!updated) {
if (candidates.has(candidate)) continue
updated = true
}
candidates.add(candidate)
}
return updated
}
function generateCss(css: string) {
return optimizeCss(compile(css, Array.from(candidates)), { minify })
}
// In dev mode, there isn't a hook to signal that we've seen all files. We use
// a timer, resetting it on each file seen, and trigger CSS generation when we
// haven't seen any new files after a timeout. If this triggers too early,
// there will be a FOOC and but CSS will regenerate after we've seen more files.
let initialScan = (() => {
// If too short, we're more likely to trigger a FOOC and generate CSS
// multiple times. If too long, we delay dev builds.
let delayInMs = 50
let timer: ReturnType<typeof setTimeout>
let resolve: () => void
let resolved = false
return {
tick() {
if (resolved) return
timer && clearTimeout(timer)
timer = setTimeout(resolve, delayInMs)
},
complete: new Promise<void>((_resolve) => {
resolve = () => {
resolved = true
_resolve()
}
}),
}
})()
return [
{
// Step 1: Scan source files for candidates
name: '@tailwindcss/vite:scan',
enforce: 'pre',
configureServer(_server) {
server = _server
},
async configResolved(config) {
minify = config.build.cssMinify !== false
},
// Scan index.html for candidates
transformIndexHtml(html) {
initialScan.tick()
let updated = scan(html, 'html')
// In dev mode, if the generated CSS contains a URL that causes the
// browser to load a page (e.g. an URL to a missing image), triggering a
// CSS update will cause an infinite loop. We only trigger if the
// candidates have been updated.
if (server && updated) {
updateCssModules()
}
},
// Scan all other files for candidates
transform(src, id) {
initialScan.tick()
if (id.includes('/.vite/')) return
let [filename] = id.split('?', 2)
let extension = path.extname(filename).slice(1)
if (extension === '' || extension === 'css') return
scan(src, extension)
if (server) {
updateCssModules()
}
},
},
{
// Step 2 (dev mode): Generate CSS
name: '@tailwindcss/vite:generate:serve',
apply: 'serve',
async transform(src, id) {
if (!isCssFile(id) || !src.includes('@tailwind')) return
cssModules.add(id)
// For the initial load we must wait for all source files to be scanned
await initialScan.complete
return { code: generateCss(src) }
},
},
{
// Step 2 (full build): Generate CSS
name: '@tailwindcss/vite:generate:build',
enforce: 'post',
apply: 'build',
generateBundle(_options, bundle) {
for (let id in bundle) {
let item = bundle[id]
if (item.type !== 'asset') continue
if (!isCssFile(id)) continue
let rawSource = item.source
let source =
rawSource instanceof Uint8Array ? new TextDecoder().decode(rawSource) : rawSource
if (source.includes('@tailwind')) {
item.source = generateCss(source)
}
}
},
},
] satisfies Plugin[]
}

View File

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

View File

@ -0,0 +1,5 @@
@layer theme, base, components, utilities;
@import './theme.css' layer(theme);
@import './preflight.css' layer(base);
@import './utilities.css' layer(utilities);

View File

@ -0,0 +1,67 @@
{
"name": "tailwindcss",
"version": "0.0.0-oxide.4",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": "https://github.com/tailwindlabs/tailwindcss.git",
"bugs": "https://github.com/tailwindlabs/tailwindcss/issues",
"homepage": "https://tailwindcss.com",
"scripts": {
"lint": "tsc --noEmit",
"build": "tsup-node --env.NODE_ENV production",
"dev": "tsup-node --env.NODE_ENV development --watch",
"test:ui": "playwright test"
},
"bin": {
"tailwindcss": "./dist/cli.js"
},
"exports": {
".": {
"style": "./index.css",
"types": "./src/index.ts",
"require": "./dist/lib.js",
"import": "./src/index.ts"
},
"./package.json": "./package.json",
"./index.css": "./index.css",
"./preflight.css": "./preflight.css",
"./theme.css": "./theme.css",
"./utilities.css": "./utilities.css"
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/lib.d.mts",
"style": "./index.css",
"require": "./dist/lib.js",
"import": "./dist/lib.mjs"
},
"./package.json": "./package.json",
"./index.css": "./index.css",
"./preflight.css": "./preflight.css",
"./theme.css": "./theme.css",
"./utilities.css": "./utilities.css"
}
},
"style": "index.css",
"files": [
"dist",
"index.css",
"preflight.css",
"theme.css",
"utilities.css"
],
"dependencies": {
"@parcel/watcher": "^2.4.1",
"lightningcss": "^1.24.0",
"mri": "^1.2.0",
"picocolors": "^1.0.0",
"postcss": "8.4.24",
"postcss-import": "^16.0.0"
},
"devDependencies": {
"@tailwindcss/oxide": "workspace:^",
"@types/node": "^20.10.8",
"@types/postcss-import": "^14.0.3"
}
}

View File

@ -0,0 +1,72 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
})

View File

@ -0,0 +1,355 @@
/*
Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
*/
*,
::after,
::before,
::backdrop,
::file-selector-button {
box-sizing: border-box;
}
/*
Remove any default margins.
*/
* {
margin: 0;
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS.
*/
html,
:host {
line-height: 1.5; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
tab-size: 4; /* 3 */
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'
); /* 4 */
font-feature-settings: var(--default-font-feature-settings, normal); /* 5 */
font-variation-settings: var(--default-font-variation-settings, normal); /* 6 */
-webkit-tap-highlight-color: transparent; /* 7 */
}
/*
Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
line-height: inherit;
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Reset the default border style to a 1px solid border.
*/
hr {
height: 0; /* 1 */
color: inherit; /* 2 */
border: 0 solid; /* 3 */
border-top-width: 1px; /* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: var(
--default-mono-font-family,
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
'Liberation Mono',
'Courier New',
monospace
); /* 4 */
font-feature-settings: var(--default-mono-font-feature-settings, normal); /* 5 */
font-variation-settings: var(--default-mono-font-variation-settings, normal); /* 6 */
font-size: 1em; /* 4 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0; /* 1 */
border-color: inherit; /* 2 */
border-collapse: collapse; /* 3 */
}
/*
1. Inherit the font styles in all browsers.
2. Reset the default inset border style to solid.
3. Remove the default background color.
4. Remove default padding.
*/
button,
input,
optgroup,
select,
textarea,
::file-selector-button {
font: inherit; /* 1 */
font-feature-settings: inherit; /* 1 */
font-variation-settings: inherit; /* 1 */
color: inherit; /* 1 */
border: 1px solid; /* 2 */
background: transparent; /* 3 */
padding: 0; /* 4 */
}
/*
1. Correct the inability to style the border radius in iOS Safari.
2. Make borders opt-in.
*/
button,
input:where([type='button'], [type='reset'], [type='submit']),
::file-selector-button {
appearance: button; /* 1 */
border: 0; /* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Remove the default border and spacing for fieldset and legend elements.
*/
fieldset {
border: 0;
padding: 0;
}
legend {
padding: 0;
}
/*
Make lists unstyled by default.
*/
ol,
ul,
menu {
list-style: none;
padding: 0;
}
/*
Remove the default padding from dialog elements.
*/
dialog {
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to a semi-transparent version of the current text color.
*/
::placeholder {
opacity: 1; /* 1 */
color: color-mix(in srgb, currentColor 50%, transparent); /* 2 */
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block; /* 1 */
vertical-align: middle; /* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/*
Make elements with the HTML hidden attribute stay hidden by default.
*/
[hidden] {
display: none !important;
}

View File

@ -0,0 +1,523 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`compiling CSS > \`@tailwind utilities\` is replaced by utilities using the default theme 1`] = `
":root {
--default-transition-duration: .15s;
--default-transition-timing-function: var(--transition-timing-function-in-out);
--default-font-family: var(--font-family-sans);
--default-font-feature-settings: var(--font-family-sans--font-feature-settings);
--default-font-variation-settings: var(--font-family-sans--font-variation-settings);
--default-mono-font-family: var(--font-family-mono);
--default-mono-font-feature-settings: var(--font-family-mono--font-feature-settings);
--default-mono-font-variation-settings: var(--font-family-mono--font-variation-settings);
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
--color-black: #000;
--color-white: #fff;
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-300: #cbd5e1;
--color-slate-400: #94a3b8;
--color-slate-500: #64748b;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
--color-slate-950: #020617;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
--color-zinc-50: #fafafa;
--color-zinc-100: #f4f4f5;
--color-zinc-200: #e4e4e7;
--color-zinc-300: #d4d4d8;
--color-zinc-400: #a1a1aa;
--color-zinc-500: #71717a;
--color-zinc-600: #52525b;
--color-zinc-700: #3f3f46;
--color-zinc-800: #27272a;
--color-zinc-900: #18181b;
--color-zinc-950: #09090b;
--color-neutral-50: #fafafa;
--color-neutral-100: #f5f5f5;
--color-neutral-200: #e5e5e5;
--color-neutral-300: #d4d4d4;
--color-neutral-400: #a3a3a3;
--color-neutral-500: #737373;
--color-neutral-600: #525252;
--color-neutral-700: #404040;
--color-neutral-800: #262626;
--color-neutral-900: #171717;
--color-neutral-950: #0a0a0a;
--color-stone-50: #fafaf9;
--color-stone-100: #f5f5f4;
--color-stone-200: #e7e5e4;
--color-stone-300: #d6d3d1;
--color-stone-400: #a8a29e;
--color-stone-500: #78716c;
--color-stone-600: #57534e;
--color-stone-700: #44403c;
--color-stone-800: #292524;
--color-stone-900: #1c1917;
--color-stone-950: #0c0a09;
--color-red-50: #fef2f2;
--color-red-100: #fee2e2;
--color-red-200: #fecaca;
--color-red-300: #fca5a5;
--color-red-400: #f87171;
--color-red-500: #ef4444;
--color-red-600: #dc2626;
--color-red-700: #b91c1c;
--color-red-800: #991b1b;
--color-red-900: #7f1d1d;
--color-red-950: #450a0a;
--color-orange-50: #fff7ed;
--color-orange-100: #ffedd5;
--color-orange-200: #fed7aa;
--color-orange-300: #fdba74;
--color-orange-400: #fb923c;
--color-orange-500: #f97316;
--color-orange-600: #ea580c;
--color-orange-700: #c2410c;
--color-orange-800: #9a3412;
--color-orange-900: #7c2d12;
--color-orange-950: #431407;
--color-amber-50: #fffbeb;
--color-amber-100: #fef3c7;
--color-amber-200: #fde68a;
--color-amber-300: #fcd34d;
--color-amber-400: #fbbf24;
--color-amber-500: #f59e0b;
--color-amber-600: #d97706;
--color-amber-700: #b45309;
--color-amber-800: #92400e;
--color-amber-900: #78350f;
--color-amber-950: #451a03;
--color-yellow-50: #fefce8;
--color-yellow-100: #fef9c3;
--color-yellow-200: #fef08a;
--color-yellow-300: #fde047;
--color-yellow-400: #facc15;
--color-yellow-500: #eab308;
--color-yellow-600: #ca8a04;
--color-yellow-700: #a16207;
--color-yellow-800: #854d0e;
--color-yellow-900: #713f12;
--color-yellow-950: #422006;
--color-lime-50: #f7fee7;
--color-lime-100: #ecfccb;
--color-lime-200: #d9f99d;
--color-lime-300: #bef264;
--color-lime-400: #a3e635;
--color-lime-500: #84cc16;
--color-lime-600: #65a30d;
--color-lime-700: #4d7c0f;
--color-lime-800: #3f6212;
--color-lime-900: #365314;
--color-lime-950: #1a2e05;
--color-green-50: #f0fdf4;
--color-green-100: #dcfce7;
--color-green-200: #bbf7d0;
--color-green-300: #86efac;
--color-green-400: #4ade80;
--color-green-500: #22c55e;
--color-green-600: #16a34a;
--color-green-700: #15803d;
--color-green-800: #166534;
--color-green-900: #14532d;
--color-green-950: #052e16;
--color-emerald-50: #ecfdf5;
--color-emerald-100: #d1fae5;
--color-emerald-200: #a7f3d0;
--color-emerald-300: #6ee7b7;
--color-emerald-400: #34d399;
--color-emerald-500: #10b981;
--color-emerald-600: #059669;
--color-emerald-700: #047857;
--color-emerald-800: #065f46;
--color-emerald-900: #064e3b;
--color-emerald-950: #022c22;
--color-teal-50: #f0fdfa;
--color-teal-100: #ccfbf1;
--color-teal-200: #99f6e4;
--color-teal-300: #5eead4;
--color-teal-400: #2dd4bf;
--color-teal-500: #14b8a6;
--color-teal-600: #0d9488;
--color-teal-700: #0f766e;
--color-teal-800: #115e59;
--color-teal-900: #134e4a;
--color-teal-950: #042f2e;
--color-cyan-50: #ecfeff;
--color-cyan-100: #cffafe;
--color-cyan-200: #a5f3fc;
--color-cyan-300: #67e8f9;
--color-cyan-400: #22d3ee;
--color-cyan-500: #06b6d4;
--color-cyan-600: #0891b2;
--color-cyan-700: #0e7490;
--color-cyan-800: #155e75;
--color-cyan-900: #164e63;
--color-cyan-950: #083344;
--color-sky-50: #f0f9ff;
--color-sky-100: #e0f2fe;
--color-sky-200: #bae6fd;
--color-sky-300: #7dd3fc;
--color-sky-400: #38bdf8;
--color-sky-500: #0ea5e9;
--color-sky-600: #0284c7;
--color-sky-700: #0369a1;
--color-sky-800: #075985;
--color-sky-900: #0c4a6e;
--color-sky-950: #082f49;
--color-blue-50: #eff6ff;
--color-blue-100: #dbeafe;
--color-blue-200: #bfdbfe;
--color-blue-300: #93c5fd;
--color-blue-400: #60a5fa;
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
--color-blue-700: #1d4ed8;
--color-blue-800: #1e40af;
--color-blue-900: #1e3a8a;
--color-blue-950: #172554;
--color-indigo-50: #eef2ff;
--color-indigo-100: #e0e7ff;
--color-indigo-200: #c7d2fe;
--color-indigo-300: #a5b4fc;
--color-indigo-400: #818cf8;
--color-indigo-500: #6366f1;
--color-indigo-600: #4f46e5;
--color-indigo-700: #4338ca;
--color-indigo-800: #3730a3;
--color-indigo-900: #312e81;
--color-indigo-950: #1e1b4b;
--color-violet-50: #f5f3ff;
--color-violet-100: #ede9fe;
--color-violet-200: #ddd6fe;
--color-violet-300: #c4b5fd;
--color-violet-400: #a78bfa;
--color-violet-500: #8b5cf6;
--color-violet-600: #7c3aed;
--color-violet-700: #6d28d9;
--color-violet-800: #5b21b6;
--color-violet-900: #4c1d95;
--color-violet-950: #2e1065;
--color-purple-50: #faf5ff;
--color-purple-100: #f3e8ff;
--color-purple-200: #e9d5ff;
--color-purple-300: #d8b4fe;
--color-purple-400: #c084fc;
--color-purple-500: #a855f7;
--color-purple-600: #9333ea;
--color-purple-700: #7e22ce;
--color-purple-800: #6b21a8;
--color-purple-900: #581c87;
--color-purple-950: #3b0764;
--color-fuchsia-50: #fdf4ff;
--color-fuchsia-100: #fae8ff;
--color-fuchsia-200: #f5d0fe;
--color-fuchsia-300: #f0abfc;
--color-fuchsia-400: #e879f9;
--color-fuchsia-500: #d946ef;
--color-fuchsia-600: #c026d3;
--color-fuchsia-700: #a21caf;
--color-fuchsia-800: #86198f;
--color-fuchsia-900: #701a75;
--color-fuchsia-950: #4a044e;
--color-pink-50: #fdf2f8;
--color-pink-100: #fce7f3;
--color-pink-200: #fbcfe8;
--color-pink-300: #f9a8d4;
--color-pink-400: #f472b6;
--color-pink-500: #ec4899;
--color-pink-600: #db2777;
--color-pink-700: #be185d;
--color-pink-800: #9d174d;
--color-pink-900: #831843;
--color-pink-950: #500724;
--color-rose-50: #fff1f2;
--color-rose-100: #ffe4e6;
--color-rose-200: #fecdd3;
--color-rose-300: #fda4af;
--color-rose-400: #fb7185;
--color-rose-500: #f43f5e;
--color-rose-600: #e11d48;
--color-rose-700: #be123c;
--color-rose-800: #9f1239;
--color-rose-900: #881337;
--color-rose-950: #4c0519;
--animate-spin: spin 1s linear infinite;
--animate-ping: ping 1s cubic-bezier(0, 0, .2, 1) infinite;
--animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;
--animate-bounce: bounce 1s infinite;
--blur: 8px;
--blur-sm: 4px;
--blur-md: 12px;
--blur-lg: 16px;
--blur-xl: 24px;
--blur-2xl: 40px;
--blur-3xl: 64px;
--radius-none: 0px;
--radius-full: 9999px;
--radius-sm: .125rem;
--radius: .25rem;
--radius-md: .375rem;
--radius-lg: .5rem;
--radius-xl: .75rem;
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
--shadow: 0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;
--shadow-xs: 0 1px #0000000d;
--shadow-sm: 0 1px 2px 0 #0000000d;
--shadow-md: 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;
--shadow-lg: 0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;
--shadow-xl: 0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;
--shadow-2xl: 0 25px 50px -12px #00000040;
--shadow-inner: inset 0 2px 4px 0 #0000000d;
--inset-shadow-xs: inset 0 1px #0000000d;
--inset-shadow-sm: inset 0 1px 1px #0000000d;
--inset-shadow: inset 0 2px 4px #0000000d;
--drop-shadow: 0 1px 2px #0000001a, 0 1px 1px #0000000f;
--drop-shadow-sm: 0 1px 1px #0000000d;
--drop-shadow-md: 0 4px 3px #00000012, 0 2px 2px #0000000f;
--drop-shadow-lg: 0 10px 8px #0000000a, 0 4px 3px #0000001a;
--drop-shadow-xl: 0 20px 13px #00000008, 0 8px 5px #00000014;
--drop-shadow-2xl: 0 25px 25px #00000026;
--spacing-px: 1px;
--spacing-0: 0px;
--spacing-0_5: .125rem;
--spacing-1: .25rem;
--spacing-1_5: .375rem;
--spacing-2: .5rem;
--spacing-2_5: .625rem;
--spacing-3: .75rem;
--spacing-3_5: .875rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-7: 1.75rem;
--spacing-8: 2rem;
--spacing-9: 2.25rem;
--spacing-10: 2.5rem;
--spacing-11: 2.75rem;
--spacing-12: 3rem;
--spacing-14: 3.5rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
--spacing-28: 7rem;
--spacing-32: 8rem;
--spacing-36: 9rem;
--spacing-40: 10rem;
--spacing-44: 11rem;
--spacing-48: 12rem;
--spacing-52: 13rem;
--spacing-56: 14rem;
--spacing-60: 15rem;
--spacing-64: 16rem;
--spacing-72: 18rem;
--spacing-80: 20rem;
--spacing-96: 24rem;
--width-3xs: 16rem;
--width-2xs: 18rem;
--width-xs: 20rem;
--width-sm: 24rem;
--width-md: 28rem;
--width-lg: 32rem;
--width-xl: 36rem;
--width-2xl: 42rem;
--width-3xl: 48rem;
--width-4xl: 56rem;
--width-5xl: 64rem;
--width-6xl: 72rem;
--width-7xl: 80rem;
--width-prose: 65ch;
--font-family-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-family-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-size-xs: .75rem;
--font-size-xs--line-height: 1rem;
--font-size-sm: .875rem;
--font-size-sm--line-height: 1.25rem;
--font-size-base: 1rem;
--font-size-base--line-height: 1.5rem;
--font-size-lg: 1.125rem;
--font-size-lg--line-height: 1.75rem;
--font-size-xl: 1.25rem;
--font-size-xl--line-height: 1.75rem;
--font-size-2xl: 1.5rem;
--font-size-2xl--line-height: 2rem;
--font-size-3xl: 1.875rem;
--font-size-3xl--line-height: 2.25rem;
--font-size-4xl: 2.25rem;
--font-size-4xl--line-height: 2.5rem;
--font-size-5xl: 3rem;
--font-size-5xl--line-height: 1;
--font-size-6xl: 3.75rem;
--font-size-6xl--line-height: 1;
--font-size-7xl: 4.5rem;
--font-size-7xl--line-height: 1;
--font-size-8xl: 6rem;
--font-size-8xl--line-height: 1;
--font-size-9xl: 8rem;
--font-size-9xl--line-height: 1;
--letter-spacing-tighter: -.05em;
--letter-spacing-tight: -.025em;
--letter-spacing-normal: 0em;
--letter-spacing-wide: .025em;
--letter-spacing-wider: .05em;
--letter-spacing-widest: .1em;
--line-height-none: 1;
--line-height-tight: 1.25;
--line-height-snug: 1.375;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
--line-height-loose: 2;
--line-height-3: .75rem;
--line-height-4: 1rem;
--line-height-5: 1.25rem;
--line-height-6: 1.5rem;
--line-height-7: 1.75rem;
--line-height-8: 2rem;
--line-height-9: 2.25rem;
--line-height-10: 2.5rem;
--transition-timing-function: cubic-bezier(.4, 0, .2, 1);
--transition-timing-function-linear: linear;
--transition-timing-function-in: cubic-bezier(.4, 0, 1, 1);
--transition-timing-function-out: cubic-bezier(0, 0, .2, 1);
--transition-timing-function-in-out: cubic-bezier(.4, 0, .2, 1);
}
.w-4 {
width: 1rem;
}
.bg-red-500 {
background-color: #ef4444;
}
.shadow {
--tw-shadow: 0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
@media (width >= 640px) {
.sm\\:flex {
display: flex;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes ping {
75%, 100% {
opacity: 0;
transform: scale(2);
}
}
@keyframes pulse {
50% {
opacity: .5;
}
}
@keyframes bounce {
0%, 100% {
animation-timing-function: cubic-bezier(.8, 0, 1, 1);
transform: translateY(-25%);
}
50% {
animation-timing-function: cubic-bezier(0, 0, .2, 1);
transform: none;
}
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-colored {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-colored {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-color {
syntax: "*";
inherits: false
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}"
`;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,122 @@
export type Rule = {
kind: 'rule'
selector: string
nodes: AstNode[]
}
export type Declaration = {
kind: 'declaration'
property: string
value: string
important: boolean
}
export type Comment = {
kind: 'comment'
value: string
}
export type AstNode = Rule | Declaration | Comment
export function rule(selector: string, nodes: AstNode[]): Rule {
return {
kind: 'rule',
selector,
nodes,
}
}
export function decl(property: string, value: string): Declaration {
return {
kind: 'declaration',
property,
value,
important: false,
}
}
export function comment(value: string): Comment {
return {
kind: 'comment',
value: value,
}
}
export function walk(
ast: AstNode[],
visit: (
node: AstNode,
utils: {
replaceWith(newNode: AstNode | AstNode[]): void
},
) => void | false,
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let shouldContinue = visit(node, {
replaceWith(newNode) {
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
// We want to visit the newly replaced node(s), which start at the current
// index (i). By decrementing the index here, the next loop will process
// this position (containing the replaced node) again.
i--
},
})
if (shouldContinue === false) return
if (node.kind === 'rule') {
walk(node.nodes, visit)
}
}
}
export function toCss(ast: AstNode[]) {
let atRoots: string[] = []
return ast
.map(function stringify(node: AstNode): string {
let css = ''
// Rule
if (node.kind === 'rule') {
// Pull out `@at-root` rules to append later
if (node.selector === '@at-root') {
for (let child of node.nodes) {
atRoots.push(stringify(child))
}
return css
}
// Print at-rules without nodes with a `;` instead of an empty block.
//
// E.g.:
//
// ```css
// @layer base, components, utilities;
// ```
if (node.selector[0] === '@' && node.nodes.length === 0) {
return `${node.selector};`
}
css += `${node.selector}{`
for (let child of node.nodes) {
css += stringify(child)
}
css += '}'
}
// Comment
else if (node.kind === 'comment') {
css += `/*${node.value}*/\n`
}
// Declaration
else if (node.property !== '--tw-sort' && node.value !== undefined && node.value !== null) {
css += `${node.property}:${node.value}${node.important ? '!important' : ''};`
}
return css
})
.concat(atRoots)
.join('\n')
}

View File

@ -0,0 +1,24 @@
import { scanDir } from '@tailwindcss/oxide'
import { bench } from 'vitest'
import { parseCandidate, parseVariant } from './candidate'
import { buildDesignSystem } from './design-system'
import { Theme } from './theme'
import { DefaultMap } from './utils/default-map'
// FOLDER=path/to/folder vitest bench
const root = process.env.FOLDER || process.cwd()
// Auto content detection
const result = scanDir({ base: root, globs: true })
const designSystem = buildDesignSystem(new Theme())
bench('parseCandidate', () => {
for (let candidate of result.candidates) {
parseCandidate(
candidate,
designSystem.utilities,
new DefaultMap((variant, map) => parseVariant(variant, designSystem.variants, map)),
)
}
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,634 @@
import { decodeArbitraryValue } from './utils/decode-arbitrary-value'
import { segment } from './utils/segment'
type ArbitraryUtilityValue = {
kind: 'arbitrary'
/**
* bg-[color:--my-color]
* ^^^^^
*/
dataType: string | null
/**
* bg-[#0088cc]
* ^^^^^^^
* bg-[--my_variable]
* var(^^^^^^^^^^^^^)
*/
value: string
/**
* bg-[--my_variable]
* ^^^^^^^^^^^^^
*/
dashedIdent: string | null
}
export type NamedUtilityValue = {
kind: 'named'
/**
* bg-red-500
* ^^^^^^^
*
* w-1/2
* ^
*/
value: string
/**
* w-1/2
* ^^^
*/
fraction: string | null
}
type ArbitraryModifier = {
kind: 'arbitrary'
/**
* bg-red-500/[50%]
* ^^^
*/
value: string
/**
* bg-red-500/[--my_variable]
* ^^^^^^^^^^^^^
*/
dashedIdent: string | null
}
type NamedModifier = {
kind: 'named'
/**
* bg-red-500/50
* ^^
*/
value: string
}
export type CandidateModifier = ArbitraryModifier | NamedModifier
type ArbitraryVariantValue = {
kind: 'arbitrary'
value: string
}
type NamedVariantValue = {
kind: 'named'
value: string
}
export type Variant =
/**
* Arbitrary variants are variants that take a selector and generate a variant
* on the fly.
*
* E.g.: `[&_p]`
*/
| {
kind: 'arbitrary'
selector: string
// If true, it can be applied as a child of a compound variant
compounds: boolean
}
/**
* Static variants are variants that don't take any arguments.
*
* E.g.: `hover`
*/
| {
kind: 'static'
root: string
// If true, it can be applied as a child of a compound variant
compounds: boolean
}
/**
* Functional variants are variants that can take an argument. The argument is
* either a named variant value or an arbitrary variant value.
*
* E.g.:
*
* - `aria-disabled`
* - `aria-[disabled]`
* - `@container-size` -> @container, with named value `size`
* - `@container-[inline-size]` -> @container, with arbitrary variant value `inline-size`
* - `@container` -> @container, with no value
*/
| {
kind: 'functional'
root: string
value: ArbitraryVariantValue | NamedVariantValue | null
modifier: ArbitraryModifier | NamedModifier | null
// If true, it can be applied as a child of a compound variant
compounds: boolean
}
/**
* Compound variants are variants that take another variant as an argument.
*
* E.g.:
*
* - `has-[&_p]`
* - `group-*`
* - `peer-*`
*/
| {
kind: 'compound'
root: string
modifier: ArbitraryModifier | NamedModifier | null
variant: Variant
// If true, it can be applied as a child of a compound variant
compounds: boolean
}
export type Candidate =
/**
* Arbitrary candidates are candidates that register utilities on the fly with
* a property and a value.
*
* E.g.:
*
* - `[color:red]`
* - `[color:red]/50`
* - `[color:red]/50!`
*/
| {
kind: 'arbitrary'
property: string
value: string
modifier: ArbitraryModifier | NamedModifier | null
variants: Variant[]
important: boolean
}
/**
* Static candidates are candidates that don't take any arguments.
*
* E.g.:
*
* - `underline`
* - `flex`
*/
| {
kind: 'static'
root: string
variants: Variant[]
negative: boolean
important: boolean
}
/**
* Functional candidates are candidates that can take an argument.
*
* E.g.:
*
* - `bg-red-500`
* - `bg-[#0088cc]`
* - `w-1/2`
*/
| {
kind: 'functional'
root: string
value: ArbitraryUtilityValue | NamedUtilityValue | null
modifier: ArbitraryModifier | NamedModifier | null
variants: Variant[]
negative: boolean
important: boolean
}
export function parseCandidate(
input: string,
utilities: {
has: (value: string) => boolean
kind: (root: string) => Omit<Candidate['kind'], 'arbitrary'>
},
parsedVariants: { get: (value: string) => Variant | null },
): Candidate | null {
// hover:focus:underline
// ^^^^^ ^^^^^^ -> Variants
// ^^^^^^^^^ -> Base
let rawVariants = segment(input, ':')
// Safety: At this point it is safe to use TypeScript's non-null assertion
// operator because even if the `input` was an empty string, splitting an
// empty string by `:` will always result in an array with at least one
// element.
let base = rawVariants.pop()!
let parsedCandidateVariants: Variant[] = []
for (let variant of rawVariants) {
let parsedVariant = parsedVariants.get(variant)
if (parsedVariant === null) return null
// Variants are applied left-to-right meaning that any representing pseudo-
// elements must come first. This is because they cannot have anything
// after them in a selector. The problem with this is that it's common for
// users to write them in the wrong order, for example:
//
// `dark:before:underline` (wrong)
// `before:dark:underline` (right)
//
// Add pseudo-element variants to the front, making both examples above
// function identically which allows users to not care about the order.
switch (variant) {
case 'after':
case 'backdrop':
case 'before':
case 'first-letter':
case 'first-line':
case 'marker':
case 'placeholder':
case 'selection':
parsedCandidateVariants.unshift(parsedVariant)
break
default:
parsedCandidateVariants.push(parsedVariant)
}
}
let state = {
important: false,
negative: false,
}
// Candidates that end with an exclamation mark are the important version with
// higher specificity of the non-important candidate, e.g. `mx-4!`.
if (base[base.length - 1] === '!') {
state.important = true
base = base.slice(0, -1)
}
// Arbitrary properties
if (base[0] === '[') {
let [baseWithoutModifier, modifierSegment = null] = segment(base, '/')
if (baseWithoutModifier[baseWithoutModifier.length - 1] !== ']') return null
// The property part of the arbitrary property can only start with a-z
// lowercase or a dash `-` in case of vendor prefixes such as `-webkit-`
// or `-moz-`.
//
// Otherwise, it is an invalid candidate, and skip continue parsing.
let charCode = baseWithoutModifier.charCodeAt(1)
if (charCode !== 45 && !(charCode >= 97 && charCode <= 122)) return null
baseWithoutModifier = baseWithoutModifier.slice(1, -1)
// Arbitrary properties consist of a property and a value separated by a
// `:`. If the `:` cannot be found, then it is an invalid candidate, and we
// can skip continue parsing.
//
// Since the property and the value should be separated by a `:`, we can
// also verify that the colon is not the first or last character in the
// candidate, because that would make it invalid as well.
let idx = baseWithoutModifier.indexOf(':')
if (idx === -1 || idx === 0 || idx === baseWithoutModifier.length - 1) return null
let property = baseWithoutModifier.slice(0, idx)
let value = decodeArbitraryValue(baseWithoutModifier.slice(idx + 1))
return {
kind: 'arbitrary',
property,
value,
modifier: modifierSegment === null ? null : parseModifier(modifierSegment),
variants: parsedCandidateVariants,
important: state.important,
}
}
// Candidates that start with a dash are the negative versions of another
// candidate, e.g. `-mx-4`.
if (base[0] === '-') {
state.negative = true
base = base.slice(1)
}
let [root, value] = findRoot(base, utilities)
let modifierSegment: string | null = null
// If the root is null, but it contains a `/`, then it could be that we are
// dealing with a functional utility that contains a modifier but doesn't
// contain a value.
//
// E.g.: `@container/parent`
if (root === null && base.includes('/')) {
let [rootWithoutModifier, rootModifierSegment = null] = segment(base, '/')
modifierSegment = rootModifierSegment
// Try to find the root and value, without the modifier present
;[root, value] = findRoot(rootWithoutModifier, utilities)
}
// If there's no root, the candidate isn't a valid class and can be discarded.
if (root === null) return null
let kind = utilities.kind(root)
if (kind === 'static') {
if (value !== null) return null
return {
kind: 'static',
root,
variants: parsedCandidateVariants,
negative: state.negative,
important: state.important,
}
}
let candidate: Candidate = {
kind: 'functional',
root,
modifier: modifierSegment === null ? null : parseModifier(modifierSegment),
value: null,
variants: parsedCandidateVariants,
negative: state.negative,
important: state.important,
}
if (value === null) return candidate
{
// Extract a modifier if present, e.g. `text-xl/9` or `bg-red-500/[14%]`
let [valueWithoutModifier, modifierSegment = null] = segment(value, '/')
if (modifierSegment !== null) {
candidate.modifier = parseModifier(modifierSegment)
}
let startArbitraryIdx = valueWithoutModifier.indexOf('[')
let valueIsArbitrary = startArbitraryIdx !== -1
if (valueIsArbitrary) {
let arbitraryValue = valueWithoutModifier.slice(startArbitraryIdx + 1, -1)
// Extract an explicit typehint if present, e.g. `bg-[color:var(--my-var)])`
let typehint = ''
for (let i = 0; i < arbitraryValue.length; i++) {
let code = arbitraryValue.charCodeAt(i)
// If we hit a ":", we're at the end of a typehint.
if (code === 58 /* ':' */) {
typehint = arbitraryValue.slice(0, i)
arbitraryValue = arbitraryValue.slice(i + 1)
break
}
// Keep iterating as long as we've only seen valid typehint characters.
if (code === 45 /* '-' */ || (code >= 97 && code <= 122) /* [a-z] */) {
continue
}
// If we see any other character, there's no typehint so break early.
break
}
// If an arbitrary value looks like a CSS variable, we automatically wrap
// it with `var(...)`.
//
// But since some CSS properties accept a `<dashed-ident>` as a value
// directly (e.g. `scroll-timeline-name`), we also store the original
// value in case the utility matcher is interested in it without
// `var(...)`.
let dashedIdent: string | null = null
if (arbitraryValue[0] === '-' && arbitraryValue[1] === '-') {
dashedIdent = arbitraryValue
arbitraryValue = `var(${arbitraryValue})`
} else {
arbitraryValue = decodeArbitraryValue(arbitraryValue)
}
candidate.value = {
kind: 'arbitrary',
dataType: typehint || null,
value: arbitraryValue,
dashedIdent,
}
} else {
// Some utilities support fractions as values, e.g. `w-1/2`. Since it's
// ambiguous whether the slash signals a modifier or not, we store the
// fraction separately in case the utility matcher is interested in it.
let fraction =
modifierSegment === null || candidate.modifier?.kind === 'arbitrary'
? null
: value.slice(valueWithoutModifier.lastIndexOf('-') + 1)
candidate.value = {
kind: 'named',
value: valueWithoutModifier,
fraction,
}
}
}
return candidate
}
function parseModifier(modifier: string): CandidateModifier {
if (modifier[0] === '[' && modifier[modifier.length - 1] === ']') {
let arbitraryValue = modifier.slice(1, -1)
// If an arbitrary value looks like a CSS variable, we automatically wrap
// it with `var(...)`.
//
// But since some CSS properties accept a `<dashed-ident>` as a value
// directly (e.g. `scroll-timeline-name`), we also store the original
// value in case the utility matcher is interested in it without
// `var(...)`.
let dashedIdent: string | null = null
if (arbitraryValue[0] === '-' && arbitraryValue[1] === '-') {
dashedIdent = arbitraryValue
arbitraryValue = `var(${arbitraryValue})`
} else {
arbitraryValue = decodeArbitraryValue(arbitraryValue)
}
return {
kind: 'arbitrary',
value: arbitraryValue,
dashedIdent,
}
}
return {
kind: 'named',
value: modifier,
}
}
export function parseVariant(
variant: string,
variants: {
has: (value: string) => boolean
kind: (root: string) => Omit<Variant['kind'], 'arbitrary'>
compounds: (root: string) => boolean
},
parsedVariants: { get: (value: string) => Variant | null },
): Variant | null {
// Arbitrary variants
if (variant[0] === '[' && variant[variant.length - 1] === ']') {
/**
* TODO: Breaking change
*
* @deprecated Arbitrary variants containing at-rules with other selectors
* are deprecated. Use stacked variants instead.
*
* Before:
* - `[@media(width>=123px){&:hover}]:`
*
* After:
* - `[@media(width>=123px)]:[&:hover]:`
* - `[@media(width>=123px)]:hover:`
*/
if (variant[1] === '@' && variant.includes('&')) return null
let selector = decodeArbitraryValue(variant.slice(1, -1))
if (selector[0] !== '@') {
// Ensure `&` is always present by wrapping the selector in `&:is(…)`
//
// E.g.:
//
// - `[p]:flex`
if (!selector.includes('&')) {
selector = `&:is(${selector})`
}
}
return {
kind: 'arbitrary',
selector,
compounds: true,
}
}
// Static, functional and compound variants
{
// group-hover/group-name
// ^^^^^^^^^^^ -> Variant without modifier
// ^^^^^^^^^^ -> Modifier
let [variantWithoutModifier, modifier = null, additionalModifier] = segment(variant, '/')
// If there's more than one modifier, the variant is invalid.
//
// E.g.:
//
// - `group-hover/foo/bar`
if (additionalModifier) return null
let [root, value] = findRoot(variantWithoutModifier, variants)
// Variant is invalid, therefore the candidate is invalid and we can skip
// continue parsing it.
if (root === null) return null
switch (variants.kind(root)) {
case 'static': {
if (value !== null) return null
return {
kind: 'static',
root,
compounds: variants.compounds(root),
}
}
case 'functional': {
if (value === null) return null
if (value[0] === '[' && value[value.length - 1] === ']') {
return {
kind: 'functional',
root,
modifier: modifier === null ? null : parseModifier(modifier),
value: {
kind: 'arbitrary',
value: decodeArbitraryValue(value.slice(1, -1)),
},
compounds: variants.compounds(root),
}
}
return {
kind: 'functional',
root,
modifier: modifier === null ? null : parseModifier(modifier),
value: { kind: 'named', value },
compounds: variants.compounds(root),
}
}
case 'compound': {
if (value === null) return null
let subVariant = parsedVariants.get(value)
if (subVariant === null) return null
if (subVariant.compounds === false) return null
return {
kind: 'compound',
root,
modifier: modifier === null ? null : { kind: 'named', value: modifier },
variant: subVariant,
compounds: variants.compounds(root),
}
}
}
}
return null
}
function findRoot(
input: string,
lookup: { has: (input: string) => boolean },
): [string | null, string | null] {
// If the lookup has an exact match, then that's the root.
if (lookup.has(input)) return [input, null]
// Otherwise test every permutation of the input by iteratively removing
// everything after the last dash.
let idx = input.lastIndexOf('-')
if (idx === -1) {
// Variants starting with `@` are special because they don't need a `-`
// after the `@` (E.g.: `@-lg` should be written as `@lg`).
if (input[0] === '@' && lookup.has('@')) {
return ['@', input.slice(1)]
}
return [null, null]
}
// Determine the root and value by testing permutations of the incoming input
// against the lookup table.
//
// In case of a candidate like `bg-red-500`, this looks like:
//
// `bg-red-500` -> No match
// `bg-red` -> No match
// `bg` -> Match
do {
let maybeRoot = input.slice(0, idx)
if (lookup.has(maybeRoot)) {
return [maybeRoot, input.slice(idx + 1)]
}
idx = input.lastIndexOf('-', idx - 1)
} while (idx > 0)
return [null, null]
}

View File

@ -0,0 +1,244 @@
import watcher from '@parcel/watcher'
import {
IO,
Parsing,
clearCache,
scanDir,
scanFiles,
type ChangedContent,
} from '@tailwindcss/oxide'
import { existsSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import postcss from 'postcss'
import atImport from 'postcss-import'
import { compile, optimizeCss } from '../../..'
import type { Arg, Result } from '../../utils/args'
import {
eprintln,
formatDuration,
header,
highlight,
println,
relative,
} from '../../utils/renderer'
import { resolve } from '../../utils/resolve'
import { drainStdin, outputFile } from './utils'
const css = String.raw
export function options() {
return {
'--input': {
type: 'string',
description: 'Input file',
alias: '-i',
},
'--output': {
type: 'string',
description: 'Output file',
alias: '-o',
},
'--watch': {
type: 'boolean | string',
description: 'Watch for changes and rebuild as needed',
alias: '-w',
},
'--minify': {
type: 'boolean',
description: 'Minify the output',
alias: '-m',
},
'--cwd': {
type: 'string',
description: 'The current working directory',
default: '.',
},
} satisfies Arg
}
export async function handle(args: Result<ReturnType<typeof options>>) {
let base = path.resolve(args['--cwd'])
// Resolve the output as an absolute path.
if (args['--output']) {
args['--output'] = path.resolve(base, args['--output'])
}
// Resolve the input as an absolute path. If the input is a `-`, then we don't
// need to resolve it because this is a flag to indicate that we want to use
// `stdin` instead.
if (args['--input'] && args['--input'] !== '-') {
args['--input'] = path.resolve(base, args['--input'])
// Ensure the provided `--input` exists.
if (!existsSync(args['--input'])) {
eprintln(header())
eprintln()
eprintln(`Specified input file ${highlight(relative(args['--input']))} does not exist.`)
process.exit(1)
}
}
let start = process.hrtime.bigint()
let { candidates } = scanDir({ base })
// Resolve the input
let [input, cssImportPaths] = await handleImports(
args['--input']
? args['--input'] === '-'
? await drainStdin()
: await fs.readFile(args['--input'], 'utf-8')
: css`
@import '${resolve('tailwindcss/index.css')}';
`,
args['--input'] ?? base,
)
// Compile the input
let result = optimizeCss(compile(input, candidates), {
file: args['--input'] ?? 'input.css',
minify: args['--minify'],
})
// Write the output
if (args['--output']) {
await outputFile(args['--output'], result)
} else {
println(result)
}
let end = process.hrtime.bigint()
eprintln(header())
eprintln()
eprintln(`Done in ${formatDuration(end - start)}`)
// Watch for changes
if (args['--watch']) {
await watcher.subscribe(base, async (err, events) => {
if (err) {
console.error(err)
return
}
try {
// 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.
if (events.length === 1 && events[0].path === args['--output']) return
let changedFiles: ChangedContent[] = []
let rebuildStrategy: 'incremental' | 'full' = 'incremental'
for (let event of events) {
// Track new and updated files for incremental rebuilds.
if (event.type === 'create' || event.type === 'update') {
changedFiles.push({
file: event.path,
extension: path.extname(event.path).slice(1),
} satisfies ChangedContent)
}
// If one of the changed files is related to the input CSS files, then
// we need to do a full rebuild because the theme might have changed.
if (cssImportPaths.includes(event.path)) {
rebuildStrategy = 'full'
// No need to check the rest of the events, because we already know we
// need to do a full rebuild.
break
}
}
// Re-compile the input
let start = process.hrtime.bigint()
// Scan the entire `base` directory for full rebuilds.
if (rebuildStrategy === 'full') {
// Clear the cache because we need to re-scan the entire directory.
clearCache()
// Re-scan the directory to get the new `candidates`.
candidates = scanDir({ base }).candidates
}
// Scan changed files only for incremental rebuilds.
else if (rebuildStrategy === 'incremental') {
let uniqueCandidates = new Set(candidates)
for (let candidate of scanFiles(changedFiles, IO.Sequential | Parsing.Sequential)) {
uniqueCandidates.add(candidate)
}
candidates = Array.from(uniqueCandidates)
}
// Resolve the input
if (rebuildStrategy === 'full') {
// Collect the new `input` and `cssImportPaths`.
;[input, cssImportPaths] = await handleImports(
args['--input']
? await fs.readFile(args['--input'], 'utf-8')
: css`
@import '${resolve('tailwindcss/index.css')}';
`,
args['--input'] ?? base,
)
}
// Compile the input
let result = optimizeCss(compile(input, candidates), {
file: args['--input'] ?? 'input.css',
minify: args['--minify'],
})
// Write the output
if (args['--output']) {
await outputFile(args['--output'], result)
} else {
println(result)
}
let end = process.hrtime.bigint()
eprintln(`Done in ${formatDuration(end - start)}`)
} catch (err) {
// Catch any errors and print them to stderr, but don't exit the process
// and keep watching.
if (err instanceof Error) {
eprintln(err.toString())
}
}
})
// Abort the watcher if `stdin` is closed to avoid zombie processes. You can
// disable this behavior with `--watch=always`.
if (args['--watch'] !== 'always') {
process.stdin.on('end', () => {
process.exit(0)
})
}
// Keep the process running
process.stdin.resume()
}
}
function handleImports(
input: string,
file: string,
): [css: string, paths: string[]] | Promise<[css: string, paths: string[]]> {
// TODO: Should we implement this ourselves instead of relying on PostCSS?
//
// Relevant specification:
// - CSS Import Resolve: https://csstools.github.io/css-import-resolve/
if (!input.includes('@import')) return [input, []]
return postcss()
.use(atImport())
.process(input, { from: file })
.then((result) => [
result.css,
// Use `result.messages` to get the imported files. This also includes the
// current file itself.
result.messages.filter((msg) => msg.type === 'postcss-import').map((msg) => msg.file),
])
}

View File

@ -0,0 +1,26 @@
import fs from 'node:fs/promises'
import path from 'node:path'
export function drainStdin() {
return new Promise<string>((resolve, reject) => {
let result = ''
process.stdin.on('data', (chunk) => {
result += chunk
})
process.stdin.on('end', () => resolve(result))
process.stdin.on('error', (err) => reject(err))
})
}
export async function outputFile(file: string, contents: string) {
try {
let currentContents = await fs.readFile(file, 'utf8')
if (currentContents === contents) return // Skip writing the file
} catch {}
// Ensure the parent directories exist
await fs.mkdir(path.dirname(file), { recursive: true })
// Write the file
await fs.writeFile(file, contents, 'utf8')
}

View File

@ -0,0 +1,170 @@
import pc from 'picocolors'
import type { Arg } from '../../utils/args'
import { UI, header, highlight, indent, println, wordWrap } from '../../utils/renderer'
export function help({
invalid,
usage,
options,
}: {
invalid?: string
usage?: string[]
options?: Arg
}) {
// Available terminal width
let width = process.stdout.columns
// Render header
println(header())
// Render the invalid command
if (invalid) {
println()
println(`${pc.dim('Invalid command:')} ${invalid}`)
}
// Render usage
if (usage && usage.length > 0) {
println()
println(pc.dim('Usage:'))
for (let [idx, example] of usage.entries()) {
// Split the usage example into the command and its options. This allows
// us to wrap the options based on the available width of the terminal.
let command = example.slice(0, example.indexOf('['))
let options = example.slice(example.indexOf('['))
// Make the options dimmed, to make them stand out less than the command
// itself.
options = options.replace(/\[.*?\]/g, (option) => pc.dim(option))
// The space between the command and the options.
let space = 1
// Wrap the options based on the available width of the terminal.
let lines = wordWrap(options, width - UI.indent - command.length - space)
// Print an empty line between the usage examples if we need to split due
// to width constraints. This ensures that the usage examples are visually
// separated.
//
// E.g.: when enough space is available
//
// ```
// Usage:
// tailwindcss build [--input input.css] [--output output.css] [--watch] [options...]
// tailwindcss other [--watch] [options...]
// ```
//
// E.g.: when not enough space is available
//
// ```
// Usage:
// tailwindcss build [--input input.css] [--output output.css]
// [--watch] [options...]
//
// tailwindcss other [--watch] [options...]
// ```
if (lines.length > 1 && idx !== 0) {
println()
}
// Print the usage examples based on available width of the terminal.
//
// E.g.: when enough space is available
//
// ```
// Usage:
// tailwindcss [--input input.css] [--output output.css] [--watch] [options...]
// ```
//
// E.g.: when not enough space is available
//
// ```
// Usage:
// tailwindcss [--input input.css] [--output output.css]
// [--watch] [options...]
// ```
//
// > Note how the second line is indented to align with the first line.
println(indent(`${command}${lines.shift()}`))
for (let line of lines) {
println(indent(line, command.length))
}
}
}
// Render options
if (options) {
// Track the max alias length, this is used to indent the options that don't
// have an alias such that everything is aligned properly.
let maxAliasLength = 0
for (let { alias } of Object.values(options)) {
if (alias) {
maxAliasLength = Math.max(maxAliasLength, alias.length)
}
}
// The option strings, which are the combination of the `alias` and the
// `flag`, with the correct spacing.
let optionStrings: string[] = []
// Track the max option length, which is the longest combination of an
// `alias` followed by `, ` and followed by the `flag`.
let maxOptionLength = 0
for (let [flag, { alias }] of Object.entries(options)) {
// The option string, which is the combination of the alias and the flag
// but already properly indented based on the other aliases to ensure
// everything is aligned properly.
let option = [
alias ? `${alias.padStart(maxAliasLength)}` : alias,
alias ? flag : ' '.repeat(maxAliasLength + 2 /* `, `.length */) + flag,
]
.filter(Boolean)
.join(', ')
optionStrings.push(option)
maxOptionLength = Math.max(maxOptionLength, option.length)
}
println()
println(pc.dim('Options:'))
// The minimum amount of dots between the option and the description.
let minimumGap = 8
for (let { description, default: defaultValue = null } of Object.values(options)) {
// The option to render
let option = optionStrings.shift() as string
// The amount of dots to show between the option and the description.
let dotCount = minimumGap + (maxOptionLength - option.length)
// To account for the space before and after the dots.
let spaces = 2
// The available width remaining for the description.
let availableWidth = width - option.length - dotCount - spaces - UI.indent
// Wrap the description and the default value (if present), based on the
// available width.
let lines = wordWrap(
defaultValue !== null
? `${description} ${pc.dim(`[default:\u202F${highlight(`${defaultValue}`)}]`)}`
: description,
availableWidth,
)
// Print the option, the spacer dots and the start of the description.
println(
indent(`${pc.blue(option)} ${pc.dim(pc.gray('\u00B7')).repeat(dotCount)} ${lines.shift()}`),
)
// Print the remaining lines of the description, indenting them to align
// with the start of the description.
for (let line of lines) {
println(indent(`${' '.repeat(option.length + dotCount + spaces)}${line}`))
}
}
}
}

View File

@ -0,0 +1,45 @@
#!/usr/bin/env node
import { args, type Arg } from './utils/args'
import * as build from './commands/build'
import { help } from './commands/help'
const sharedOptions = {
'--help': { type: 'boolean', description: 'Display usage information', alias: '-h' },
} satisfies Arg
const shared = args(sharedOptions)
const command = shared._[0]
// Right now we don't support any sub-commands. Let's show the help message
// instead.
if (command) {
help({
invalid: command,
usage: ['tailwindcss [options]'],
options: { ...build.options(), ...sharedOptions },
})
process.exit(1)
}
// Display main help message if no command is being used.
//
// E.g.:
//
// - `tailwindcss` // should show the help message
//
// E.g.: implicit `build` command
//
// - `tailwindcss -o output.css` // should run the build command, not show the help message
// - `tailwindcss > output.css` // should run the build command, not show the help message
if ((process.stdout.isTTY && !process.argv.slice(2).includes('-o')) || shared['--help']) {
help({
usage: ['tailwindcss [--input input.css] [--output output.css] [--watch] [options…]'],
options: { ...build.options(), ...sharedOptions },
})
process.exit(0)
}
// Handle the build command
build.handle(args(build.options()))

View File

@ -0,0 +1,123 @@
import { expect, it } from 'vitest'
import { args, type Arg } from './args'
it('should be possible to parse a single argument', () => {
expect(
args(
{
'--input': { type: 'string', description: 'Input file' },
},
['--input', 'input.css'],
),
).toMatchInlineSnapshot(`
{
"--input": "input.css",
"_": [],
}
`)
})
it('should fallback to the default value if no flag is passed', () => {
expect(
args(
{
'--input': { type: 'string', description: 'Input file', default: 'input.css' },
},
['--other'],
),
).toMatchInlineSnapshot(`
{
"--input": "input.css",
"_": [],
}
`)
})
it('should fallback to null if no flag is passed and no default value is provided', () => {
expect(
args(
{
'--input': { type: 'string', description: 'Input file' },
},
['--other'],
),
).toMatchInlineSnapshot(`
{
"--input": null,
"_": [],
}
`)
})
it('should be possible to parse a single argument using the shorthand alias', () => {
expect(
args(
{
'--input': { type: 'string', description: 'Input file', alias: '-i' },
},
['-i', 'input.css'],
),
).toMatchInlineSnapshot(`
{
"--input": "input.css",
"_": [],
}
`)
})
it('should convert the incoming value to the correct type', () => {
expect(
args(
{
'--input': { type: 'string', description: 'Input file' },
'--watch': { type: 'boolean', description: 'Watch mode' },
'--retries': { type: 'number', description: 'Amount of retries' },
},
['--input', 'input.css', '--watch', '--retries', '3'],
),
).toMatchInlineSnapshot(`
{
"--input": "input.css",
"--retries": 3,
"--watch": true,
"_": [],
}
`)
})
it('should be possible to provide multiple types, and convert the value to that type', () => {
let options = {
'--retries': { type: 'boolean | number | string', description: 'Retries' },
} satisfies Arg
expect(args(options, ['--retries'])).toMatchInlineSnapshot(`
{
"--retries": true,
"_": [],
}
`)
expect(args(options, ['--retries', 'true'])).toMatchInlineSnapshot(`
{
"--retries": true,
"_": [],
}
`)
expect(args(options, ['--retries', 'false'])).toMatchInlineSnapshot(`
{
"--retries": false,
"_": [],
}
`)
expect(args(options, ['--retries', '5'])).toMatchInlineSnapshot(`
{
"--retries": 5,
"_": [],
}
`)
expect(args(options, ['--retries', 'indefinitely'])).toMatchInlineSnapshot(`
{
"--retries": "indefinitely",
"_": [],
}
`)
})

View File

@ -0,0 +1,160 @@
import parse from 'mri'
// Definition of the arguments for a command in the CLI.
export type Arg = {
[key: `--${string}`]: {
type: keyof Types
description: string
alias?: `-${string}`
default?: Types[keyof Types]
}
}
// Each argument will have a type and we want to convert the incoming raw string
// based value to the correct type. We can't use pure TypeScript types because
// these don't exist at runtime. Instead, we define a string-based type that
// maps to a TypeScript type.
type Types = {
boolean: boolean
number: number | null
string: string | null
'boolean | string': boolean | string | null
'number | string': number | string | null
'boolean | number': boolean | number | null
'boolean | number | string': boolean | number | string | null
}
// Convert the `Arg` type to a type that can be used at runtime.
//
// E.g.:
//
// Arg:
// ```
// { '--input': { type: 'string', description: 'Input file', alias: '-i' } }
// ```
//
// Command:
// ```
// ./tailwindcss -i input.css
// ./tailwindcss --input input.css
// ```
//
// Result type:
// ```
// {
// _: string[], // All non-flag arguments
// '--input': string | null // The `--input` flag will be filled with `null`, if the flag is not used.
// // The `null` type will not be there if `default` is provided.
// }
// ```
//
// Result runtime object:
// ```
// {
// _: [],
// '--input': 'input.css'
// }
// ```
export type Result<T extends Arg> = {
[K in keyof T]: T[K] extends { type: keyof Types; default?: any }
? undefined extends T[K]['default']
? Types[T[K]['type']]
: NonNullable<Types[T[K]['type']]>
: never
} & {
// All non-flag arguments
_: string[]
}
export function args<const T extends Arg>(options: T, argv = process.argv.slice(2)): Result<T> {
let parsed = parse(argv)
let result: { _: string[]; [key: string]: unknown } = {
_: parsed._,
}
for (let [
flag,
{ type, alias, default: defaultValue = type === 'boolean' ? false : null },
] of Object.entries(options)) {
// Start with the default value
result[flag] = defaultValue
// Try to find the `alias`, and map it to long form `flag`
if (alias) {
let key = alias.slice(1)
if (parsed[key] !== undefined) {
result[flag] = convert(parsed[key], type)
}
}
// Try to find the long form `flag`
{
let key = flag.slice(2)
if (parsed[key] !== undefined) {
result[flag] = convert(parsed[key], type)
}
}
}
return result as Result<T>
}
// ---
type ArgumentType = string | boolean
// Try to convert the raw incoming `value` (which will be a string or a boolean,
// this is coming from `mri`'s parse function'), to the correct type based on
// the `type` of the argument.
function convert<T extends keyof Types>(value: string | boolean, type: T) {
switch (type) {
case 'string':
return convertString(value)
case 'boolean':
return convertBoolean(value)
case 'number':
return convertNumber(value)
case 'boolean | string':
return convertBoolean(value) ?? convertString(value)
case 'number | string':
return convertNumber(value) ?? convertString(value)
case 'boolean | number':
return convertBoolean(value) ?? convertNumber(value)
case 'boolean | number | string':
return convertBoolean(value) ?? convertNumber(value) ?? convertString(value)
default:
throw new Error(`Unhandled type: ${type}`)
}
}
function convertBoolean(value: ArgumentType) {
if (value === true || value === false) {
return value
}
if (value === 'true') {
return true
}
if (value === 'false') {
return false
}
}
function convertNumber(value: ArgumentType) {
if (typeof value === 'number') {
return value
}
{
let valueAsNumber = Number(value)
if (!Number.isNaN(valueAsNumber)) {
return valueAsNumber
}
}
}
function convertString(value: ArgumentType) {
return `${value}`
}

View File

@ -0,0 +1,28 @@
import { expect, it } from 'vitest'
import { formatNanoseconds } from './format-ns'
it.each([
[0, '0ns'],
[1, '1ns'],
[999, '999ns'],
[1000, '1µs'],
[1001, '1µs'],
[999999, '999µs'],
[1000000, '1ms'],
[1000001, '1ms'],
[999999999, '999ms'],
[1000000000, '1s'],
[1000000001, '1s'],
[59999999999, '59s'],
[60000000000, '1m'],
[60000000001, '1m'],
[3599999999999n, '59m'],
[3600000000000n, '1h'],
[3600000000001n, '1h'],
[86399999999999n, '23h'],
[86400000000000n, '1d'],
[86400000000001n, '1d'],
[8640000000000000n, '100d'],
])('should format %s nanoseconds as %s', (ns, expected) => {
expect(formatNanoseconds(ns)).toBe(expected)
})

View File

@ -0,0 +1,23 @@
export function formatNanoseconds(input: bigint | number) {
let ns = typeof input === 'number' ? BigInt(input) : input
if (ns < 1_000n) return `${ns}ns`
ns /= 1_000n
if (ns < 1_000n) return `${ns}µs`
ns /= 1_000n
if (ns < 1_000n) return `${ns}ms`
ns /= 1_000n
if (ns < 60n) return `${ns}s`
ns /= 60n
if (ns < 60n) return `${ns}m`
ns /= 60n
if (ns < 24n) return `${ns}h`
ns /= 24n
return `${ns}d`
}

View File

@ -0,0 +1,63 @@
import path from 'path'
import { describe, expect, it } from 'vitest'
import { relative, wordWrap } from './renderer'
describe('relative', () => {
it('should print an absolute path relative to the current working directory', () => {
expect(relative(path.resolve('index.css'))).toMatchInlineSnapshot(`"./index.css"`)
})
it('should prefer the shortest value by default', () => {
// Shortest between absolute and relative paths
expect(relative('index.css')).toMatchInlineSnapshot(`"index.css"`)
})
it('should be possible to override the current working directory', () => {
expect(relative('../utils/index.css', '..')).toMatchInlineSnapshot(`"./utils/index.css"`)
})
it('should be possible to always prefer the relative path', () => {
expect(
relative('index.css', process.cwd(), { preferAbsoluteIfShorter: false }),
).toMatchInlineSnapshot(`"./index.css"`)
})
})
describe('word wrap', () => {
it('should wrap a sentence', () => {
expect(wordWrap('The quick brown fox jumps over the lazy dog', 10)).toMatchInlineSnapshot(`
[
"The quick",
"brown fox",
"jumps over",
"the lazy",
"dog",
]
`)
expect(wordWrap('The quick brown fox jumps over the lazy dog', 30)).toMatchInlineSnapshot(`
[
"The quick brown fox jumps over",
"the lazy dog",
]
`)
})
it('should wrap a sentence with ANSI escape codes', () => {
// The ANSI escape codes are not counted in the length, but they should
// still be rendered correctly.
expect(
wordWrap(
'\x1B[31mThe\x1B[39m \x1B[32mquick\x1B[39m \x1B[34mbrown\x1B[39m \x1B[35mfox\x1B[39m jumps over the lazy dog',
10,
),
).toMatchInlineSnapshot(`
[
"The quick",
"brown fox",
"jumps over",
"the lazy",
"dog",
]
`)
})
})

View File

@ -0,0 +1,98 @@
import path from 'node:path'
import pc from 'picocolors'
import { version } from '../../../package.json'
import { formatNanoseconds } from './format-ns'
export const UI = {
indent: 2,
}
export function header() {
return `${pc.italic(pc.bold(pc.blue('\u2248')))} tailwindcss ${pc.blue(`v${version}`)}`
}
export function highlight(file: string) {
return `${pc.dim(pc.blue('`'))}${pc.blue(file)}${pc.dim(pc.blue('`'))}`
}
/**
* Convert an `absolute` path to a `relative` path from the current working
* directory.
*/
export function relative(
to: string,
from = process.cwd(),
{ preferAbsoluteIfShorter = true } = {},
) {
let result = path.relative(from, to)
if (!result.startsWith('..')) {
result = `.${path.sep}${result}`
}
if (preferAbsoluteIfShorter && result.length > to.length) {
return to
}
return result
}
/**
* Wrap `text` into multiple lines based on the `width`.
*/
export function wordWrap(text: string, width: number) {
let words = text.split(' ')
let lines = []
let line = ''
let lineLength = 0
for (let word of words) {
let wordLength = clearAnsiEscapes(word).length
if (lineLength + wordLength + 1 > width) {
lines.push(line)
line = ''
lineLength = 0
}
line += (lineLength ? ' ' : '') + word
lineLength += wordLength + (lineLength ? 1 : 0)
}
if (lineLength) {
lines.push(line)
}
return lines
}
const ESCAPE = /((?:\x9B|\x1B\[)[0-?]*[ -\/]*[@-~])/g
function clearAnsiEscapes(input: string) {
return input.replace(ESCAPE, '')
}
/**
* Format a duration in nanoseconds to a more human readable format.
*/
export function formatDuration(ns: bigint) {
let formatted = formatNanoseconds(ns)
if (ns <= 50 * 1e6) return pc.green(formatted)
if (ns <= 300 * 1e6) return pc.blue(formatted)
if (ns <= 1000 * 1e6) return pc.yellow(formatted)
return pc.red(formatted)
}
export function indent(value: string, offset = 0) {
return `${' '.repeat(offset + UI.indent)}${value}`
}
// Rust inspired functions to print to the console:
export function eprintln(value = '') {
process.stderr.write(`${value}\n`)
}
export function println(value = '') {
process.stdout.write(`${value}\n`)
}

View File

@ -0,0 +1,4 @@
import { createRequire } from 'node:module'
export const resolve =
typeof require?.resolve !== 'undefined' ? require.resolve : createRequire(import.meta.url).resolve

View File

@ -0,0 +1,251 @@
import { rule, type AstNode, type Rule } from './ast'
import { parseCandidate, parseVariant, type Candidate, type Variant } from './candidate'
import { type DesignSystem } from './design-system'
import GLOBAL_PROPERTY_ORDER from './property-order'
import { DefaultMap } from './utils/default-map'
import { escape } from './utils/escape'
import type { Variants } from './variants'
export function compileCandidates(
rawCandidates: string[],
designSystem: DesignSystem,
{ throwOnInvalidCandidate = false } = {},
) {
// Ensure the candidates are sorted alphabetically
rawCandidates.sort()
let nodeSorting = new Map<
AstNode,
{ properties: number[]; variants: bigint; candidate: string }
>()
let astNodes: AstNode[] = []
// A lazy map implementation that will return the variant if it exists. If it
// doesn't exist yet, the raw string variant will be parsed and added to the
// map.
let parsedVariants: DefaultMap<string, Variant | null> = new DefaultMap((variant, map) => {
return parseVariant(variant, designSystem.variants, map)
})
let candidates = new Map<Candidate, string>()
// Parse candidates and variants
for (let rawCandidate of rawCandidates) {
let candidate = parseCandidate(rawCandidate, designSystem.utilities, parsedVariants)
if (candidate === null) {
if (throwOnInvalidCandidate) {
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
}
continue // Bail, invalid candidate
}
candidates.set(candidate, rawCandidate)
}
// Sort the variants
let variants = Array.from(parsedVariants.values()).sort((a, z) => {
return designSystem.variants.compare(a, z)
})
// Create the AST
next: for (let [candidate, rawCandidate] of candidates) {
let nodes: AstNode[] = []
// Handle arbitrary properties
if (candidate.kind === 'arbitrary') {
let compileFn = designSystem.utilities.getArbitrary()
// Build the node
let compiledNodes = compileFn(candidate)
if (compiledNodes === undefined) {
if (throwOnInvalidCandidate) {
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
}
continue next
}
nodes = compiledNodes
}
// Handle named utilities
else if (candidate.kind === 'static' || candidate.kind === 'functional') {
// Safety: At this point it is safe to use TypeScript's non-null assertion
// operator because if the `candidate.root` didn't exist, `parseCandidate`
// would have returned `null` and we would have returned early resulting
// in not hitting this code path.
let { compileFn } = designSystem.utilities.get(candidate.root)!
// Build the node
let compiledNodes = compileFn(candidate)
if (compiledNodes === undefined) {
if (throwOnInvalidCandidate) {
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
}
continue next
}
nodes = compiledNodes
}
let propertySort = getPropertySort(nodes)
if (candidate.important) {
applyImportant(nodes)
}
let node: Rule = {
kind: 'rule',
selector: `.${escape(rawCandidate)}`,
nodes,
}
let variantOrder = 0n
for (let variant of candidate.variants) {
let result = applyVariant(node, variant, designSystem.variants)
// When the variant results in `null`, it means that the variant cannot be
// applied to the rule. Discard the candidate and continue to the next
// one.
if (result === null) {
if (throwOnInvalidCandidate) {
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
}
continue next
}
// Track the variant order which is a number with each bit representing a
// variant. This allows us to sort the rules based on the order of
// variants used.
variantOrder |= 1n << BigInt(variants.indexOf(variant))
}
nodeSorting.set(node, {
properties: propertySort,
variants: variantOrder,
candidate: rawCandidate,
})
astNodes.push(node)
}
astNodes.sort((a, z) => {
// Safety: At this point it is safe to use TypeScript's non-null assertion
// operator because if the ast nodes didn't exist, we introduced a bug
// above, but there is no need to re-check just to be sure. If this relied
// on pure user input, then we would need to check for its existence.
let aSorting = nodeSorting.get(a)!
let zSorting = nodeSorting.get(z)!
// Sort by variant order first
if (aSorting.variants - zSorting.variants !== 0n) {
return Number(aSorting.variants - zSorting.variants)
}
// Find the first property that is different between the two rules
let offset = 0
while (
aSorting.properties.length < offset &&
zSorting.properties.length < offset &&
aSorting.properties[offset] === zSorting.properties[offset]
) {
offset += 1
}
return (
// Sort by lowest property index first
(aSorting.properties[offset] ?? Infinity) - (zSorting.properties[offset] ?? Infinity) ||
// Sort by most properties first, then by least properties
zSorting.properties.length - aSorting.properties.length
)
})
return {
astNodes,
nodeSorting,
}
}
export function applyVariant(node: Rule, variant: Variant, variants: Variants): null | void {
if (variant.kind === 'arbitrary') {
node.nodes = [rule(variant.selector, node.nodes)]
return
}
// Safety: At this point it is safe to use TypeScript's non-null assertion
// operator because if the `candidate.root` didn't exist, `parseCandidate`
// would have returned `null` and we would have returned early resulting in
// not hitting this code path.
let { applyFn } = variants.get(variant.root)!
if (variant.kind === 'compound') {
let result = applyVariant(node, variant.variant, variants)
if (result === null) return null
for (let child of node.nodes) {
// Only some variants wrap children in rules. For example, the `force`
// variant is a noop on the AST. And the `has` variant modifies the
// selector rather than the children.
//
// This means `child` may be a declaration and we don't want to apply the
// variant to it. This also means the entire variant as a whole is not
// applicable to the rule and should generate nothing.
if (child.kind !== 'rule') return null
let result = applyFn(child as Rule, variant)
if (result === null) return null
}
return
}
// All other variants
let result = applyFn(node, variant)
if (result === null) return null
}
function applyImportant(ast: AstNode[]): void {
for (let node of ast) {
// Skip any `@at-root` rules — we don't want to make the contents of things
// like `@keyframes` or `@property` important.
if (node.kind === 'rule' && node.selector === '@at-root') {
continue
}
if (node.kind === 'declaration') {
node.important = true
} else if (node.kind === 'rule') {
applyImportant(node.nodes)
}
}
}
function getPropertySort(nodes: AstNode[]) {
// Determine sort order based on properties used
let propertySort = new Set<number>()
let q: AstNode[] = nodes.slice()
while (q.length > 0) {
// Safety: At this point it is safe to use TypeScript's non-null assertion
// operator because we guarded against `q.length > 0` above.
let node = q.shift()!
if (node.kind === 'declaration') {
if (node.property === '--tw-sort') {
let idx = GLOBAL_PROPERTY_ORDER.indexOf(node.value)
if (idx !== -1) {
propertySort.add(idx)
break
}
}
let idx = GLOBAL_PROPERTY_ORDER.indexOf(node.property)
if (idx !== -1) propertySort.add(idx)
} else if (node.kind === 'rule') {
// Don't consider properties within `@at-root` when determining the sort
// order for a rule.
if (node.selector === '@at-root') continue
for (let child of node.nodes) {
q.push(child)
}
}
}
return Array.from(propertySort).sort((a, z) => a - z)
}

View File

@ -0,0 +1,995 @@
import { describe, expect, it } from 'vitest'
import { parse } from './css-parser'
const css = String.raw
describe('comments', () => {
it('should parse a comment and ignore it', () => {
expect(
parse(css`
/*Hello, world!*/
`),
).toEqual([])
})
it('should parse a comment with an escaped ending and ignore it', () => {
expect(
parse(css`
/*Hello, \*\/ world!*/
`),
).toEqual([])
})
it('should parse a comment inside of a selector and ignore it', () => {
expect(
parse(css`
.foo {
/*Example comment*/
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [],
},
])
})
it('should remove comments in between selectors while maintaining the correct whitespace', () => {
expect(
parse(css`
.foo/*.bar*/.baz {
}
.foo/*.bar*//*.baz*/.qux
{
}
.foo/*.bar*/ /*.baz*/.qux {
/* ^ whitespace */
}
.foo /*.bar*/.baz {
/*^ whitespace */
}
.foo/*.bar*/ .baz {
/* ^ whitespace */
}
.foo/*.bar*/
.baz {
}
`),
).toEqual([
{ kind: 'rule', selector: '.foo.baz', nodes: [] },
{ kind: 'rule', selector: '.foo.qux', nodes: [] },
{ kind: 'rule', selector: '.foo .qux', nodes: [] },
{ kind: 'rule', selector: '.foo .baz', nodes: [] },
{ kind: 'rule', selector: '.foo .baz', nodes: [] },
{ kind: 'rule', selector: '.foo .baz', nodes: [] },
])
})
it('should collect license comments', () => {
expect(
parse(css`
/*! License #1 */
/*!
* License #2
*/
`),
).toEqual([
{ kind: 'comment', value: '! License #1 ' },
{
kind: 'comment',
value: `!
* License #2
`,
},
])
})
it('should hoist all license comments', () => {
expect(
parse(css`
/*! License #1 */
.foo {
color: red; /*! License #1.5 */
}
/*! License #2 */
.bar {
/*! License #2.5 */
color: blue;
}
/*! License #3 */
`),
).toEqual([
{ kind: 'comment', value: '! License #1 ' },
{ kind: 'comment', value: '! License #1.5 ' },
{ kind: 'comment', value: '! License #2 ' },
{ kind: 'comment', value: '! License #2.5 ' },
{ kind: 'comment', value: '! License #3 ' },
{
kind: 'rule',
selector: '.foo',
nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }],
},
{
kind: 'rule',
selector: '.bar',
nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }],
},
])
})
it('should handle comments before element selectors', () => {
expect(
parse(css`
.dark /* comment */p {
color: black;
}
`),
).toEqual([
{
kind: 'rule',
selector: '.dark p',
nodes: [
{
kind: 'declaration',
property: 'color',
value: 'black',
important: false,
},
],
},
])
})
})
describe('declarations', () => {
it('should parse a simple declaration', () => {
expect(
parse(css`
color: red;
`),
).toEqual([{ kind: 'declaration', property: 'color', value: 'red', important: false }])
})
it('should parse declarations with strings', () => {
expect(
parse(css`
content: 'Hello, world!';
`),
).toEqual([
{ kind: 'declaration', property: 'content', value: "'Hello, world!'", important: false },
])
})
it('should parse declarations with nested strings', () => {
expect(
parse(css`
content: 'Good, "monday", morning!';
`),
).toEqual([
{
kind: 'declaration',
property: 'content',
value: `'Good, "monday", morning!'`,
important: false,
},
])
})
it('should parse declarations with nested strings that are not balanced', () => {
expect(
parse(css`
content: "It's a beautiful day!";
`),
).toEqual([
{
kind: 'declaration',
property: 'content',
value: `"It's a beautiful day!"`,
important: false,
},
])
})
it('should parse declarations with with strings and escaped string endings', () => {
expect(
parse(css`
content: 'These are not the end "\' of the string';
`),
).toEqual([
{
kind: 'declaration',
property: 'content',
value: `'These are not the end \"\\' of the string'`,
important: false,
},
])
})
describe('important', () => {
it('should parse declarations with `!important`', () => {
expect(
parse(css`
width: 123px !important;
`),
).toEqual([
{
kind: 'declaration',
property: 'width',
value: '123px',
important: true,
},
])
})
it('should parse declarations with `!important` when there is a trailing comment', () => {
expect(
parse(css`
width: 123px !important /* Very important */;
`),
).toEqual([
{
kind: 'declaration',
property: 'width',
value: '123px',
important: true,
},
])
})
})
describe('Custom properties', () => {
it('should parse a custom property', () => {
expect(
parse(css`
--foo: bar;
`),
).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: false }])
})
it('should parse a minified custom property', () => {
expect(parse(':root{--foo:bar;}')).toEqual([
{
kind: 'rule',
selector: ':root',
nodes: [
{
kind: 'declaration',
property: '--foo',
value: 'bar',
important: false,
},
],
},
])
})
it('should parse a minified custom property with no semicolon ', () => {
expect(parse(':root{--foo:bar}')).toEqual([
{
kind: 'rule',
selector: ':root',
nodes: [
{
kind: 'declaration',
property: '--foo',
value: 'bar',
important: false,
},
],
},
])
})
it('should parse a custom property with a missing ending `;`', () => {
expect(
parse(`
--foo: bar
`),
).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: false }])
})
it('should parse a custom property with a missing ending `;` and `!important`', () => {
expect(
parse(`
--foo: bar !important
`),
).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: true }])
})
it('should parse a custom property with an embedded programming language', () => {
expect(
parse(css`
--foo: if(x > 5) this.width = 10;
`),
).toEqual([
{
kind: 'declaration',
property: '--foo',
value: 'if(x > 5) this.width = 10',
important: false,
},
])
})
it('should parse a custom property with an empty block as the value', () => {
expect(parse('--foo: {};')).toEqual([
{
kind: 'declaration',
property: '--foo',
value: '{}',
important: false,
},
])
})
it('should parse a custom property with a block including nested "css"', () => {
expect(
parse(css`
--foo: {
background-color: red;
/* A comment */
content: 'Hello, world!';
};
`),
).toEqual([
{
kind: 'declaration',
property: '--foo',
value: `{
background-color: red;
/* A comment */
content: 'Hello, world!';
}`,
important: false,
},
])
})
it('should parse a custom property with a block including nested "css" and comments with end characters inside them', () => {
expect(
parse(css`
--foo: {
background-color: red;
/* A comment ; */
content: 'Hello, world!';
};
--bar: {
background-color: red;
/* A comment } */
content: 'Hello, world!';
};
`),
).toEqual([
{
kind: 'declaration',
property: '--foo',
value: `{
background-color: red;
/* A comment ; */
content: 'Hello, world!';
}`,
important: false,
},
{
kind: 'declaration',
property: '--bar',
value: `{
background-color: red;
/* A comment } */
content: 'Hello, world!';
}`,
important: false,
},
])
})
it('should parse a custom property with escaped characters in the value', () => {
expect(
parse(css`
--foo: This is not the end \;, but this is;
`),
).toEqual([
{
kind: 'declaration',
property: '--foo',
value: 'This is not the end \\;, but this is',
important: false,
},
])
})
it('should parse a custom property with escaped characters inside a comment in the value', () => {
expect(
parse(css`
--foo: /* This is not the end \; this is also not the end ; */ but this is;
`),
).toEqual([
{
kind: 'declaration',
property: '--foo',
value: '/* This is not the end \\; this is also not the end ; */ but this is',
important: false,
},
])
})
it('should parse empty custom properties', () => {
expect(
parse(css`
--foo: ;
`),
).toEqual([{ kind: 'declaration', property: '--foo', value: '', important: false }])
})
it('should parse custom properties with `!important`', () => {
expect(
parse(css`
--foo: bar !important;
`),
).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: true }])
})
})
it('should parse multiple declarations', () => {
expect(
parse(css`
color: red;
background-color: blue;
`),
).toEqual([
{ kind: 'declaration', property: 'color', value: 'red', important: false },
{ kind: 'declaration', property: 'background-color', value: 'blue', important: false },
])
})
/**
*
*/
it('should correctly parse comments with `:` inside of them', () => {
expect(
parse(css`
color/* color: #f00; */: red;
font-weight:/* font-size: 12px */ bold;
.foo {
background-color/* background-color: #f00; */: red;
}
`),
).toEqual([
{ kind: 'declaration', property: 'color', value: 'red', important: false },
{ kind: 'declaration', property: 'font-weight', value: 'bold', important: false },
{
kind: 'rule',
selector: '.foo',
nodes: [
{
kind: 'declaration',
property: 'background-color',
value: 'red',
important: false,
},
],
},
])
})
it('should parse mutlti-line declarations', () => {
expect(
parse(css`
.foo {
grid-template-areas:
'header header header'
'sidebar main main'
'footer footer footer';
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [
{
kind: 'declaration',
property: 'grid-template-areas',
value: "'header header header' 'sidebar main main' 'footer footer footer'",
important: false,
},
],
},
])
})
})
describe('selectors', () => {
it('should parse a simple selector', () => {
expect(
parse(css`
.foo {
}
`),
).toEqual([{ kind: 'rule', selector: '.foo', nodes: [] }])
})
it('should parse selectors with escaped characters', () => {
expect(
parse(css`
.hover\:foo:hover {
}
.\32 xl\:foo {
}
`),
).toEqual([
{ kind: 'rule', selector: '.hover\\:foo:hover', nodes: [] },
{ kind: 'rule', selector: '.\\32 xl\\:foo', nodes: [] },
])
})
it('should parse multiple simple selectors', () => {
expect(
parse(css`
.foo,
.bar {
}
`),
).toEqual([{ kind: 'rule', selector: '.foo, .bar', nodes: [] }])
})
it('should parse multiple declarations inside of a selector', () => {
expect(
parse(css`
.foo {
color: red;
font-size: 16px;
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [
{ kind: 'declaration', property: 'color', value: 'red', important: false },
{ kind: 'declaration', property: 'font-size', value: '16px', important: false },
],
},
])
})
it('should parse rules with declarations that end with a missing `;`', () => {
expect(
parse(`
.foo {
color: red;
font-size: 16px
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [
{ kind: 'declaration', property: 'color', value: 'red', important: false },
{ kind: 'declaration', property: 'font-size', value: '16px', important: false },
],
},
])
})
it('should parse rules with declarations that end with a missing `;` and `!important`', () => {
expect(
parse(`
.foo {
color: red;
font-size: 16px !important
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [
{ kind: 'declaration', property: 'color', value: 'red', important: false },
{ kind: 'declaration', property: 'font-size', value: '16px', important: true },
],
},
])
})
})
describe('at-rules', () => {
it('should parse an at-rule without a block', () => {
expect(
parse(css`
@charset "UTF-8";
`),
).toEqual([{ kind: 'rule', selector: '@charset "UTF-8"', nodes: [] }])
})
it("should parse an at-rule without a block or semicolon when it's the last rule in a block", () => {
expect(
parse(`
@layer utilities {
@tailwind utilities
}
`),
).toEqual([
{
kind: 'rule',
selector: '@layer utilities',
nodes: [{ kind: 'rule', selector: '@tailwind utilities', nodes: [] }],
},
])
})
it('should parse a nested at-rule without a block', () => {
expect(
parse(css`
@layer utilities {
@charset "UTF-8";
}
.foo {
@apply font-bold hover:text-red-500;
}
`),
).toEqual([
{
kind: 'rule',
selector: '@layer utilities',
nodes: [{ kind: 'rule', selector: '@charset "UTF-8"', nodes: [] }],
},
{
kind: 'rule',
selector: '.foo',
nodes: [{ kind: 'rule', selector: '@apply font-bold hover:text-red-500', nodes: [] }],
},
])
})
it('should parse custom at-rules without a block', () => {
expect(
parse(css`
@tailwind;
@tailwind base;
`),
).toEqual([
{ kind: 'rule', selector: '@tailwind', nodes: [] },
{ kind: 'rule', selector: '@tailwind base', nodes: [] },
])
})
it('should parse (nested) media queries', () => {
expect(
parse(css`
@media (width >= 600px) {
.foo {
color: red;
@media (width >= 800px) {
color: blue;
}
@media (width >= 1000px) {
color: green;
}
}
}
`),
).toEqual([
{
kind: 'rule',
selector: '@media (width >= 600px)',
nodes: [
{
kind: 'rule',
selector: '.foo',
nodes: [
{ kind: 'declaration', property: 'color', value: 'red', important: false },
{
kind: 'rule',
selector: '@media (width >= 800px)',
nodes: [
{ kind: 'declaration', property: 'color', value: 'blue', important: false },
],
},
{
kind: 'rule',
selector: '@media (width >= 1000px)',
nodes: [
{ kind: 'declaration', property: 'color', value: 'green', important: false },
],
},
],
},
],
},
])
})
it('should parse at-rules that span multiple lines', () => {
expect(
parse(css`
.foo {
@apply hover:text-red-100
sm:hover:text-red-200
md:hover:text-red-300
lg:hover:text-red-400
xl:hover:text-red-500;
}
`),
).toEqual([
{
kind: 'rule',
nodes: [
{
kind: 'rule',
nodes: [],
selector:
'@apply hover:text-red-100 sm:hover:text-red-200 md:hover:text-red-300 lg:hover:text-red-400 xl:hover:text-red-500',
},
],
selector: '.foo',
},
])
})
})
describe('nesting', () => {
it('should parse nested rules', () => {
expect(
parse(css`
.foo {
.bar {
.baz {
color: red;
}
}
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [
{
kind: 'rule',
selector: '.bar',
nodes: [
{
kind: 'rule',
selector: '.baz',
nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }],
},
],
},
],
},
])
})
it('should parse nested selector with `&`', () => {
expect(
parse(css`
.foo {
color: red;
&:hover {
color: blue;
}
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [
{ kind: 'declaration', property: 'color', value: 'red', important: false },
{
kind: 'rule',
selector: '&:hover',
nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }],
},
],
},
])
})
it('should parse nested sibling selectors', () => {
expect(
parse(css`
.foo {
.bar {
color: red;
}
.baz {
color: blue;
}
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [
{
kind: 'rule',
selector: '.bar',
nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }],
},
{
kind: 'rule',
selector: '.baz',
nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }],
},
],
},
])
})
it('should parse nested sibling selectors and sibling declarations', () => {
expect(
parse(css`
.foo {
font-weight: bold;
text-declaration-line: underline;
.bar {
color: red;
}
--in-between: 1;
.baz {
color: blue;
}
--at-the-end: 2;
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [
{ kind: 'declaration', property: 'font-weight', value: 'bold', important: false },
{
kind: 'declaration',
property: 'text-declaration-line',
value: 'underline',
important: false,
},
{
kind: 'rule',
selector: '.bar',
nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }],
},
{ kind: 'declaration', property: '--in-between', value: '1', important: false },
{
kind: 'rule',
selector: '.baz',
nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }],
},
{ kind: 'declaration', property: '--at-the-end', value: '2', important: false },
],
},
])
})
})
describe('complex', () => {
it('should parse complex examples', () => {
expect(
parse(css`
@custom \{ {
foo: bar;
}
`),
).toEqual([
{
kind: 'rule',
selector: '@custom \\{',
nodes: [{ kind: 'declaration', property: 'foo', value: 'bar', important: false }],
},
])
})
it('should parse minified nested CSS', () => {
expect(
parse('.foo{color:red;@media(width>=600px){.bar{color:blue;font-weight:bold}}}'),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [
{ kind: 'declaration', property: 'color', value: 'red', important: false },
{
kind: 'rule',
selector: '@media(width>=600px)',
nodes: [
{
kind: 'rule',
selector: '.bar',
nodes: [
{ kind: 'declaration', property: 'color', value: 'blue', important: false },
{ kind: 'declaration', property: 'font-weight', value: 'bold', important: false },
],
},
],
},
],
},
])
})
it('should ignore everything inside of comments', () => {
expect(
parse(css`
.foo:has(.bar /* instead \*\/ of .baz { */) {
color: red;
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo:has(.bar )',
nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }],
},
])
})
})
describe('errors', () => {
it('should error when curly brackets are unbalanced (opening)', () => {
expect(() =>
parse(`
.foo {
color: red;
}
.bar
/* ^ Missing opening { */
color: blue;
}
`),
).toThrowErrorMatchingInlineSnapshot(`[Error: Missing opening {]`)
})
it('should error when curly brackets are unbalanced (closing)', () => {
expect(() =>
parse(`
.foo {
color: red;
}
.bar {
color: blue;
/* ^ Missing closing } */
`),
).toThrowErrorMatchingInlineSnapshot(`[Error: Missing closing } at .bar]`)
})
it('should error when an unterminated string is used', () => {
expect(() =>
parse(css`
.foo {
content: "Hello world!
/* ^ missing " */
font-weight: bold;
}
`),
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!"]`)
})
it('should error when an unterminated string is used with a `;`', () => {
expect(() =>
parse(css`
.foo {
content: "Hello world!;
/* ^ missing " */
font-weight: bold;
}
`),
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!;"]`)
})
})

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