mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
This PR is an umbrella PR where we will add support for the new `@source` directive. This will allow you to add explicit content glob patterns if you want to look for Tailwind classes in other files that are not automatically detected yet. Right now this is an addition to the existing auto content detection that is automatically enabled in the `@tailwindcss/postcss` and `@tailwindcss/cli` packages. The `@tailwindcss/vite` package doesn't use the auto content detection, but uses the module graph instead. From an API perspective there is not a lot going on. There are only a few things that you have to know when using the `@source` directive, and you probably already know the rules: 1. You can use multiple `@source` directives if you want. 2. The `@source` accepts a glob pattern so that you can match multiple files at once 3. The pattern is relative to the current file you are in 4. The pattern includes all files it is matching, even git ignored files 1. The motivation for this is so that you can explicitly point to a `node_modules` folder if you want to look at `node_modules` for whatever reason. 6. Right now we don't support negative globs (starting with a `!`) yet, that will be available in the near future. Usage example: ```css /* ./src/input.css */ @import "tailwindcss"; @source "../laravel/resources/views/**/*.blade.php"; @source "../../packages/monorepo-package/**/*.js"; ``` It looks like the PR introduced a lot of changes, but this is a side effect of all the other plumbing work we had to do to make this work. For example: 1. We added dedicated integration tests that run on Linux and Windows in CI (just to make sure that all the `path` logic is correct) 2. We Have to make sure that the glob patterns are always correct even if you are using `@import` in your CSS and use `@source` in an imported file. This is because we receive the flattened CSS contents where all `@import`s are inlined. 3. We have to make sure that we also listen for changes in the files that match any of these patterns and trigger a rebuild. PRs: - [x] https://github.com/tailwindlabs/tailwindcss/pull/14063 - [x] https://github.com/tailwindlabs/tailwindcss/pull/14085 - [x] https://github.com/tailwindlabs/tailwindcss/pull/14079 - [x] https://github.com/tailwindlabs/tailwindcss/pull/14067 - [x] https://github.com/tailwindlabs/tailwindcss/pull/14076 - [x] https://github.com/tailwindlabs/tailwindcss/pull/14080 - [x] https://github.com/tailwindlabs/tailwindcss/pull/14127 - [x] https://github.com/tailwindlabs/tailwindcss/pull/14135 Once all the PRs are merged, then this umbrella PR can be merged. > [!IMPORTANT] > Make sure to merge this without rebasing such that each individual PR ends up on the main branch. --------- Co-authored-by: Philipp Spiess <hello@philippspiess.com> Co-authored-by: Jordan Pittman <jordan@cryptica.me> Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
import dedent from 'dedent'
|
||
import fastGlob from 'fast-glob'
|
||
import killPort from 'kill-port'
|
||
import { execSync, spawn } from 'node:child_process'
|
||
import fs from 'node:fs/promises'
|
||
import net from 'node:net'
|
||
import { homedir, platform, tmpdir } from 'node:os'
|
||
import path from 'node:path'
|
||
import { test as defaultTest, expect } from 'vitest'
|
||
|
||
const REPO_ROOT = path.join(__dirname, '..')
|
||
const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) =>
|
||
name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''),
|
||
)
|
||
|
||
interface SpawnedProcess {
|
||
dispose: () => void
|
||
onStdout: (predicate: (message: string) => boolean) => Promise<void>
|
||
onStderr: (predicate: (message: string) => boolean) => Promise<void>
|
||
}
|
||
|
||
interface ChildProcessOptions {
|
||
cwd?: string
|
||
}
|
||
|
||
interface TestConfig {
|
||
fs: {
|
||
[filePath: string]: string
|
||
}
|
||
}
|
||
interface TestContext {
|
||
root: string
|
||
exec(command: string, options?: ChildProcessOptions): Promise<string>
|
||
spawn(command: string, options?: ChildProcessOptions): Promise<SpawnedProcess>
|
||
getFreePort(): Promise<number>
|
||
fs: {
|
||
write(filePath: string, content: string): Promise<void>
|
||
read(filePath: string): Promise<string>
|
||
glob(pattern: string): Promise<[string, string][]>
|
||
expectFileToContain(filePath: string, contents: string | string[]): Promise<void>
|
||
}
|
||
}
|
||
type TestCallback = (context: TestContext) => Promise<void> | void
|
||
|
||
type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void }
|
||
|
||
const TEST_TIMEOUT = 30000
|
||
const ASSERTION_TIMEOUT = 5000
|
||
|
||
export function test(
|
||
name: string,
|
||
config: TestConfig,
|
||
testCallback: TestCallback,
|
||
{ only = false } = {},
|
||
) {
|
||
return (only ? defaultTest.only : defaultTest)(
|
||
name,
|
||
{ timeout: TEST_TIMEOUT },
|
||
async (options) => {
|
||
let root = await fs.mkdtemp(
|
||
// On Windows CI, tmpdir returns a path containing a weird RUNNER~1 folder
|
||
// that apparently causes the vite builds to not work.
|
||
path.join(
|
||
process.env.CI && platform() === 'win32' ? homedir() : tmpdir(),
|
||
'tailwind-integrations',
|
||
),
|
||
)
|
||
|
||
async function write(filename: string, content: string): Promise<void> {
|
||
let full = path.join(root, filename)
|
||
|
||
if (filename.endsWith('package.json')) {
|
||
content = await overwriteVersionsInPackageJson(content)
|
||
}
|
||
|
||
// Ensure that files written on Windows use \r\n line ending
|
||
if (platform() === 'win32') {
|
||
content = content.replace(/\n/g, '\r\n')
|
||
}
|
||
|
||
let dir = path.dirname(full)
|
||
await fs.mkdir(dir, { recursive: true })
|
||
await fs.writeFile(full, content)
|
||
}
|
||
|
||
for (let [filename, content] of Object.entries(config.fs)) {
|
||
await write(filename, content)
|
||
}
|
||
|
||
try {
|
||
execSync('pnpm install', { cwd: root })
|
||
} catch (error: any) {
|
||
console.error(error.stdout.toString())
|
||
console.error(error.stderr.toString())
|
||
throw error
|
||
}
|
||
|
||
let disposables: (() => Promise<void>)[] = []
|
||
|
||
async function dispose() {
|
||
await Promise.all(disposables.map((dispose) => dispose()))
|
||
try {
|
||
await fs.rm(root, { recursive: true, maxRetries: 5, force: true })
|
||
} catch (err) {
|
||
if (!process.env.CI) {
|
||
throw err
|
||
}
|
||
}
|
||
}
|
||
|
||
options.onTestFinished(dispose)
|
||
|
||
let context = {
|
||
root,
|
||
async exec(command: string, childProcessOptions: ChildProcessOptions = {}) {
|
||
return execSync(command, {
|
||
cwd: root,
|
||
stdio: 'pipe',
|
||
...childProcessOptions,
|
||
}).toString()
|
||
},
|
||
async spawn(command: string, childProcessOptions: ChildProcessOptions = {}) {
|
||
let resolveDisposal: (() => void) | undefined
|
||
let rejectDisposal: ((error: Error) => void) | undefined
|
||
let disposePromise = new Promise<void>((resolve, reject) => {
|
||
resolveDisposal = resolve
|
||
rejectDisposal = reject
|
||
})
|
||
|
||
let child = spawn(command, {
|
||
cwd: root,
|
||
shell: true,
|
||
env: {
|
||
...process.env,
|
||
},
|
||
...childProcessOptions,
|
||
})
|
||
|
||
function dispose() {
|
||
child.kill()
|
||
|
||
let timer = setTimeout(
|
||
() =>
|
||
rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)),
|
||
ASSERTION_TIMEOUT,
|
||
)
|
||
disposePromise.finally(() => clearTimeout(timer))
|
||
return disposePromise
|
||
}
|
||
disposables.push(dispose)
|
||
function onExit() {
|
||
resolveDisposal?.()
|
||
}
|
||
|
||
let stdoutMessages: string[] = []
|
||
let stderrMessages: string[] = []
|
||
|
||
let stdoutActors: SpawnActor[] = []
|
||
let stderrActors: SpawnActor[] = []
|
||
|
||
function notifyNext(actors: SpawnActor[], messages: string[]) {
|
||
if (actors.length <= 0) return
|
||
let [next] = actors
|
||
|
||
for (let [idx, message] of messages.entries()) {
|
||
if (next.predicate(message)) {
|
||
messages.splice(0, idx + 1)
|
||
let actorIdx = actors.indexOf(next)
|
||
actors.splice(actorIdx, 1)
|
||
next.resolve()
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
let combined: ['stdout' | 'stderr', string][] = []
|
||
|
||
child.stdout.on('data', (result) => {
|
||
let content = result.toString()
|
||
combined.push(['stdout', content])
|
||
stdoutMessages.push(content)
|
||
notifyNext(stdoutActors, stdoutMessages)
|
||
})
|
||
child.stderr.on('data', (result) => {
|
||
let content = result.toString()
|
||
combined.push(['stderr', content])
|
||
stderrMessages.push(content)
|
||
notifyNext(stderrActors, stderrMessages)
|
||
})
|
||
child.on('exit', onExit)
|
||
child.on('error', (error) => {
|
||
if (error.name !== 'AbortError') {
|
||
throw error
|
||
}
|
||
})
|
||
|
||
options.onTestFailed(() => {
|
||
for (let [type, message] of combined) {
|
||
if (type === 'stdout') {
|
||
console.log(message)
|
||
} else {
|
||
console.error(message)
|
||
}
|
||
}
|
||
})
|
||
|
||
return {
|
||
dispose,
|
||
onStdout(predicate: (message: string) => boolean) {
|
||
return new Promise<void>((resolve) => {
|
||
stdoutActors.push({ predicate, resolve })
|
||
notifyNext(stdoutActors, stdoutMessages)
|
||
})
|
||
},
|
||
onStderr(predicate: (message: string) => boolean) {
|
||
return new Promise<void>((resolve) => {
|
||
stderrActors.push({ predicate, resolve })
|
||
notifyNext(stderrActors, stderrMessages)
|
||
})
|
||
},
|
||
}
|
||
},
|
||
async getFreePort(): Promise<number> {
|
||
return new Promise((resolve, reject) => {
|
||
let server = net.createServer()
|
||
server.listen(0, () => {
|
||
let address = server.address()
|
||
let port = address === null || typeof address === 'string' ? null : address.port
|
||
|
||
server.close(() => {
|
||
if (port === null) {
|
||
reject(new Error(`Failed to get a free port: address is ${address}`))
|
||
} else {
|
||
disposables.push(async () => {
|
||
// Wait for 10ms in case the process was just killed
|
||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||
|
||
// kill-port uses `lsof` on macOS which is expensive and can
|
||
// block for multiple seconds. In order to avoid that for a
|
||
// server that is no longer running, we check if the port is
|
||
// still in use first.
|
||
let isPortTaken = await testIfPortTaken(port)
|
||
if (!isPortTaken) {
|
||
return
|
||
}
|
||
|
||
await killPort(port)
|
||
})
|
||
resolve(port)
|
||
}
|
||
})
|
||
})
|
||
})
|
||
},
|
||
fs: {
|
||
write,
|
||
read(filePath: string) {
|
||
return fs.readFile(path.resolve(root, filePath), 'utf8')
|
||
},
|
||
async glob(pattern: string) {
|
||
let files = await fastGlob(pattern, { cwd: root })
|
||
return Promise.all(
|
||
files.map(async (file) => {
|
||
let content = await fs.readFile(path.join(root, file), 'utf8')
|
||
return [file, content]
|
||
}),
|
||
)
|
||
},
|
||
async expectFileToContain(filePath, contents) {
|
||
return retryUntil(async () => {
|
||
let fileContent = await this.read(filePath)
|
||
for (let content of contents) {
|
||
expect(fileContent).toContain(content)
|
||
}
|
||
})
|
||
},
|
||
},
|
||
} satisfies TestContext
|
||
|
||
await testCallback(context)
|
||
},
|
||
)
|
||
}
|
||
test.only = (name: string, config: TestConfig, testCallback: TestCallback) => {
|
||
return test(name, config, testCallback, { only: true })
|
||
}
|
||
|
||
// Maps package names to their tarball filenames. See scripts/pack-packages.ts
|
||
// for more details.
|
||
function pkgToFilename(name: string) {
|
||
return `${name.replace('@', '').replace('/', '-')}.tgz`
|
||
}
|
||
|
||
async function overwriteVersionsInPackageJson(content: string): Promise<string> {
|
||
let json = JSON.parse(content)
|
||
|
||
// Resolve all workspace:^ versions to local tarballs
|
||
;['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach(
|
||
(key) => {
|
||
let dependencies = json[key] || {}
|
||
for (let dependency in dependencies) {
|
||
if (dependencies[dependency] === 'workspace:^') {
|
||
dependencies[dependency] = resolveVersion(dependency)
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
// Inject transitive dependency overwrite. This is necessary because
|
||
// @tailwindcss/vite internally depends on a specific version of
|
||
// @tailwindcss/oxide and we instead want to resolve it to the locally built
|
||
// version.
|
||
json.pnpm ||= {}
|
||
json.pnpm.overrides ||= {}
|
||
for (let pkg of PUBLIC_PACKAGES) {
|
||
json.pnpm.overrides[pkg] = resolveVersion(pkg)
|
||
}
|
||
|
||
return JSON.stringify(json, null, 2)
|
||
}
|
||
|
||
function resolveVersion(dependency: string) {
|
||
let tarball = path.join(REPO_ROOT, 'dist', pkgToFilename(dependency))
|
||
return `file:${tarball}`
|
||
}
|
||
|
||
export function stripTailwindComment(content: string) {
|
||
return content.replace(/\/\*! tailwindcss .*? \*\//g, '').trim()
|
||
}
|
||
|
||
function testIfPortTaken(port: number): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
let client = new net.Socket()
|
||
client.once('connect', () => {
|
||
resolve(true)
|
||
client.end()
|
||
})
|
||
client.once('error', (error: any) => {
|
||
if (error.code !== 'ECONNREFUSED') {
|
||
resolve(true)
|
||
} else {
|
||
resolve(false)
|
||
}
|
||
client.end()
|
||
})
|
||
client.connect({ port: port, host: 'localhost' })
|
||
})
|
||
}
|
||
|
||
export let css = dedent
|
||
export let html = dedent
|
||
export let ts = dedent
|
||
export let js = dedent
|
||
export let json = dedent
|
||
export let yaml = dedent
|
||
export let txt = dedent
|
||
|
||
export function candidate(strings: TemplateStringsArray, ...values: any[]) {
|
||
let output: string[] = []
|
||
for (let i = 0; i < strings.length; i++) {
|
||
output.push(strings[i])
|
||
if (i < values.length) {
|
||
output.push(values[i])
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
async function retryUntil<T>(
|
||
fn: () => Promise<T>,
|
||
{ timeout = ASSERTION_TIMEOUT, delay = 5 }: { timeout?: number; delay?: number } = {},
|
||
) {
|
||
let end = Date.now() + timeout
|
||
let error: any
|
||
while (Date.now() < end) {
|
||
try {
|
||
return await fn()
|
||
} catch (err) {
|
||
error = err
|
||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||
}
|
||
}
|
||
throw error
|
||
}
|