Robin Malfait 7e9a53f6cb
Enable ESM and TS based config files (#10785)
* add `jiti` and `detective-typescript` dependencies

* use `jiti` and `detective-typescript`

Instead of `detective`, this way we will be able to support
`tailwind.config.ts` files and `ESM` files.

* use `@swc/core` instead of the built-in `babel` form `jiti`

* update changelog

* add `jiti` and `detective-typescript` dependencies to `stable`

* use `sucrase` to transform the configs

* add `sucrase` dependency to `stable` engine

* make loading the config easier

* use abstracted loading config utils

* WIP: make `load` related files public API

* use new config loader in PostCSS plugin

* add list of default config files to look for

* cleanup unused arguments

* find default config path when using CLI

* improve `init` command

* make eslint happy

* keep all files in `stubs` folder

* add `tailwind.config.js` stub file

* Initialize PostCSS config using the same format as Tailwind config

* Rename config content stubs to config.*.js

* Improve option descriptions for init options

* Remove unused code, remove `constants` file

* Fix TS warning

* apply CLI changes to the Oxide version

* update `--help` output in CLI tests

* WIP: make tests work on CI

TODO: Test all combinations of `--full`, `--ts`, `--postcss`, and `--esm`.

* wip

* remove unused `fs`

* Fix init tests

Did you know you could pass an empty args to a command? No? Me neither. ¯\_(ツ)_/¯

* bump `napi-derive`

* list extensions we are interested in

* no-op the `removeFile` if file doesn't exist

* ensure all `init` flags work

* ensure we cleanup the new files

* test ESM/CJS generation based on package.json

* remove unnecessary test

We are not displaying output in the `--help` anymore based on whether
`type: module` is present or not.
Therefore this test is unneeded.

* only look for `TypeScript` files when the entryFile is `TypeScript` as well

* refactor `load` to be `loadConfig`

This will allow you to use:

```js
import loadConfig from 'tailwindcss/loadConfig'

let config = loadConfig("/Users/xyz/projects/my-app/tailwind.config.ts")
```

The `loadConfig` function will return the configuration object based on
the given absolute path of a tailwind configuration file.

The given path can be a CJS, an ESM or a TS file.

* use the `config.full.js` stub instead of the `defaultConfig.stub.js` file

The root `defaultConfig` is still there for backwards compatibilty
reasons. But the `module.exports = requrie('./config.full.js')` was
causing some problems when actually using tailwindcss.

So dropped it instead.

* apply `load` -> `loadConfig` changes to `Oxide` engine CLI

* ensure we write the config file in the Oxide engine

* improve type in Oxide engine CLI

* catch errors instead of checking if the file exists

A little smaller but just for tests so doesn't matter too much here 👍

* ensure we publish the correct stub files

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
Co-authored-by: Nate Moore <nate@natemoo.re>
Co-authored-by: Enzo Innocenzi <enzo@innocenzi.dev>
2023-03-15 17:04:18 -04:00

181 lines
5.3 KiB
JavaScript

let { rm, existsSync } = require('fs')
let path = require('path')
let fs = require('fs/promises')
let chokidar = require('chokidar')
let resolveToolRoot = require('./resolve-tool-root')
function getWatcherOptions() {
return {
usePolling: true,
interval: 200,
awaitWriteFinish: {
stabilityThreshold: 1500,
pollInterval: 50,
},
}
}
module.exports = function ({
/** Output directory, relative to the tool. */
output = 'dist',
/** Input directory, relative to the tool. */
input = 'src',
/** Whether or not you want to cleanup the output directory. */
cleanup = true,
} = {}) {
let toolRoot = resolveToolRoot()
let fileCache = {}
let absoluteOutputFolder = path.resolve(toolRoot, output)
let absoluteInputFolder = path.resolve(toolRoot, input)
if (cleanup) {
beforeAll((done) => rm(absoluteOutputFolder, { recursive: true, force: true }, done))
afterEach((done) => rm(absoluteOutputFolder, { recursive: true, force: true }, done))
}
// Restore all written files
afterEach(async () => {
await Promise.all(
Object.entries(fileCache).map(async ([file, content]) => {
try {
if (content === null) {
return await fs.unlink(file)
} else {
return await fs.writeFile(file, content, 'utf8')
}
} catch {}
})
)
})
async function readdir(start, parent = []) {
let files = await fs.readdir(start, { withFileTypes: true })
let resolvedFiles = await Promise.all(
files.map((file) => {
if (file.isDirectory()) {
return readdir(path.resolve(start, file.name), [...parent, file.name])
}
return parent.concat(file.name).join(path.sep)
})
)
return resolvedFiles.flat(Infinity)
}
async function resolveFile(fileOrRegex, directory) {
if (fileOrRegex instanceof RegExp) {
let files = await readdir(directory)
if (files.length === 0) {
throw new Error(`No files exists in "${directory}"`)
}
let filtered = files.filter((file) => fileOrRegex.test(file))
if (filtered.length === 0) {
throw new Error(`Not a single file matched: ${fileOrRegex}`)
} else if (filtered.length > 1) {
throw new Error(`Multiple files matched: ${fileOrRegex}`)
}
return filtered[0]
}
return fileOrRegex
}
return {
cleanupFile(file) {
let filePath = path.resolve(toolRoot, file)
fileCache[filePath] = null
},
async fileExists(file) {
let filePath = path.resolve(toolRoot, file)
return existsSync(filePath)
},
async removeFile(file) {
let filePath = path.resolve(toolRoot, file)
if (!fileCache[filePath]) {
fileCache[filePath] = await fs.readFile(filePath, 'utf8').catch(() => null)
}
await fs.unlink(filePath).catch(() => null)
},
async readOutputFile(file) {
file = await resolveFile(file, absoluteOutputFolder)
return fs.readFile(path.resolve(absoluteOutputFolder, file), 'utf8')
},
async readInputFile(file) {
file = await resolveFile(file, absoluteInputFolder)
return fs.readFile(path.resolve(absoluteInputFolder, file), 'utf8')
},
async appendToInputFile(file, contents) {
let filePath = path.resolve(absoluteInputFolder, file)
if (!fileCache[filePath]) {
fileCache[filePath] = await fs.readFile(filePath, 'utf8')
}
return fs.appendFile(filePath, contents, 'utf8')
},
async writeInputFile(file, contents) {
let filePath = path.resolve(absoluteInputFolder, file)
if (!fileCache[filePath]) {
try {
fileCache[filePath] = await fs.readFile(filePath, 'utf8')
} catch (err) {
if (err.code === 'ENOENT') {
fileCache[filePath] = null // Sentinel value to `delete` the file afterwards. This also means that we are writing to a `new` file inside the test.
} else {
throw err
}
}
}
return fs.writeFile(path.resolve(absoluteInputFolder, file), contents, 'utf8')
},
async waitForOutputFileCreation(file) {
if (file instanceof RegExp) {
let r = file
let watcher = chokidar.watch(absoluteOutputFolder, getWatcherOptions())
return new Promise((resolve) => {
watcher.on('add', (file) => {
if (r.test(file)) {
watcher.close().then(() => resolve())
}
})
})
} else {
let filePath = path.resolve(absoluteOutputFolder, file)
return new Promise((resolve) => {
let watcher = chokidar.watch(absoluteOutputFolder, getWatcherOptions())
watcher.on('add', (addedFile) => {
if (addedFile !== filePath) return
return watcher.close().finally(resolve)
})
})
}
},
async waitForOutputFileChange(file, cb = () => {}) {
file = await resolveFile(file, absoluteOutputFolder)
let filePath = path.resolve(absoluteOutputFolder, file)
return new Promise((resolve) => {
let watcher = chokidar.watch(absoluteOutputFolder, getWatcherOptions())
watcher
.on('change', (changedFile) => {
if (changedFile !== filePath) return
return watcher.close().finally(resolve)
})
.on('ready', cb)
})
},
}
}