diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e2433d3b..b7e4a4674 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,11 +65,19 @@ jobs: - name: Build Tailwind CSS run: npx turbo run build --filter=// - - name: Test + - name: Test (Node.js) run: | npx turbo run test --filter=// || \ npx turbo run test --filter=// || \ npx turbo run test --filter=// || exit 1 + - name: Test (Rust) + run: | + cd ./oxide + cargo test || \ + cargo test || \ + cargo test || exit 1 + cd - + - name: Lint run: npx turbo run style --filter=// diff --git a/CHANGELOG.md b/CHANGELOG.md index f61138f1b..f7aa8bc7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Oxide] Use `lightningcss` for nesting and vendor prefixes in PostCSS plugin ([#10399](https://github.com/tailwindlabs/tailwindcss/pull/10399)) - Support `@import "tailwindcss"` using top-level `index.css` file ([#11205](https://github.com/tailwindlabs/tailwindcss/pull/11205)) +- [Oxide] Automatically detect content paths when no `content` configuration is provided ([#11173](https://github.com/tailwindlabs/tailwindcss/pull/11173)) ### Changed diff --git a/integrations/io.js b/integrations/io.js index c555ac8ba..d30570d3a 100644 --- a/integrations/io.js +++ b/integrations/io.js @@ -122,6 +122,17 @@ module.exports = function ({ }, async writeInputFile(file, contents) { let filePath = path.resolve(absoluteInputFolder, file) + + // Ensure the parent folder of the file exists + if ( + !(await fs + .stat(filePath) + .then(() => true) + .catch(() => false)) + ) { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + } + if (!fileCache[filePath]) { try { fileCache[filePath] = await fs.readFile(filePath, 'utf8') diff --git a/integrations/tailwindcss-cli/fixtures/example-app/src/index.css b/integrations/tailwindcss-cli/fixtures/example-app/src/index.css new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/integrations/tailwindcss-cli/fixtures/example-app/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/integrations/tailwindcss-cli/fixtures/example-app/src/index.html b/integrations/tailwindcss-cli/fixtures/example-app/src/index.html new file mode 100644 index 000000000..b956240c2 --- /dev/null +++ b/integrations/tailwindcss-cli/fixtures/example-app/src/index.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/integrations/tailwindcss-cli/tests/integration.test.js b/integrations/tailwindcss-cli/tests/integration.test.js index 75c9779af..de806dd27 100644 --- a/integrations/tailwindcss-cli/tests/integration.test.js +++ b/integrations/tailwindcss-cli/tests/integration.test.js @@ -1,4 +1,5 @@ let fs = require('fs') +let path = require('path') let $ = require('../../execute') let { css, html, javascript } = require('../../syntax') let { env } = require('../../../lib/lib/sharedState') @@ -1000,4 +1001,436 @@ describe('watcher', () => { return runningProcess.stop() }) + + if (env.ENGINE === 'oxide') { + describe('auto content', () => { + let { readOutputFile, writeInputFile } = require('../../io')({ + output: 'fixtures/example-app/dist', + input: 'fixtures/example-app/src', + }) + let options = { + cwd: path.resolve(__dirname, '..', 'fixtures', 'example-app'), + } + + it('should detect classes in existing files', async () => { + await writeInputFile( + '../tailwind.config.js', + javascript` + module.exports = { + corePlugins: { + preflight: false, + }, + } + ` + ) + + let runningProcess = $( + 'node ../../../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w', + options + ) + + await writeInputFile('index.html', html`
`) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .font-bold { + font-weight: 700; + } + ` + ) + + return runningProcess.stop() + }) + + it('should detect changes in existing files', async () => { + await writeInputFile( + '../tailwind.config.js', + javascript` + module.exports = { + corePlugins: { + preflight: false, + }, + } + ` + ) + + await writeInputFile('index.html', html`
`) + + let runningProcess = $( + 'node ../../../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w', + options + ) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .font-bold { + font-weight: 700; + } + ` + ) + + expect(await readOutputFile('main.css')).not.toIncludeCss( + css` + .underline { + -webkit-text-decoration-line: underline; + text-decoration-line: underline; + } + ` + ) + + // Make a change + + await writeInputFile('index.html', html`
`) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .underline { + -webkit-text-decoration-line: underline; + text-decoration-line: underline; + } + ` + ) + + return runningProcess.stop() + }) + + it('should detect changes in new files in existing folders with a known extension', async () => { + await writeInputFile( + '../tailwind.config.js', + javascript` + module.exports = { + corePlugins: { + preflight: false, + }, + } + ` + ) + + await writeInputFile('index.html', html`
`) + + let runningProcess = $( + 'node ../../../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w', + options + ) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .font-bold { + font-weight: 700; + } + ` + ) + + expect(await readOutputFile('main.css')).not.toIncludeCss( + css` + .underline { + -webkit-text-decoration-line: underline; + text-decoration-line: underline; + } + ` + ) + + // Make a change to a new file in an existing folder with a known extension. + + await writeInputFile('other.html', html`
`) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .underline { + -webkit-text-decoration-line: underline; + text-decoration-line: underline; + } + ` + ) + + return runningProcess.stop() + }) + + it('should not scan ignored files', async () => { + await writeInputFile( + '../tailwind.config.js', + javascript` + module.exports = { + corePlugins: { + preflight: false, + }, + } + ` + ) + await writeInputFile('../.gitignore', 'generated-folder/') + await writeInputFile('../generated-folder/bad.html', html`
`) + await writeInputFile('index.html', html`
`) + + let runningProcess = $( + 'node ../../../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w', + options + ) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .font-bold { + font-weight: 700; + } + ` + ) + + expect(await readOutputFile('main.css')).not.toIncludeCss( + css` + .italic { + font-style: italic; + } + ` + ) + + return runningProcess.stop() + }) + + it('should not scan for known binary files', async () => { + await writeInputFile( + '../tailwind.config.js', + javascript` + module.exports = { + corePlugins: { + preflight: false, + }, + } + ` + ) + await writeInputFile('example-1.png', html`
`) + await writeInputFile('example-2.mp4', html`
`) + await writeInputFile('index.html', html`
`) + + let runningProcess = $( + 'node ../../../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w', + options + ) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .font-bold { + font-weight: 700; + } + ` + ) + + expect(await readOutputFile('main.css')).not.toIncludeCss( + css` + .italic { + font-style: italic; + } + + .underline { + -webkit-text-decoration-line: underline; + text-decoration-line: underline; + } + ` + ) + + return runningProcess.stop() + }) + + it('should not scan for explicitly ignored extensions (such as css/scss/less/...)', async () => { + await writeInputFile( + '../tailwind.config.js', + javascript` + module.exports = { + corePlugins: { + preflight: false, + }, + } + ` + ) + await writeInputFile('example.css', html`
`) + await writeInputFile('example.less', html`
`) + await writeInputFile('index.html', html`
`) + + let runningProcess = $( + 'node ../../../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w', + options + ) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .font-bold { + font-weight: 700; + } + ` + ) + + expect(await readOutputFile('main.css')).not.toIncludeCss( + css` + .italic { + font-style: italic; + } + + .underline { + -webkit-text-decoration-line: underline; + text-decoration-line: underline; + } + ` + ) + + return runningProcess.stop() + }) + + it('should not scan for explicitly ignored files (such as package-lock.json)', async () => { + await writeInputFile( + '../tailwind.config.js', + javascript` + module.exports = { + corePlugins: { + preflight: false, + }, + } + ` + ) + await writeInputFile('package-lock.json', html`
`) + await writeInputFile('yarn.lock', html`
`) + await writeInputFile('pnpm-lock.yaml', html`
`) + await writeInputFile('index.html', html`
`) + + let runningProcess = $( + 'node ../../../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w', + options + ) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .font-bold { + font-weight: 700; + } + ` + ) + + expect(await readOutputFile('main.css')).not.toIncludeCss( + css` + .italic { + font-style: italic; + } + ` + ) + + return runningProcess.stop() + }) + + it('should not include the tailwind.config.js file as a template file', async () => { + await writeInputFile( + '../tailwind.config.js', + javascript` + // Example class that should not be included: flex italic + module.exports = { + corePlugins: { + preflight: false, + }, + } + ` + ) + await writeInputFile('index.html', html`
`) + + let runningProcess = $( + 'node ../../../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w', + options + ) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .font-bold { + font-weight: 700; + } + ` + ) + + expect(await readOutputFile('main.css')).not.toIncludeCss( + css` + .flex { + display: flex; + } + ` + ) + expect(await readOutputFile('main.css')).not.toIncludeCss( + css` + .italic { + font-style: italic; + } + ` + ) + + return runningProcess.stop() + }) + + it('should optimize the globs and ensure that nested ignored folders are not scanned', async () => { + await writeInputFile( + '../tailwind.config.js', + javascript` + module.exports = { + corePlugins: { + preflight: false, + }, + } + ` + ) + + await writeInputFile('../.gitignore', 'node_modules') + await writeInputFile('../node_modules/a.html', html`
`) + await writeInputFile('index.html', html`
`) + await writeInputFile('nested/index.html', html`
`) + await writeInputFile( + 'nested/node_modules/index.html', + html`
` + ) + + let runningProcess = $( + 'node ../../../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w', + options + ) + await runningProcess.onStderr(ready) + + // Root node_modules + expect(await readOutputFile('main.css')).not.toIncludeCss( + css` + .text-red-100 { + color: #fee2e2; + } + ` + ) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .text-red-200 { + color: #fecaca; + } + ` + ) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .text-red-300 { + color: #fca5a5; + } + ` + ) + + // Nested node_modules + expect(await readOutputFile('main.css')).not.toIncludeCss( + css` + .text-red-400 { + color: #f87171; + } + ` + ) + + return runningProcess.stop() + }) + }) + } }) diff --git a/oxide/Cargo.lock b/oxide/Cargo.lock index 5b959d726..948b8b4cc 100644 --- a/oxide/Cargo.lock +++ b/oxide/Cargo.lock @@ -17,7 +17,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", "winapi", ] @@ -48,9 +48,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.0.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fca0852af221f458706eb0725c03e4ed6c46af9ac98e6a689d5e634215d594dd" +checksum = "c3d4260bcc2e8fc9df1eac4919a720effeb63a3f0952f5bf4944adfa18897f09" dependencies = [ "memchr", "once_cell", @@ -76,6 +76,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + [[package]] name = "cfg-if" version = "1.0.0" @@ -280,6 +286,36 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "fnv" version = "1.0.7" @@ -297,12 +333,12 @@ dependencies = [ [[package]] name = "globset" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a1e17342619edbc21a964c2afbeb6c820c6a2560032872f397bb97ea127bd0a" +checksum = "029d74589adefde59de1a0c4f4732695c32805624aec7b68d91503d4dba79afc" dependencies = [ "aho-corasick", - "bstr 0.2.17", + "bstr 1.4.0", "fnv", "log", "regex", @@ -341,12 +377,17 @@ dependencies = [ ] [[package]] -name = "ignore" -version = "0.4.18" +name = "hermit-abi" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713f1b139373f96a2e0ce3ac931cd01ee973c3c5dd7c40c0c2efe96ad2b6751d" +checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" + +[[package]] +name = "ignore" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe7873dab538a9a44ad79ede1faf5f30d49f9a5c883ddbab48bce81b64b7492" dependencies = [ - "crossbeam-utils", "globset", "lazy_static", "log", @@ -358,6 +399,26 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220" +dependencies = [ + "hermit-abi 0.3.1", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "itertools" version = "0.10.5" @@ -396,9 +457,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.137" +version = "0.2.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" +checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" [[package]] name = "libloading" @@ -410,6 +471,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "linux-raw-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece97ea872ece730aed82664c424eb4c8291e1ff2480247ccf7409044bc6479f" + [[package]] name = "log" version = "0.4.17" @@ -523,7 +590,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6058e64324c71e02bc2b150e4f3bc8286db6c83092132ffa3f6b1eab0f9def5" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", ] @@ -650,6 +717,15 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.7.0" @@ -676,6 +752,20 @@ version = "0.6.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" +[[package]] +name = "rustix" +version = "0.37.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d" +dependencies = [ + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys 0.48.0", +] + [[package]] name = "ryu" version = "1.0.11" @@ -786,15 +876,18 @@ version = "0.1.0" name = "tailwindcss-core" version = "0.1.0" dependencies = [ - "bstr 1.0.1", + "bstr 1.4.0", "criterion", "crossbeam", "fxhash", "globwalk", + "ignore", "log", "rayon", + "tempfile", "tracing", "tracing-subscriber", + "walkdir", ] [[package]] @@ -806,6 +899,19 @@ dependencies = [ "tailwindcss-core", ] +[[package]] +name = "tempfile" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9fbec84f381d5795b08656e4912bec604d162bff9291d6189a78f4c8ab87998" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys 0.45.0", +] + [[package]] name = "termcolor" version = "1.1.3" @@ -937,12 +1043,11 @@ checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "walkdir" -version = "2.3.2" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" dependencies = [ "same-file", - "winapi", "winapi-util", ] @@ -1040,3 +1145,135 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" diff --git a/oxide/crates/core/Cargo.toml b/oxide/crates/core/Cargo.toml index ae895f34a..0db8eb2b2 100644 --- a/oxide/crates/core/Cargo.toml +++ b/oxide/crates/core/Cargo.toml @@ -12,9 +12,12 @@ 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" [dev-dependencies] criterion = { version = "0.3", features = ['html_reports'] } +tempfile = "3.5.0" [[bench]] name = "parse_candidates" diff --git a/oxide/crates/core/src/fixtures/binary-extensions.txt b/oxide/crates/core/src/fixtures/binary-extensions.txt new file mode 100644 index 000000000..b7cee312c --- /dev/null +++ b/oxide/crates/core/src/fixtures/binary-extensions.txt @@ -0,0 +1,259 @@ +3dm +3ds +3g2 +3gp +7z +DS_Store +a +aac +adp +ai +aif +aiff +alz +ape +apk +appimage +ar +arj +asf +au +avi +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 +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 diff --git a/oxide/crates/core/src/fixtures/ignored-extensions.txt b/oxide/crates/core/src/fixtures/ignored-extensions.txt new file mode 100644 index 000000000..f147c24fe --- /dev/null +++ b/oxide/crates/core/src/fixtures/ignored-extensions.txt @@ -0,0 +1,6 @@ +css +less +lock +sass +scss +styl diff --git a/oxide/crates/core/src/fixtures/ignored-files.txt b/oxide/crates/core/src/fixtures/ignored-files.txt new file mode 100644 index 000000000..45d4ced87 --- /dev/null +++ b/oxide/crates/core/src/fixtures/ignored-files.txt @@ -0,0 +1,3 @@ +package-lock.json +pnpm-lock.yaml +bun.lockb diff --git a/oxide/crates/core/src/fixtures/template-extensions.txt b/oxide/crates/core/src/fixtures/template-extensions.txt new file mode 100644 index 000000000..c059eff3a --- /dev/null +++ b/oxide/crates/core/src/fixtures/template-extensions.txt @@ -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 diff --git a/oxide/crates/core/src/lib.rs b/oxide/crates/core/src/lib.rs index 9dd88437d..6601267fd 100644 --- a/oxide/crates/core/src/lib.rs +++ b/oxide/crates/core/src/lib.rs @@ -1,8 +1,12 @@ use crate::parser::Extractor; use fxhash::FxHashSet; +use ignore::WalkBuilder; use rayon::prelude::*; +use std::cmp::Ordering; +use std::path::Path; use std::path::PathBuf; use tracing::event; +use walkdir::WalkDir; pub mod candidate; pub mod glob; @@ -30,6 +34,275 @@ pub struct ChangedContent { pub extension: String, } +#[derive(Debug, Clone)] +pub struct ContentPathInfo { + pub base: String, +} + +pub fn resolve_content_paths(args: ContentPathInfo) -> Vec { + let root = Path::new(&args.base); + + let allowed_paths = FxHashSet::from_iter( + WalkBuilder::new(&root) + .hidden(false) + .filter_entry(|entry| match entry.file_type() { + Some(file_type) if file_type.is_dir() => entry + .file_name() + .to_str() + .map(|s| s != ".git") + .unwrap_or(false), + _ => true, + }) + .build() + .filter_map(Result::ok) + .map(|x| x.into_path()), + ); + + // 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()), + ); + + // A list of static files that we should track directly. + let mut static_files = vec![]; + + // 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 = 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 = 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. + else { + 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()); + } + + // If the current path is a file inside any of the root directories, then we have to + // track it directly as a static file. + if let Some(parent_path) = path.parent() { + if root_directories.contains(parent_path) { + static_files.push(format!("{}", path.display())); + } + } + } + } + + // Build the globs for all globable directories. + let shallow_globs = shallow_globable_directories + .iter() + .map(|path| format!("{}", path.display())); + let deep_globs = deep_globable_directories + .iter() + .map(|path| format!("{}/**", path.display())); + let globs = shallow_globs + .chain(deep_globs) + .flat_map(|path| match found_extensions.len() { + 0 => None, // This should never happen + 1 => Some(format!( + "{}/*.{}", + path, + found_extensions.iter().next().unwrap() + )), + _ => Some(format!( + "{}/*.{{{}}}", + path, + found_extensions + .iter() + .map(|x| x.to_string()) + .collect::>() + .join(","), + )), + }) + .collect::>(); + + static_files.extend(globs); + static_files +} + +pub fn is_git_ignored_content_path(base: &Path, path: &Path) -> bool { + !WalkBuilder::new(base) + .hidden(false) + .filter_entry(|entry| match entry.file_type() { + Some(file_type) if file_type.is_dir() => entry + .file_name() + .to_str() + .map(|s| s != ".git") + .unwrap_or(false), + _ => true, + }) + .build() + .filter_map(Result::ok) + .any(|e| e.path() == path) +} + +pub fn is_allowed_content_path(path: &Path) -> bool { + let binary_extensions = include_str!("fixtures/binary-extensions.txt") + .trim() + .lines() + .collect::>(); + let ignored_extensions = include_str!("fixtures/ignored-extensions.txt") + .trim() + .lines() + .collect::>(); + let ignored_files = include_str!("fixtures/ignored-files.txt") + .trim() + .lines() + .collect::>(); + + 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 + return 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, @@ -89,7 +362,13 @@ fn read_all_files(changed_content: Vec) -> Vec> { changed_content .into_par_iter() .map(|c| match (c.file, c.content) { - (Some(file), None) => std::fs::read(file).unwrap(), + (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(), }) diff --git a/oxide/crates/core/src/parser.rs b/oxide/crates/core/src/parser.rs index 16831afff..372758e8e 100644 --- a/oxide/crates/core/src/parser.rs +++ b/oxide/crates/core/src/parser.rs @@ -352,7 +352,7 @@ impl<'a> Extractor<'a> { self.in_candidate && !self.in_arbitrary && (0..=127).contains(&c) - && (self.idx_start == 0 || self.input[(self.idx_start - 1)] <= 127) + && (self.idx_start == 0 || self.input[self.idx_start - 1] <= 127) } #[inline(always)] @@ -470,7 +470,7 @@ impl<'a> Iterator for Extractor<'a> { mod test { use super::*; - fn please_trace() { + fn _please_trace() { tracing_subscriber::fmt() .with_max_level(tracing::Level::TRACE) .with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE) diff --git a/oxide/crates/core/tests/auto_content.rs b/oxide/crates/core/tests/auto_content.rs new file mode 100644 index 000000000..f7a185f6c --- /dev/null +++ b/oxide/crates/core/tests/auto_content.rs @@ -0,0 +1,248 @@ +#[cfg(test)] +mod auto_content { + use std::fs; + use std::process::Command; + + use tailwindcss_core::*; + use tempfile::tempdir; + + fn test(paths_with_content: &[(&str, Option<&str>)]) -> Vec { + // 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 mut paths: Vec<_> = resolve_content_paths(ContentPathInfo { base: base.clone() }) + .into_iter() + .map(|x| x.replace(&format!("{}/", &base), "")) + .collect(); + + // Sort the output for easier comparison (depending on internal datastructure the order + // _could_ be random) + paths.sort(); + + paths + } + + #[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}"]); + } + + #[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![ + // Listed explicitly because they are in the root + "bar.html", + "baz.html", + "foo.html", + + // Listed using a deep glob, because nothing inside is ignored + "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}", + + // Listed using a deep glob, because nothing inside is ignored (but contains deeply + // nested folders) + "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}", + + // Listed using a shallow glob, because `nested-c` contains ignored folders, deeply + // nested globs can't be used anymore + "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}", + + // Deeply nested folder can use a deep glob again + "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}", + + // The deeply nested folder can use a deep glob, however all of its parents can + // only contain a shallow glob because of the ignored folder + "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/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/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}", + ] + ); + } +} diff --git a/oxide/crates/node/package.json b/oxide/crates/node/package.json index f9841b69f..80ddc6fcf 100644 --- a/oxide/crates/node/package.json +++ b/oxide/crates/node/package.json @@ -19,7 +19,7 @@ }, "license": "MIT", "devDependencies": { - "@napi-rs/cli": "^2.15.2" + "@napi-rs/cli": "2.15.2" }, "engines": { "node": ">= 10" diff --git a/oxide/crates/node/src/lib.rs b/oxide/crates/node/src/lib.rs index f8b927db5..82bf28b0a 100644 --- a/oxide/crates/node/src/lib.rs +++ b/oxide/crates/node/src/lib.rs @@ -29,6 +29,17 @@ pub fn parse_candidate_strings_from_files(changed_content: Vec) ) } +#[derive(Debug, Clone)] +#[napi(object)] +pub struct ContentPathInfo { + pub base: String, +} + +#[napi] +pub fn resolve_content_paths(args: ContentPathInfo) -> Vec { + tailwindcss_core::resolve_content_paths(tailwindcss_core::ContentPathInfo { base: args.base }) +} + #[derive(Debug)] #[napi] pub enum IO { diff --git a/package-lock.json b/package-lock.json index fffad06d0..af4967b34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16472,7 +16472,7 @@ "version": "0.0.0", "license": "MIT", "devDependencies": { - "@napi-rs/cli": "^2.15.2" + "@napi-rs/cli": "2.15.2" }, "engines": { "node": ">= 10" @@ -19253,7 +19253,7 @@ "@tailwindcss/oxide": { "version": "file:oxide/crates/node", "requires": { - "@napi-rs/cli": "^2.15.2", + "@napi-rs/cli": "2.15.2", "@tailwindcss/oxide-darwin-arm64": "0.0.0", "@tailwindcss/oxide-darwin-x64": "0.0.0", "@tailwindcss/oxide-freebsd-x64": "0.0.0", @@ -29917,7 +29917,7 @@ "@tailwindcss/oxide": { "version": "file:oxide/crates/node", "requires": { - "@napi-rs/cli": "^2.15.2", + "@napi-rs/cli": "2.15.2", "@tailwindcss/oxide-darwin-arm64": "0.0.0", "@tailwindcss/oxide-darwin-x64": "0.0.0", "@tailwindcss/oxide-freebsd-x64": "0.0.0", diff --git a/src/cli/build/plugin.js b/src/cli/build/plugin.js index 9f3f6f52f..a5045593d 100644 --- a/src/cli/build/plugin.js +++ b/src/cli/build/plugin.js @@ -19,6 +19,7 @@ import { findAtConfigPath } from '../../lib/findAtConfigPath.js' import log from '../../util/log' import { loadConfig } from '../../lib/load-config' import getModuleDependencies from '../../lib/getModuleDependencies' +import { validateConfig } from '../../util/validateConfig' /** * @@ -161,7 +162,13 @@ let state = { } // @ts-ignore - this.configBag.config = resolveConfig(this.configBag.config, { content: { files: [] } }) + if (__OXIDE__) { + this.configBag.config = validateConfig(resolveConfig(this.configBag.config)) + } else { + this.configBag.config = validateConfig( + resolveConfig(this.configBag.config, { content: { files: [] } }) + ) + } // Override content files if `--content` has been passed explicitly if (content?.length > 0) { diff --git a/src/cli/init/index.js b/src/cli/init/index.js index 6bd7e41e2..ac118549a 100644 --- a/src/cli/init/index.js +++ b/src/cli/init/index.js @@ -38,6 +38,11 @@ export function init(args) { 'utf8' ) + // Drop `content` in the oxide engine to promote auto content + if (__OXIDE__) { + stubContentsFile = stubContentsFile.replace(/\s*content: \[\],\n/, '') + } + // Change colors import stubContentsFile = stubContentsFile.replace('../colors', 'tailwindcss/colors') diff --git a/src/lib/content.js b/src/lib/content.js index e814efe4d..7b58e9d67 100644 --- a/src/lib/content.js +++ b/src/lib/content.js @@ -11,6 +11,28 @@ import { env } from './sharedState' /** @typedef {import('../../types/config.js').RawFile} RawFile */ /** @typedef {import('../../types/config.js').FilePath} FilePath */ +/* + * @param {import('tailwindcss').Config} tailwindConfig + * @param {{skip:string[]}} options + * @returns {ContentPath[]} + */ +function resolveContentFiles(tailwindConfig, { skip = [] } = {}) { + if (tailwindConfig.content.files === 'auto' && __OXIDE__) { + env.DEBUG && console.time('Calculating resolve content paths') + tailwindConfig.content.files = require('@tailwindcss/oxide').resolveContentPaths({ + base: process.cwd(), + }) + if (skip.length > 0) { + tailwindConfig.content.files = tailwindConfig.content.files.filter( + (filePath) => !skip.includes(filePath) + ) + } + env.DEBUG && console.timeEnd('Calculating resolve content paths') + } + + return tailwindConfig.content.files +} + /** * @typedef {object} ContentPath * @property {string} original @@ -32,7 +54,9 @@ import { env } from './sharedState' * @returns {ContentPath[]} */ export function parseCandidateFiles(context, tailwindConfig) { - let files = tailwindConfig.content.files + let files = resolveContentFiles(tailwindConfig, { + skip: [context.userConfigPath], + }) // Normalize the file globs files = files.filter((filePath) => typeof filePath === 'string') @@ -167,9 +191,12 @@ function resolvePathSymlinks(contentPath) { * @returns {[{ content: string, extension: string }[], Map]} */ export function resolvedChangedContent(context, candidateFiles, fileModifiedMap) { - let changedContent = context.tailwindConfig.content.files - .filter((item) => typeof item.raw === 'string') - .map(({ raw, extension = 'html' }) => ({ content: raw, extension })) + let changedContent = + context.tailwindConfig.content.files === 'auto' && __OXIDE__ + ? [] + : context.tailwindConfig.content.files + .filter((item) => typeof item.raw === 'string') + .map(({ raw, extension = 'html' }) => ({ content: raw, extension })) let [changedFiles, mTimesToCommit] = resolveChangedFiles(candidateFiles, fileModifiedMap) diff --git a/src/oxide/cli/build/plugin.ts b/src/oxide/cli/build/plugin.ts index 4141ec439..aa9bb5b99 100644 --- a/src/oxide/cli/build/plugin.ts +++ b/src/oxide/cli/build/plugin.ts @@ -18,6 +18,7 @@ import log from '../../../util/log' import { loadConfig } from '../../../lib/load-config' import getModuleDependencies from '../../../lib/getModuleDependencies' import type { Config } from '../../../../types' +import { validateConfig } from '../../../util/validateConfig' /** * @@ -160,7 +161,13 @@ let state = { } // @ts-ignore - this.configBag.config = resolveConfig(this.configBag.config, { content: { files: [] } }) + if (__OXIDE__) { + this.configBag.config = validateConfig(resolveConfig(this.configBag.config)) + } else { + this.configBag.config = validateConfig( + resolveConfig(this.configBag.config, { content: { files: [] } }) + ) + } // Override content files if `--content` has been passed explicitly if (content?.length > 0) { diff --git a/src/oxide/cli/init/index.ts b/src/oxide/cli/init/index.ts index abc93cdc1..b05138349 100644 --- a/src/oxide/cli/init/index.ts +++ b/src/oxide/cli/init/index.ts @@ -36,6 +36,11 @@ export function init(args) { 'utf8' ) + // Drop `content` in the oxide engine to promote auto content + if (__OXIDE__) { + stubContentsFile = stubContentsFile.replace(/\s*content: \[\],\n/, '') + } + // Change colors import stubContentsFile = stubContentsFile.replace('../colors', 'tailwindcss/colors') diff --git a/src/util/getAllConfigs.js b/src/util/getAllConfigs.js index ce3665b97..90a20e11a 100644 --- a/src/util/getAllConfigs.js +++ b/src/util/getAllConfigs.js @@ -2,7 +2,14 @@ import defaultFullConfig from '../../stubs/config.full.js' import { flagEnabled } from '../featureFlags' export default function getAllConfigs(config) { - const configs = (config?.presets ?? [defaultFullConfig]) + const configs = ( + config?.presets ?? [ + __OXIDE__ + ? // Drop `content` in the oxide engine to promote auto content + Object.assign({}, defaultFullConfig, { content: 'auto' }) + : defaultFullConfig, + ] + ) .slice() .reverse() .flatMap((preset) => getAllConfigs(preset instanceof Function ? preset() : preset)) diff --git a/src/util/normalizeConfig.js b/src/util/normalizeConfig.js index 7e1a592de..a79aaefd0 100644 --- a/src/util/normalizeConfig.js +++ b/src/util/normalizeConfig.js @@ -18,6 +18,10 @@ export function normalizeConfig(config) { * } */ let valid = (() => { + if (config.content === 'auto') { + return true + } + // `config.purge` should not exist anymore if (config.purge) { return false @@ -181,6 +185,31 @@ export function normalizeConfig(config) { config.prefix = config.prefix ?? '' } + let auto = (() => { + // Config still has a `purge` option (for backwards compatibility), auto content should not be + // used + if (config.purge) return false + + // + if (config.content === 'auto') return true + + // We don't have content at all, auto content should be used + if (config.content === undefined) return true + + // We do have content as an object, but we don't have any files defined, auto content should + // be used + if ( + typeof config.content === 'object' && + config.content !== null && + !Array.isArray(config.content) + ) { + return config.content.files === undefined + } + + // We do have content defined, auto content should not be used + return false + })() + // Normalize the `content` config.content = { relative: (() => { @@ -193,17 +222,20 @@ export function normalizeConfig(config) { return flagEnabled(config, 'relativeContentPathsByDefault') })(), - files: (() => { - let { content, purge } = config + files: auto + ? 'auto' + : (() => { + let { content, purge } = config - if (Array.isArray(purge)) return purge - if (Array.isArray(purge?.content)) return purge.content - if (Array.isArray(content)) return content - if (Array.isArray(content?.content)) return content.content - if (Array.isArray(content?.files)) return content.files + if (content === undefined && purge === undefined) return [] + if (Array.isArray(purge)) return purge + if (Array.isArray(purge?.content)) return purge.content + if (Array.isArray(content)) return content + if (Array.isArray(content?.content)) return content.content + if (Array.isArray(content?.files)) return content.files - return [] - })(), + return [] + })(), extract: (() => { let extract = (() => { @@ -286,14 +318,16 @@ export function normalizeConfig(config) { // Validate globs to prevent bogus globs. // E.g.: `./src/*.{html}` is invalid, the `{html}` should just be `html` - for (let file of config.content.files) { - if (typeof file === 'string' && /{([^,]*?)}/g.test(file)) { - log.warn('invalid-glob-braces', [ - `The glob pattern ${dim(file)} in your Tailwind CSS configuration is invalid.`, - `Update it to ${dim(file.replace(/{([^,]*?)}/g, '$1'))} to silence this warning.`, - // TODO: Add https://tw.wtf/invalid-glob-braces - ]) - break + if (config.content.files !== 'auto') { + for (let file of config.content.files) { + if (typeof file === 'string' && /{([^,]*?)}/g.test(file)) { + log.warn('invalid-glob-braces', [ + `The glob pattern ${dim(file)} in your Tailwind CSS configuration is invalid.`, + `Update it to ${dim(file.replace(/{([^,]*?)}/g, '$1'))} to silence this warning.`, + // TODO: Add https://tw.wtf/invalid-glob-braces + ]) + break + } } } diff --git a/src/util/validateConfig.js b/src/util/validateConfig.js index 8c22e4450..dfba01844 100644 --- a/src/util/validateConfig.js +++ b/src/util/validateConfig.js @@ -1,7 +1,7 @@ import log from './log' export function validateConfig(config) { - if (config.content.files.length === 0) { + if (config.content.files !== 'auto' && config.content.files.length === 0) { log.warn('content-problems', [ 'The `content` option in your Tailwind CSS configuration is missing or empty.', 'Configure your content sources or your generated CSS will be missing styles.', @@ -9,6 +9,13 @@ export function validateConfig(config) { ]) } + if (config.content.files === 'auto') { + log.warn('auto-content-experimental', [ + 'Automatic content detection in Tailwind CSS is currently in experimental preview.', + 'Preview features are not covered by semver, and may change at any time.', + ]) + } + // Warn if the line-clamp plugin is installed try { let plugin = require('@tailwindcss/line-clamp') diff --git a/tests/normalize-config.test.js b/tests/normalize-config.test.js index a0b7a59bb..47980cbdd 100644 --- a/tests/normalize-config.test.js +++ b/tests/normalize-config.test.js @@ -7,7 +7,7 @@ crosscheck(({ stable, oxide }) => { config ${{ purge: [{ raw: 'text-center' }] }} ${{ purge: { content: [{ raw: 'text-center' }] } }} - ${{ content: { content: [{ raw: 'text-center' }] } }} + ${{ content: { files: [], content: [{ raw: 'text-center' }] } }} `('should normalize content $config', ({ config }) => { return run('@tailwind utilities', config).then((result) => { return expect(result.css).toMatchFormattedCss(css` @@ -22,7 +22,7 @@ crosscheck(({ stable, oxide }) => { config ${{ purge: { safelist: ['text-center'] } }} ${{ purge: { options: { safelist: ['text-center'] } } }} - ${{ content: { safelist: ['text-center'] } }} + ${{ content: { files: [], safelist: ['text-center'] } }} `('should normalize safelist $config', ({ config }) => { return run('@tailwind utilities', config).then((result) => { return expect(result.css).toMatchFormattedCss(css` diff --git a/tests/warnings.test.js b/tests/warnings.test.js index e3ea94fe5..d2be7189f 100644 --- a/tests/warnings.test.js +++ b/tests/warnings.test.js @@ -1,7 +1,9 @@ import { crosscheck, run, html, css } from './util/run' -crosscheck(() => { - test('it warns when there is no content key', async () => { +crosscheck(({ oxide, stable }) => { + // The oxide engine will use auto content if no `content` is given + oxide.test.todo('it warns when there is no content key') + stable.test('it warns when there is no content key', async () => { let config = { corePlugins: { preflight: false }, }