diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index e997fb825f6f..620d13ae2377 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -100,3 +100,86 @@ jobs: with: webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} message: 'The [most recent build](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.' + + bun-tests: + strategy: + fail-fast: false + matrix: + bun-version: [1.2.2] + runner: + - name: Windows + os: windows-latest + + - name: Linux + os: ubuntu-latest + + - name: macOS + os: macos-14 + + # Exclude windows and macos from being built on feature branches + on-main-branch: + - ${{ github.ref == 'refs/heads/main' }} + exclude: + - on-main-branch: false + runner: + name: Windows + - on-main-branch: false + runner: + name: macOS + + runs-on: ${{ matrix.runner.os }} + timeout-minutes: 30 + + name: Bun / ${{ matrix.runner.name }} + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: ${{ matrix.bun-version }} + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-bun- + + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v4 + 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@v4 + with: + path: | + ./target/ + ./crates/node/*.node + ./crates/node/index.js + ./crates/node/index.d.ts + key: ${{ runner.os }}-oxide-${{ hashFiles('./crates/**/*') }} + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm run build + env: + CARGO_PROFILE_RELEASE_LTO: 'off' + CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER: 'lld-link' + + - name: Test + run: pnpm vitest --root=./integrations bun diff --git a/Cargo.lock b/Cargo.lock index f742de3eefb0..ffe8b91d407a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anyhow" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" + [[package]] name = "arrayvec" version = "0.7.6" @@ -44,6 +50,28 @@ dependencies = [ "serde", ] +[[package]] +name = "bun-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9b9082118e799fe4ec203a283da1cd85eb0b0c8aa21a4c61ed96dd412c1862b" +dependencies = [ + "anyhow", + "napi", + "quote", + "syn", +] + +[[package]] +name = "bun-native-plugin" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e433ce0299dab021217de9c52049b571208da6d879972e55765f901a62eec9e" +dependencies = [ + "anyhow", + "bun-macro", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -268,9 +296,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "napi" -version = "2.16.11" +version = "2.16.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53575dfa17f208dd1ce3a2da2da4659aae393b256a472f2738a8586a6c4107fd" +checksum = "d3437deb8b6ba2448b6a94260c5c6b9e5eeb5a5d6277e44b40b2532d457b0f0d" dependencies = [ "bitflags", "ctor", @@ -518,10 +546,12 @@ dependencies = [ name = "tailwind-oxide" version = "0.0.0" dependencies = [ + "bun-native-plugin", "napi", "napi-build", "napi-derive", "rayon", + "rustc-hash", "tailwindcss-oxide", ] diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 0009076936ab..461708c381f1 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -8,10 +8,12 @@ crate-type = ["cdylib"] [dependencies] # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix -napi = { version = "2.16.11", default-features = false, features = ["napi4"] } +napi = { version = "2.16.15", default-features = false, features = ["napi4"] } napi-derive = "2.16.12" tailwindcss-oxide = { path = "../oxide" } rayon = "1.5.3" +bun-native-plugin = { version = "0.2.0" } +fxhash = { package = "rustc-hash", version = "2.0.0" } [build-dependencies] napi-build = "2.0.1" diff --git a/crates/node/export_native_binding.js b/crates/node/export_native_binding.js new file mode 100644 index 000000000000..100d43b6f553 --- /dev/null +++ b/crates/node/export_native_binding.js @@ -0,0 +1,16 @@ +/** + * This code modifies the `index.js` JS glue code generated by napi-rs to export the `require()`'d napi module. + * This is needed for the native bun plugin implementation. + */ +const fs = require('fs') +const path = require('path') + +const indexPath = path.join(__dirname, 'index.js') +const exportLine = '\nmodule.exports.nativeBinding = nativeBinding;\n' + +fs.appendFileSync(indexPath, exportLine) + +const indexDtsPath = path.join(__dirname, 'index.d.ts') +const exportDtsLine = '\nexport declare const nativeBinding: unknown;\n' + +fs.appendFileSync(indexDtsPath, exportDtsLine) diff --git a/crates/node/package.json b/crates/node/package.json index b97cbaa511fa..ce7e0455c0b5 100644 --- a/crates/node/package.json +++ b/crates/node/package.json @@ -42,9 +42,9 @@ }, "scripts": { "artifacts": "napi artifacts", - "build": "napi build --platform --release --no-const-enum", + "build": "napi build --platform --release --no-const-enum && node export_native_binding.js", "dev": "cargo watch --quiet --shell 'npm run build'", - "build:debug": "napi build --platform --no-const-enum", + "build:debug": "napi build --platform --no-const-enum && node export_native_binding.js", "version": "napi version" }, "optionalDependencies": { diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 2eff57be308d..f86d1c62e654 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -1,3 +1,12 @@ +use std::ops::Deref; +use std::sync::{atomic::AtomicBool, Mutex}; + +use bun_native_plugin::{anyhow, bun, define_bun_plugin, Error, Result}; +use fxhash::{FxHashMap, FxHashSet}; +use napi::{ + bindgen_prelude::{Array, External}, + Env, Result as NapiResult, +}; use utf16::IndexConverter; #[macro_use] @@ -148,3 +157,138 @@ impl Scanner { .collect() } } + +/// State which contains the scanned candidates from the module graph. +/// +/// This is turned into a Napi External so the JS plugin can hold it and +/// eventually request the candidates to be turned in to JS. +/// +/// The state inside this struct must be threadsafe as it could be accessed from +/// the JS thread as well as the other bundler threads. +#[derive(Default)] +pub struct TailwindContextExternal { + /// Candidates scanned from the module graph. + module_graph_candidates: Mutex>>, + /// Atomic flag to indicate whether the state has been changed. + dirty: AtomicBool, +} + +define_bun_plugin!("tailwindcss"); + +/// Create the TailwindContextExternal and return it to JS wrapped in a Napi External. +#[no_mangle] +#[napi] +pub fn twctx_create() -> External { + let external = External::new(TailwindContextExternal { + module_graph_candidates: Default::default(), + dirty: AtomicBool::new(false), + }); + + external +} + +#[napi(object)] +struct CandidatesEntry { + pub id: String, + pub candidates: Vec, +} + +/// Convert the scanned candidates into a JS array of objects so the JS code can +/// use it. +#[no_mangle] +#[napi] +pub fn twctx_to_js(env: Env, ctx: External) -> NapiResult { + let candidates = ctx.module_graph_candidates.lock().map_err(|_| { + napi::Error::new( + napi::Status::WouldDeadlock, + "Failed to acquire lock on candidates: another thread panicked while holding the lock.", + ) + })?; + + let len: u32 = candidates.len().try_into().map_err(|_| { + napi::Error::new( + napi::Status::InvalidArg, + format!("Too many candidates: {}", candidates.len()), + ) + })?; + + let mut arr = env.create_array(len)?; + + // TODO: Creating objects and copying/convertings strings is slow in NAPI. + // We could use a more efficient approach: + // 1. Flat array of de-duped candidate strings + // 2. Flat array of int32 (or smaller) indices into the candidate array as well as lengths + // 3. A flat array of ids + // + // However, it is unclear how much of a performance difference this makes as this array will + // get turned into a set inside the corresponding JS code. + for (i, (id, candidates)) in candidates.iter().enumerate() { + let mut obj = env.create_object()?; + obj.set("id", id)?; + obj.set("candidates", candidates.iter().collect::>())?; + arr.set(i as u32, obj)?; + } + + Ok(arr) +} + +/// This function can be called from JS to check if the state has been changed and to +/// then call `twctx_to_js` to convert the candidates into JS values. +#[no_mangle] +#[napi] +pub fn twctx_is_dirty(_env: Env, ctx: External) -> NapiResult { + Ok(ctx.dirty.load(std::sync::atomic::Ordering::Acquire)) +} + +/// This is the main native bundler plugin function. +/// +/// It is executed for every file that matches the regex (see the `.onBeforeParse` call in `@tailwindcss-bun/src/index.ts`). +/// +/// This function is essentially given as input the source code to the file before it is parsed by Bun. It uses this to +/// scan it for potential candidates. +/// +/// Care must be taken to ensure that this code is threadsafe as it could be executing concurrrently on multiple of Bun's bundler +/// threads. +#[bun] +pub fn tw_on_before_parse(handle: &mut OnBeforeParse) -> Result<()> { + let source_code = handle.input_source_code()?; + + let mut scanner = tailwindcss_oxide::Scanner::new(None); + + let candidates = scanner.scan_content(vec![tailwindcss_oxide::ChangedContent { + content: Some(source_code.to_string()), + file: None, + }]); + + // If we found candidates, update our state + if !candidates.is_empty() { + let tw_ctx: &TailwindContextExternal = unsafe { + handle + .external(External::inner_from_raw) + .and_then(|tw| tw.ok_or(Error::Unknown))? + }; + + let mut graph_candidates = tw_ctx.module_graph_candidates.lock().map_err(|_| { + anyhow::Error::msg( + "Failed to acquire lock on candidates: another thread panicked while holding the lock", + ) + })?; + + tw_ctx + .dirty + .store(true, std::sync::atomic::Ordering::Release); + + let path = handle.path()?; + + if let Some(graph_candidates) = graph_candidates.get_mut(path.deref()) { + graph_candidates.extend(candidates.into_iter()); + } else { + graph_candidates.insert(path.to_string(), candidates.into_iter().collect()); + } + } + + let loader = handle.output_loader(); + handle.set_output_loader(loader); + + Ok(()) +} diff --git a/integrations/bun/index.test.ts b/integrations/bun/index.test.ts new file mode 100644 index 000000000000..12aa18b860f9 --- /dev/null +++ b/integrations/bun/index.test.ts @@ -0,0 +1,938 @@ +import path from 'node:path' +import { describe, vi } from 'vitest' +import { + candidate, + css, + fetchStyles, + html, + js, + json, + retryAssertion, + test, + ts, + txt, + yaml, +} from '../utils' + +let port = 49000 + Math.floor(Math.random() * 1000) + +const bunServer = (routes: Record) => ts/* ts */ ` + ${Object.values(routes) + .map(({ import: importIdentifier, path }) => `import ${importIdentifier} from '${path}'`) + .join('\n')} + + let port = process.env.PORT + let remainingTries = 5 + if (!port) throw new Error('PORT is not set') + + let server + while (remainingTries > 0) { + try { + server = Bun.serve({ + static: { + ${Object.entries(routes) + .map( + ([route, { import: importIdentifier }], i) => + `'${route}': ${importIdentifier}${i === Object.keys(routes).length - 1 ? '' : ','}`, + ) + .join('\n')}, + }, + port, + fetch(req) { + return new Response('Not found', { status: 404 }) + }, + }) + console.log('ready in LOL') + console.log(' http://localhost:' + port + '/') + break + } catch (error) { + if (error.code === 'EADDRINUSE') { + remainingTries-- + port++ + continue + } + throw error + } + } +` + +describe('Bun', () => { + vi.setConfig({ sequence: { concurrent: false } }) + + test( + `production build`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/bun": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + } + } + `, + 'project-a/build.ts': ts` + import tailwindcss from '@tailwindcss/bun' + + const result = await Bun.build({ + entrypoints: ['./index.html'], + outdir: './dist', + plugins: [tailwindcss], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/tailwind.config.js': js` + export default { + content: ['../project-b/src/**/*.js'], + } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss/theme' theme(reference); + @import 'tailwindcss/utilities'; + @config '../tailwind.config.js'; + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, fs, exec, expect }) => { + await exec('bun build.ts', { cwd: path.join(root, 'project-a') }) + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] + + await fs.expectFileToContain(filename, [ + candidate`underline`, + candidate`m-2`, + candidate`flex`, + candidate`content-['project-b/src/index.js']`, + ]) + }, + {}, + ) + + test( + 'dev mode', + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/bun": "workspace:^", + "tailwindcss": "workspace:^" + } + } + `, + 'project-a/serve.ts': bunServer({ + '/': { import: 'index', path: './index.html' }, + '/about': { import: 'about', path: './about.html' }, + }), + 'project-a/bunfig.toml': ` + [serve.static] + plugins = ["@tailwindcss/bun"] + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/about.html': html` + + + + +
Tailwind Labs
+ + `, + 'project-a/tailwind.config.js': js` + export default { + content: ['../project-b/src/**/*.js'], + } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss/theme' theme(reference); + @import 'tailwindcss/utilities'; + @config '../tailwind.config.js'; + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, spawn, fs, expect }) => { + let process = await spawn(`bun serve.ts`, { + cwd: path.join(root, 'project-a'), + env: { + PORT: `${port++}`, + }, + }) + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + // Candidates are resolved lazily, so the first visit of index.html + // will only have candidates from this file. + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/') + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).not.toContain(candidate`font-bold`) + }) + + // Going to about.html will extend the candidate list to include + // candidates from about.html. + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/about') + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`font-bold`) + }) + + await retryAssertion(async () => { + // Updates are additive and cause new candidates to be added. + await fs.write( + 'project-a/index.html', + html` + + + + +
Hello, world!
+ + `, + ) + + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`font-bold`) + expect(styles).toContain(candidate`m-2`) + }) + + await retryAssertion(async () => { + // Manually added `@source`s are watched and trigger a rebuild + await fs.write( + 'project-b/src/index.js', + js` + const className = "[.changed_&]:content-['project-b/src/index.js']" + module.exports = { className } + `, + ) + + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`font-bold`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + }) + + await retryAssertion(async () => { + // After updates to the CSS file, all previous candidates should still be in + // the generated CSS + await fs.write( + 'project-a/src/index.css', + css` + ${await fs.read('project-a/src/index.css')} + + .red { + color: red; + } + `, + ) + + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`red`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + expect(styles).toContain(candidate`font-bold`) + }) + }, + ) + + test( + 'dev mode directly importing from an html file', + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/serve.ts': bunServer({ + '/': { import: 'index', path: './index.html' }, + }), + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/bun": "workspace:^", + "tailwindcss": "workspace:^" + } + } + `, + 'project-a/bunfig.toml': ` + [serve.static] + plugins = ["@tailwindcss/bun"] + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + }, + }, + async ({ root, spawn, fs, expect }) => { + let process = await spawn(`bun serve.ts`, { + cwd: path.join(root, 'project-a'), + env: { + PORT: `${port++}`, + }, + }) + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + // Candidates are resolved lazily, so the first visit of index.html + // will only have candidates from this file. + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/') + expect(styles).toContain(candidate`underline`) + expect(styles).not.toContain(candidate`font-bold`) + }) + }, + ) + + // TODO: Add watch mode test once Bun supports it + // test( + // 'watch mode', + // { + // fs: { + // 'package.json': json`{}`, + // 'pnpm-workspace.yaml': yaml` + // # + // packages: + // - project-a + // `, + // 'project-a/package.json': txt` + // { + // "type": "module", + // "dependencies": { + // "@tailwindcss/bun": "workspace:^", + // "tailwindcss": "workspace:^" + // } + // } + // `, + // 'project-a/vite.config.ts': ts` + // import tailwindcss from '@tailwindcss/vite' + // import { defineConfig } from 'vite' + + // export default defineConfig({ + // build: { cssMinify: false }, + // plugins: [tailwindcss()], + // }) + // `, + // 'project-a/index.html': html` + // + // + // + // + //
Hello, world!
+ // + // `, + // 'project-a/tailwind.config.js': js` + // export default { + // content: ['../project-b/src/**/*.js'], + // } + // `, + // 'project-a/src/index.css': css` + // @import 'tailwindcss/theme' theme(reference); + // @import 'tailwindcss/utilities'; + // @import './custom-theme.css'; + // @config '../tailwind.config.js'; + // @source '../../project-b/src/**/*.html'; + // `, + // 'project-a/src/custom-theme.css': css` + // /* Will be overwritten later */ + // @theme { + // --color-primary: black; + // } + // `, + // 'project-b/src/index.html': html` + //
+ // `, + // 'project-b/src/index.js': js` + // const className = "content-['project-b/src/index.js']" + // module.exports = { className } + // `, + // }, + // }, + // async ({ root, spawn, fs, expect }) => { + // let process = await spawn('pnpm vite build --watch', { + // cwd: path.join(root, 'project-a'), + // }) + // await process.onStdout((m) => m.includes('built in')) + + // let filename = '' + // await retryAssertion(async () => { + // let files = await fs.glob('project-a/dist/**/*.css') + // expect(files).toHaveLength(1) + // filename = files[0][0] + // }) + + // await fs.expectFileToContain(filename, [ + // candidate`underline`, + // candidate`flex`, + // css` + // .text-primary { + // color: var(--color-primary); + // } + // `, + // ]) + + // await retryAssertion(async () => { + // await fs.write( + // 'project-a/src/custom-theme.css', + // css` + // /* Overriding the primary color */ + // @theme { + // --color-primary: red; + // } + // `, + // ) + + // let files = await fs.glob('project-a/dist/**/*.css') + // expect(files).toHaveLength(1) + // let [, styles] = files[0] + + // expect(styles).toContain(css` + // .text-primary { + // color: var(--color-primary); + // } + // `) + // }) + + // await retryAssertion(async () => { + // // Updates are additive and cause new candidates to be added. + // await fs.write( + // 'project-a/index.html', + // html` + // + // + // + // + //
Hello, world!
+ // + // `, + // ) + + // let files = await fs.glob('project-a/dist/**/*.css') + // expect(files).toHaveLength(1) + // let [, styles] = files[0] + // expect(styles).toContain(candidate`underline`) + // expect(styles).toContain(candidate`flex`) + // expect(styles).toContain(candidate`m-2`) + // }) + + // await retryAssertion(async () => { + // // Manually added `@source`s are watched and trigger a rebuild + // await fs.write( + // 'project-b/src/index.js', + // js` + // const className = "[.changed_&]:content-['project-b/src/index.js']" + // module.exports = { className } + // `, + // ) + + // let files = await fs.glob('project-a/dist/**/*.css') + // expect(files).toHaveLength(1) + // let [, styles] = files[0] + // expect(styles).toContain(candidate`underline`) + // expect(styles).toContain(candidate`flex`) + // expect(styles).toContain(candidate`m-2`) + // expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + // }) + + // await retryAssertion(async () => { + // // After updates to the CSS file, all previous candidates should still be in + // // the generated CSS + // await fs.write( + // 'project-a/src/index.css', + // css` + // ${await fs.read('project-a/src/index.css')} + + // .red { + // color: red; + // } + // `, + // ) + + // let files = await fs.glob('project-a/dist/**/*.css') + // expect(files).toHaveLength(1) + // let [, styles] = files[0] + // expect(styles).toContain(candidate`underline`) + // expect(styles).toContain(candidate`flex`) + // expect(styles).toContain(candidate`m-2`) + // expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + // expect(styles).toContain(candidate`red`) + // }) + // }, + // ) + + test( + `source(none) disables looking at the module graph`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/bun": "workspace:^", + "tailwindcss": "workspace:^" + } + } + `, + 'project-a/build.ts': ts` + import tailwindcss from '@tailwindcss/bun' + + await Bun.build({ + entrypoints: ['./index.html'], + outdir: './dist', + minify: false, + plugins: [tailwindcss], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/src/index.css': css` + @import 'tailwindcss' source(none); + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, fs, exec, expect }) => { + await exec('bun build.ts', { cwd: path.join(root, 'project-a') }) + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] + + // `underline` and `m-2` are only present from files in the module graph + // which we've explicitly disabled with source(none) so they should not + // be present + await fs.expectFileNotToContain(filename, [ + // + candidate`underline`, + candidate`m-2`, + ]) + + // The files from `project-b` should be included because there is an + // explicit `@source` directive for it + await fs.expectFileToContain(filename, [ + // + candidate`flex`, + ]) + + // The explicit source directive only covers HTML files, so the JS file + // should not be included + await fs.expectFileNotToContain(filename, [ + // + candidate`content-['project-b/src/index.js']`, + ]) + }, + ) + + test( + `source("…") filters the module graph`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/bun": "workspace:^", + "tailwindcss": "workspace:^" + } + } + `, + 'project-a/build.ts': ts` + import tailwindcss from '@tailwindcss/bun' + + await Bun.build({ + entrypoints: ['./index.html'], + outdir: './dist', + minify: false, + plugins: [tailwindcss], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + + `, + 'project-a/app/index.js': js` + const className = "content-['project-a/app/index.js']" + export default { className } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss' source('../app'); + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, fs, exec, expect }) => { + await exec('bun build.ts', { cwd: path.join(root, 'project-a') }) + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] + + // `underline` and `m-2` are present in files in the module graph but + // we've filtered the module graph such that we only look in + // `./app/**/*` so they should not be present + await fs.expectFileNotToContain(filename, [ + // + candidate`underline`, + candidate`m-2`, + candidate`content-['project-a/index.html']`, + ]) + + // We've filtered the module graph to only look in ./app/**/* so the + // candidates from that project should be present + await fs.expectFileToContain(filename, [ + // + candidate`content-['project-a/app/index.js']`, + ]) + + // Even through we're filtering the module graph explicit sources are + // additive and as such files from `project-b` should be included + // because there is an explicit `@source` directive for it + await fs.expectFileToContain(filename, [ + // + candidate`content-['project-b/src/index.html']`, + ]) + + // The explicit source directive only covers HTML files, so the JS file + // should not be included + await fs.expectFileNotToContain(filename, [ + // + candidate`content-['project-b/src/index.js']`, + ]) + }, + ) + + test( + `source("…") must be a directory`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/bun": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "vite": "^6" + } + } + `, + 'project-a/build.ts': ts` + import tailwindcss from '@tailwindcss/bun' + + await Bun.build({ + entrypoints: ['./index.html'], + outdir: './dist', + minify: false, + plugins: [tailwindcss], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + + `, + 'project-a/app/index.js': js` + const className = "content-['project-a/app/index.js']" + export default { className } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss' source('../i-do-not-exist'); + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, fs, exec, expect }) => { + await expect(() => + exec('bun ./build.ts', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }), + ).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist') + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(0) + }, + ) +}) + +// TODO: Bundler plugins in Bun.serve execute on a bundle-per-route basis, but will eventually be changed to +// match vite. +// test( +// `demote Tailwind roots to regular CSS files and back to Tailwind roots while restoring all candidates`, +// { +// fs: { +// 'package.json': json` +// { +// "type": "module", +// "dependencies": { +// "@tailwindcss/vite": "workspace:^", +// "tailwindcss": "workspace:^" +// }, +// "devDependencies": { +// "vite": "^6" +// } +// } +// `, +// 'vite.config.ts': ts` +// import tailwindcss from '@tailwindcss/vite' +// import { defineConfig } from 'vite' + +// export default defineConfig({ +// build: { cssMinify: false }, +// plugins: [tailwindcss()], +// }) +// `, +// 'index.html': html` +// +// +// +// +//
Hello, world!
+// +// `, +// 'about.html': html` +// +// +// +// +//
Tailwind Labs
+// +// `, +// 'src/index.css': css`@import 'tailwindcss';`, +// }, +// }, +// async ({ spawn, fs, expect }) => { +// let process = await spawn('pnpm vite dev') +// await process.onStdout((m) => m.includes('ready in')) + +// let url = '' +// await process.onStdout((m) => { +// let match = /Local:\s*(http.*)\//.exec(m) +// if (match) url = match[1] +// return Boolean(url) +// }) + +// // Candidates are resolved lazily, so the first visit of index.html +// // will only have candidates from this file. +// await retryAssertion(async () => { +// let styles = await fetchStyles(url, '/index.html') +// expect(styles).toContain(candidate`underline`) +// expect(styles).not.toContain(candidate`font-bold`) +// }) + +// // Going to about.html will extend the candidate list to include +// // candidates from about.html. +// await retryAssertion(async () => { +// let styles = await fetchStyles(url, '/about.html') +// expect(styles).toContain(candidate`underline`) +// expect(styles).toContain(candidate`font-bold`) +// }) + +// await retryAssertion(async () => { +// // We change the CSS file so it is no longer a valid Tailwind root. +// await fs.write('src/index.css', css`@import 'tailwindcss';`) + +// let styles = await fetchStyles(url) +// expect(styles).toContain(candidate`underline`) +// expect(styles).toContain(candidate`font-bold`) +// }) +// }, +// ) + +// TODO: Bun does not support import urls with ?raw and ?url yet +// test( +// `does not interfere with ?raw and ?url static asset handling`, +// { +// fs: { +// 'package.json': json` +// { +// "type": "module", +// "dependencies": { +// "@tailwindcss/vite": "workspace:^", +// "tailwindcss": "workspace:^" +// }, +// "devDependencies": { +// "vite": "^6" +// } +// } +// `, +// 'vite.config.ts': ts` +// import tailwindcss from '@tailwindcss/vite' +// import { defineConfig } from 'vite' + +// export default defineConfig({ +// build: { cssMinify: false }, +// plugins: [tailwindcss()], +// }) +// `, +// 'index.html': html` +// +// +// +// `, +// 'src/index.js': js` +// import url from './index.css?url' +// import raw from './index.css?raw' +// `, +// 'src/index.css': css`@import 'tailwindcss';`, +// }, +// }, +// async ({ spawn, expect }) => { +// let process = await spawn('pnpm vite dev') +// await process.onStdout((m) => m.includes('ready in')) + +// let baseUrl = '' +// await process.onStdout((m) => { +// let match = /Local:\s*(http.*)\//.exec(m) +// if (match) baseUrl = match[1] +// return Boolean(baseUrl) +// }) + +// await retryAssertion(async () => { +// // We have to load the .js file first so that the static assets are +// // resolved +// await fetch(`${baseUrl}/src/index.js`).then((r) => r.text()) + +// let [raw, url] = await Promise.all([ +// fetch(`${baseUrl}/src/index.css?raw`).then((r) => r.text()), +// fetch(`${baseUrl}/src/index.css?url`).then((r) => r.text()), +// ]) + +// expect(firstLine(raw)).toBe(`export default "@import 'tailwindcss';"`) +// expect(firstLine(url)).toBe(`export default "/src/index.css"`) +// }) +// }, +// ) + +function firstLine(str: string) { + return str.split('\n')[0] +} diff --git a/integrations/utils.ts b/integrations/utils.ts index 4558edd035d8..71966806c997 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -30,9 +30,7 @@ interface ExecOptions { } interface TestConfig { - fs: { - [filePath: string]: string | Uint8Array - } + fs: { [filePath: string]: string | Uint8Array } installDependencies?: boolean } @@ -118,11 +116,7 @@ export function test( return new Promise((resolve, reject) => { let child = exec( command, - { - cwd, - ...childProcessOptions, - env: childProcessOptions.env, - }, + { cwd, ...childProcessOptions, env: childProcessOptions.env }, (error, stdout, stderr) => { if (error) { if (execOptions.ignoreStdErr !== true) console.error(stderr) @@ -163,10 +157,7 @@ export function test( cwd, shell: true, ...childProcessOptions, - env: { - ...process.env, - ...childProcessOptions.env, - }, + env: { ...process.env, ...childProcessOptions.env }, }) function dispose() { @@ -424,6 +415,9 @@ export function test( if (only || debug) { try { await context.exec('git init', { cwd: root }) + // Add these lines to set git identity for CI + await context.exec('git config user.email "test@example.com"', { cwd: root }) + await context.exec('git config user.name "Test User"', { cwd: root }) await context.exec('git add --all', { cwd: root }) await context.exec('git commit -m "before migration"', { cwd: root }) } catch (error: any) { @@ -526,6 +520,80 @@ export function candidate(strings: TemplateStringsArray, ...values: any[]) { return `.${escape(output.join('').trim())}` } +// https://drafts.csswg.org/cssom/#serialize-an-identifier +export function escape(value: string) { + if (arguments.length == 0) { + throw new TypeError('`CSS.escape` requires an argument.') + } + var string = String(value) + var length = string.length + var index = -1 + var codeUnit + var result = '' + var firstCodeUnit = string.charCodeAt(0) + + if ( + // If the character is the first character and is a `-` (U+002D), and + // there is no second character, […] + length == 1 && + firstCodeUnit == 0x002d + ) { + return '\\' + string + } + + while (++index < length) { + codeUnit = string.charCodeAt(index) + // Note: there's no need to special-case astral symbols, surrogate + // pairs, or lone surrogates. + + // If the character is NULL (U+0000), then the REPLACEMENT CHARACTER + // (U+FFFD). + if (codeUnit == 0x0000) { + result += '\uFFFD' + continue + } + + if ( + // If the character is in the range [\1-\1F] (U+0001 to U+001F) or is + // U+007F, […] + (codeUnit >= 0x0001 && codeUnit <= 0x001f) || + codeUnit == 0x007f || + // If the character is the first character and is in the range [0-9] + // (U+0030 to U+0039), […] + (index == 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || + // If the character is the second character and is in the range [0-9] + // (U+0030 to U+0039) and the first character is a `-` (U+002D), […] + (index == 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit == 0x002d) + ) { + // https://drafts.csswg.org/cssom/#escape-a-character-as-code-point + result += '\\' + codeUnit.toString(16) + ' ' + continue + } + + // If the character is not handled by one of the above rules and is + // greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or + // is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to + // U+005A), or [a-z] (U+0061 to U+007A), […] + if ( + codeUnit >= 0x0080 || + codeUnit == 0x002d || + codeUnit == 0x005f || + (codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (codeUnit >= 0x0041 && codeUnit <= 0x005a) || + (codeUnit >= 0x0061 && codeUnit <= 0x007a) + ) { + // the character itself + result += string.charAt(index) + continue + } + + // Otherwise, the escaped character. + // https://drafts.csswg.org/cssom/#escape-a-character + result += '\\' + string.charAt(index) + } + return result +} + export async function retryAssertion( fn: () => Promise, { timeout = ASSERTION_TIMEOUT, delay = 5 }: { timeout?: number; delay?: number } = {}, @@ -543,7 +611,7 @@ export async function retryAssertion( throw error } -export async function fetchStyles(base: string, path = '/'): Promise { +export async function fetchStyles(base: string, path = '/', isBun = false): Promise { while (base.endsWith('/')) { base = base.slice(0, -1) } @@ -551,7 +619,7 @@ export async function fetchStyles(base: string, path = '/'): Promise { let index = await fetch(`${base}${path}`) let html = await index.text() - let linkRegex = /]*>([\s\S]*?)<\/style>/gi let stylesheets: string[] = [] @@ -567,11 +635,7 @@ export async function fetchStyles(base: string, path = '/'): Promise { stylesheets.push( ...(await Promise.all( paths.map(async (path) => { - let css = await fetch(`${base}${path}`, { - headers: { - Accept: 'text/css', - }, - }) + let css = await fetch(`${base}${path}`, { headers: { Accept: 'text/css' } }) return await css.text() }), )), diff --git a/packages/@tailwindcss-bun/README.md b/packages/@tailwindcss-bun/README.md new file mode 100644 index 000000000000..95ec9d87ddcc --- /dev/null +++ b/packages/@tailwindcss-bun/README.md @@ -0,0 +1,40 @@ +

+ + + + + Tailwind CSS + + +

+ +

+ A utility-first CSS framework for rapidly building custom user interfaces. +

+ +

+ Build Status + Total Downloads + Latest Release + License +

+ +--- + +## Documentation + +For full documentation, visit [tailwindcss.com](https://tailwindcss.com). + +## Community + +For help, discussion about best practices, or any other conversation that would benefit from being searchable: + +[Discuss Tailwind CSS on GitHub](https://github.com/tailwindcss/tailwindcss/discussions) + +For chatting with others using the framework: + +[Join the Tailwind CSS Discord Server](https://discord.gg/7NF8GNe) + +## Contributing + +If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindcss/tailwindcss/blob/next/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/packages/@tailwindcss-bun/package.json b/packages/@tailwindcss-bun/package.json new file mode 100644 index 000000000000..dad0b8134131 --- /dev/null +++ b/packages/@tailwindcss-bun/package.json @@ -0,0 +1,39 @@ +{ + "name": "@tailwindcss/bun", + "version": "4.0.1", + "description": "A utility-first CSS framework for rapidly building custom user interfaces.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/tailwindlabs/tailwindcss.git", + "directory": "packages/@tailwindcss-bun" + }, + "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", + "homepage": "https://tailwindcss.com", + "scripts": { + "build": "tsup-node", + "dev": "pnpm run build -- --watch" + }, + "files": [ + "dist/" + ], + "publishConfig": { + "provenance": true, + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "dependencies": { + "@tailwindcss/node": "workspace:^", + "@tailwindcss/oxide": "workspace:^", + "tailwindcss": "workspace:*" + }, + "devDependencies": { + "@types/bun": "1.2.2", + "@types/node": "catalog:" + } +} diff --git a/packages/@tailwindcss-bun/src/index.ts b/packages/@tailwindcss-bun/src/index.ts new file mode 100644 index 000000000000..ad5d4263945b --- /dev/null +++ b/packages/@tailwindcss-bun/src/index.ts @@ -0,0 +1,211 @@ +import { compile, Features, normalizePath } from '@tailwindcss/node' +import { nativeBinding, Scanner, twctxCreate, twctxIsDirty, twctxToJs } from '@tailwindcss/oxide' +import type { BunPlugin } from 'bun' +import fs from 'node:fs/promises' +import * as path from 'path' + +const addon = nativeBinding + +const SPECIAL_QUERY_RE = /[?&](raw|url)\b/ + +const NON_CSS_ROOT_FILE_RE = + /(?:\/\.vite\/|(?!\.css$|\.vue\?.*&lang\.css|\.astro\?.*&lang\.css|\.svelte\?.*&lang\.css).*|\?(?:raw|url)\b)/ + +type Compiler = Awaited> + +const plugin: BunPlugin = { + name: 'tailwindcss', + setup(build) { + const external = twctxCreate() + + let moduleGraphCandidates = new Map>() + function getSharedCandidates() { + if (twctxIsDirty(external)) { + let rawCandidates: Array<{ id: string; candidates: string[] }> = twctxToJs(external) + for (let { id, candidates } of rawCandidates) { + moduleGraphCandidates.set(id, new Set(candidates)) + } + } + return moduleGraphCandidates + } + + // @ts-ignore + build.onBeforeParse( + { filter: NON_CSS_ROOT_FILE_RE }, + { napiModule: addon, symbol: 'tw_on_before_parse', external }, + ) + + build.onLoad({ filter: /\.css/ }, async ({ defer, path: inputPath }) => { + if (!isPotentialCssRootFile(inputPath)) return + + let inputBaseForRoot = path.dirname(path.resolve(inputPath)) + + let sourceContents = await Bun.file(inputPath).text() + let compiler = await compile(sourceContents, { + base: inputBaseForRoot, + onDependency(path) { + // TODO: Bun does not currently have a bundler API which is + // analogous to `.addWatchFile()`. + }, + }) + + // Wait until the native plugin has scanned all files in + // the module graph. + await defer() + + let candidates = new Set() + let basePath: string | null = null + + let sources = (() => { + // Disable auto source detection + if (compiler.root === 'none') { + return [] + } + + // No root specified, use the module graph + if (compiler.root === null) { + return [] + } + + // Use the specified root + return [compiler.root] + })().concat(compiler.globs) + + let scanner = new Scanner({ sources }) + + if ( + !( + compiler.features & + (Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities) + ) + ) { + return undefined + } + + for (let candidate of scanner.scan()) { + candidates.add(candidate) + } + + if (compiler.features & Features.Utilities) { + // TODO: Watch individual files found via custom `@source` paths + // Bun does not currently have a bundler API which is + // analogous to `.addWatchFile()`, but this is where + // we would handle @source + // Watch globs found via custom `@source` paths + for (let glob of scanner.globs) { + /* TODO: addWatchFile + if (glob.pattern[0] === '!') continue + + let relative = path.relative(this.base, glob.base) + if (relative[0] !== '.') { + relative = './' + relative + } + // Ensure relative is a posix style path since we will merge it with the + // glob. + relative = normalizePath(relative) + + addWatchFile(path.posix.join(relative, glob.pattern)) + */ + + let root = compiler.root + + if (root !== 'none' && root !== null) { + let newBasePath = normalizePath(path.resolve(root.base, root.pattern)) + + let isDir = await fs.stat(newBasePath).then( + (stats) => stats.isDirectory(), + () => false, + ) + + if (!isDir) { + throw new Error( + `The path given to \`source(…)\` must be a directory but got \`source(${newBasePath})\` instead.`, + ) + } + + basePath = newBasePath + } else if (root === null) { + basePath = null + } + } + } + + let contents = compiler.build([ + ...sharedCandidates(compiler, basePath, getSharedCandidates), + ...candidates, + ]) + + return { + // Return directly to Bun's bundler which will optimize the CSS + contents, + loader: 'css', + } + }) + }, +} + +export default plugin + +function sharedCandidates( + compiler: Compiler, + basePath: string | null, + getSharedCandidates: () => Map>, +): Set { + if (compiler.root === 'none') return new Set() + + const HAS_DRIVE_LETTER = /^[A-Z]:/ + + let shouldIncludeCandidatesFrom = (id: string) => { + if (basePath === null) return true + + if (id.startsWith(basePath)) return true + + // This is a windows absolute path that doesn't match so return false + if (HAS_DRIVE_LETTER.test(id)) return false + + // We've got a path that's not absolute and not on Windows + // TODO: this is probably a virtual module -- not sure if we need to scan it + if (!id.startsWith('/')) return true + + // This is an absolute path on POSIX and it does not match + return false + } + + let shared = new Set() + + for (let [id, candidates] of getSharedCandidates()) { + if (!shouldIncludeCandidatesFrom(id)) continue + + for (let candidate of candidates) { + shared.add(candidate) + } + } + + return shared +} + +function isPotentialCssRootFile(id: string) { + if (id.includes('/.vite/')) return + let extension = getExtension(id) + let isCssFile = + (extension === 'css' || + (extension === 'vue' && id.includes('&lang.css')) || + (extension === 'astro' && id.includes('&lang.css')) || + // We want to process Svelte `