diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..dd22785 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,71 @@ +# Greatly inspired from +# https://github.com/paskausks/rust-bin-github-workflows/blob/894a4f2debade42f8d7b5b95f493eaa33fdeb81b/.github/workflows/release.yml + +name: Create release + +on: + push: + tags: + - 'v*' + +env: + RELEASE_BIN: esbuild-config + RELEASE_ADDS: README.md LICENSE + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + build: [linux, macos, windows] + include: + - build: linux + os: ubuntu-latest + rust: stable + - build: macos + os: macos-latest + rust: stable + - build: windows + os: windows-latest + rust: stable + + steps: + - uses: actions/checkout@v2 + + - name: Install Rust (rustup) + run: rustup update ${{ matrix.rust }} --no-self-update && rustup default ${{ matrix.rust }} + shell: bash + + - name: Build + run: cargo build --verbose --release + + - name: Create artifact directory + run: mkdir artifacts + + - name: Create archive for Linux + run: 7z a -ttar -so -an ./target/release/${{ env.RELEASE_BIN }} ${{ env.RELEASE_ADDS }} | 7z a -si ./artifacts/${{ env.RELEASE_BIN }}-linux-x86_64.tar.gz + if: matrix.os == 'ubuntu-latest' + + - name: Create archive for Windows + run: | + 7z a ./tmp/${{ env.RELEASE_BIN }}-windows-x86_64.tar ./target/release/${{ env.RELEASE_BIN }}.exe ${{ env.RELEASE_ADDS }} + 7z a ./artifacts/${{ env.RELEASE_BIN }}-windows-x86_64.tar.gz ./tmp/${{ env.RELEASE_BIN }}-windows-x86_64.tar + if: matrix.os == 'windows-latest' + + - name: Install p7zip + # 7Zip not available on MacOS, install p7zip via homebrew. + run: brew install p7zip + if: matrix.os == 'macos-latest' + + - name: Create archive for MacOS + run: 7z a -ttar -so -an ./target/release/${{ env.RELEASE_BIN }} ${{ env.RELEASE_ADDS }} | 7z a -si ./artifacts/${{ env.RELEASE_BIN }}-macos-x86_64.tar.gz + if: matrix.os == 'macos-latest' + + # This will double-zip + # See - https://github.com/actions/upload-artifact/issues/39 + - uses: actions/upload-artifact@v1 + name: Upload archive + with: + name: ${{ runner.os }} + path: artifacts/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc3de31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target +**/*.rs.bk +node_modules/ +yarn.lock + +/npm/esbuild-config-linux-64/bin/esbuild-config +/npm/esbuild-config-darwin-64/bin/esbuild-config +/npm/esbuild-config-windows-64/esbuild-config.exe diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e6976d4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,88 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "esbuild-config" +version = "0.1.0" +dependencies = [ + "json 0.12.4 (registry+https://github.com/rust-lang/crates.io-index)", + "snailquote 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "json" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "proc-macro2" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "unicode-xid 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "quote" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 1.0.19 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "snailquote" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "thiserror 1.0.20 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode_categories 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "syn" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 1.0.19 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.7 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-xid 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "thiserror" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "thiserror-impl 1.0.20 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 1.0.19 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.7 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.38 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "unicode-xid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[metadata] +"checksum json 0.12.4 (registry+https://github.com/rust-lang/crates.io-index)" = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" +"checksum proc-macro2 1.0.19 (registry+https://github.com/rust-lang/crates.io-index)" = "04f5f085b5d71e2188cb8271e5da0161ad52c3f227a661a3c135fdf28e258b12" +"checksum quote 1.0.7 (registry+https://github.com/rust-lang/crates.io-index)" = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37" +"checksum snailquote 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f34b729d802f52194598858ac852c3fb3b33f6e026cd03195072ccb7bf3fc810" +"checksum syn 1.0.38 (registry+https://github.com/rust-lang/crates.io-index)" = "e69abc24912995b3038597a7a593be5053eb0fb44f3cc5beec0deb421790c1f4" +"checksum thiserror 1.0.20 (registry+https://github.com/rust-lang/crates.io-index)" = "7dfdd070ccd8ccb78f4ad66bf1982dc37f620ef696c6b5028fe2ed83dd3d0d08" +"checksum thiserror-impl 1.0.20 (registry+https://github.com/rust-lang/crates.io-index)" = "bd80fc12f73063ac132ac92aceea36734f04a1d93c1240c6944e23a3b8841793" +"checksum unicode-xid 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" +"checksum unicode_categories 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e110236 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "esbuild-config" +version = "0.1.0" +edition = "2018" +description = "A short description of my package" +authors = ["Pierre Bertet "] +repository = "https://github.com/bpierre/esbuild-config" +license = "MIT" + +[dependencies] +json = "0.12.4" +snailquote = "0.3.0" + +[[bin]] +name = "esbuild-config" +path = "src/main.rs" +test = false + +[lib] +name = "esbuild_config_lib" +path = "src/lib/mod.rs" +doctest = false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1de767b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Pierre Bertet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6c6773 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# esbuild-config + +Config files for [esbuild](https://github.com/evanw/esbuild). + +## Why? + +esbuild is an incredible tool, that is [exclusively using command line parameters](https://github.com/evanw/esbuild/issues/39) as a configuration syntax. Some people prefer configuration files, so I thought it could be a good idea to provide a solution for this. It is also for me a pretext to use Rust while learning it :) + +## Usage + +The esbuild-config command outputs a list of parameters based on a `esbuild.config.json` file, that can get passed to esbuild directly: + +```console +esbuild $(esbuild-config) +``` + +It detects the presence of `esbuild.config.json` in the current directory, or the project root (using the presence of a `package.json` file). Any file can also get provided as a parameter: + +```console +esbuild $(esbuild-config ./my-conf.json) +``` + +## Install + +You have different options to install esbuild-config. + +### npm + +Install globally with npm using the following command: + +```console +npm install --global esbuild-config +``` + +You can also add it to your project: + +```console +npm install --save-dev esbuild-config +``` + +### Cargo + +Install it with [Cargo](https://github.com/rust-lang/cargo) using the following command: + +```console +cargo install esbuild-config +``` + +### Binaries + +You can download the precompiled binaries [from the release page](https://github.com/bpierre/esbuild-config/releases). + +### From source + +To clone the repository and build esbuild-config, run these commands ([after having installed Rust](https://www.rust-lang.org/tools/install)): + +```console +git clone git@github.com:bpierre/esbuild-config.git +cd esbuild-config +cargo build --release +``` + +The compiled binary is at `target/release/esbuild-config`. + +## Syntax + +esbuild-config doesn’t do any validation on the configuration values: it only converts JSON types into arguments that are compatible with the format esbuild uses for its arguments. This makes it independent from esbuild versions, assuming the format doesn’t change. + +The only exception to this is the `entry` field, which gets converted into a list of file names (when an array is provided) or a single file name (when a string is provided). + +This is how JSON types get converted: + +```json +{ + "entry": "./index.js", + "outfile": "./bundle.js", + "external": ["react", "react-dom"], + "loader": { ".js": "jsx", ".png": "base64" }, + "minify": true +} +``` + +Output: + +```console +--outfile=./bundle.js --minify --external:react --external:react-dom --loader:.js=jsx --loader:.png=base64 ./index.js +``` + +Notice how the entry, `./index.js`, has been moved to the end. esbuild-config also takes care of escaping the parameters as needed (e.g. by adding quotes). + +## Contribute + +```console +# Run the app +cargo run + +# Run the tests +cargo test + +# Generate the code coverage report +cargo tarpaulin -o Html +``` + +## Special thanks + +[esbuild](https://github.com/evanw/esbuild) and [its author](https://github.com/evanw) obviously, not only for esbuild itself but also for its approach to [install a platform-specific binary through npm](https://github.com/evanw/esbuild/blob/1336fbcf9bcca2f2708f5f575770f13a8440bde3/lib/install.ts), that esbuild-config is also using. + +## License + +[MIT](./LICENSE) diff --git a/npm/esbuild-config-darwin-64/README.md b/npm/esbuild-config-darwin-64/README.md new file mode 100644 index 0000000..fe155bb --- /dev/null +++ b/npm/esbuild-config-darwin-64/README.md @@ -0,0 +1,3 @@ +# esbuild-config + +This is the macOS 64-bit binary for esbuild-config. See https://github.com/bpierre/esbuild-config for details. diff --git a/npm/esbuild-config-darwin-64/package.json b/npm/esbuild-config-darwin-64/package.json new file mode 100644 index 0000000..b0c2e77 --- /dev/null +++ b/npm/esbuild-config-darwin-64/package.json @@ -0,0 +1,11 @@ +{ + "name": "esbuild-config-darwin-64", + "description": "The macOS 64-bit binary for esbuild-config.", + "version": "0.1.0", + "repository": "https://github.com/bpierre/esbuild-config", + "author": "Pierre Bertet ", + "license": "MIT", + "os": ["darwin"], + "cpu": ["x64"], + "directories": { "bin": "bin" } +} diff --git a/npm/esbuild-config-linux-64/README.md b/npm/esbuild-config-linux-64/README.md new file mode 100644 index 0000000..2547053 --- /dev/null +++ b/npm/esbuild-config-linux-64/README.md @@ -0,0 +1,3 @@ +# esbuild-config + +This is the Linux 64-bit binary for esbuild-config. See https://github.com/bpierre/esbuild-config for details. diff --git a/npm/esbuild-config-linux-64/package.json b/npm/esbuild-config-linux-64/package.json new file mode 100644 index 0000000..d0cfb44 --- /dev/null +++ b/npm/esbuild-config-linux-64/package.json @@ -0,0 +1,11 @@ +{ + "name": "esbuild-config-linux-64", + "description": "The Linux 64-bit binary for esbuild-config.", + "version": "0.1.0", + "repository": "https://github.com/bpierre/esbuild-config", + "author": "Pierre Bertet ", + "license": "MIT", + "os": ["linux"], + "cpu": ["x64"], + "directories": { "bin": "bin" } +} diff --git a/npm/esbuild-config-windows-64/README.md b/npm/esbuild-config-windows-64/README.md new file mode 100644 index 0000000..96229e8 --- /dev/null +++ b/npm/esbuild-config-windows-64/README.md @@ -0,0 +1,3 @@ +# esbuild-config + +This is the Windows 64-bit binary for esbuild-config. See https://github.com/bpierre/esbuild-config for details. diff --git a/npm/esbuild-config-windows-64/bin/esbuild-config b/npm/esbuild-config-windows-64/bin/esbuild-config new file mode 100644 index 0000000..9b82f34 --- /dev/null +++ b/npm/esbuild-config-windows-64/bin/esbuild-config @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +// From esbuild: +// https://github.com/evanw/esbuild/blob/1336fbcf9bcca2f2708f5f575770f13a8440bde3/npm/esbuild-windows-64/bin/esbuild + +// Unfortunately even though npm shims "bin" commands on Windows with auto- +// generated forwarding scripts, it doesn't strip the ".exe" from the file name +// first. So it's possible to publish executables via npm on all platforms +// except Windows. I consider this a npm bug. +// +// My workaround is to add this script as another layer of indirection. It'll +// be slower because node has to boot up just to shell out to the actual exe, +// but Windows is somewhat of a second-class platform to npm so it's the best +// I can do I think. +const esbuild_exe = require.resolve('esbuild-windows-64/esbuild-config.exe'); +const child_process = require('child_process'); +child_process.spawnSync(esbuild_exe, process.argv.slice(2), { stdio: 'inherit' }); diff --git a/npm/esbuild-config-windows-64/package.json b/npm/esbuild-config-windows-64/package.json new file mode 100644 index 0000000..97a023e --- /dev/null +++ b/npm/esbuild-config-windows-64/package.json @@ -0,0 +1,11 @@ +{ + "name": "esbuild-config-windows-64", + "description": "The Windows 64-bit binary for esbuild-config.", + "version": "0.1.0", + "repository": "https://github.com/bpierre/esbuild-config", + "author": "Pierre Bertet ", + "license": "MIT", + "os": ["win32"], + "cpu": ["x64"], + "directories": { "bin": "bin" } +} diff --git a/npm/esbuild-config/README.md b/npm/esbuild-config/README.md new file mode 100644 index 0000000..d6c6773 --- /dev/null +++ b/npm/esbuild-config/README.md @@ -0,0 +1,110 @@ +# esbuild-config + +Config files for [esbuild](https://github.com/evanw/esbuild). + +## Why? + +esbuild is an incredible tool, that is [exclusively using command line parameters](https://github.com/evanw/esbuild/issues/39) as a configuration syntax. Some people prefer configuration files, so I thought it could be a good idea to provide a solution for this. It is also for me a pretext to use Rust while learning it :) + +## Usage + +The esbuild-config command outputs a list of parameters based on a `esbuild.config.json` file, that can get passed to esbuild directly: + +```console +esbuild $(esbuild-config) +``` + +It detects the presence of `esbuild.config.json` in the current directory, or the project root (using the presence of a `package.json` file). Any file can also get provided as a parameter: + +```console +esbuild $(esbuild-config ./my-conf.json) +``` + +## Install + +You have different options to install esbuild-config. + +### npm + +Install globally with npm using the following command: + +```console +npm install --global esbuild-config +``` + +You can also add it to your project: + +```console +npm install --save-dev esbuild-config +``` + +### Cargo + +Install it with [Cargo](https://github.com/rust-lang/cargo) using the following command: + +```console +cargo install esbuild-config +``` + +### Binaries + +You can download the precompiled binaries [from the release page](https://github.com/bpierre/esbuild-config/releases). + +### From source + +To clone the repository and build esbuild-config, run these commands ([after having installed Rust](https://www.rust-lang.org/tools/install)): + +```console +git clone git@github.com:bpierre/esbuild-config.git +cd esbuild-config +cargo build --release +``` + +The compiled binary is at `target/release/esbuild-config`. + +## Syntax + +esbuild-config doesn’t do any validation on the configuration values: it only converts JSON types into arguments that are compatible with the format esbuild uses for its arguments. This makes it independent from esbuild versions, assuming the format doesn’t change. + +The only exception to this is the `entry` field, which gets converted into a list of file names (when an array is provided) or a single file name (when a string is provided). + +This is how JSON types get converted: + +```json +{ + "entry": "./index.js", + "outfile": "./bundle.js", + "external": ["react", "react-dom"], + "loader": { ".js": "jsx", ".png": "base64" }, + "minify": true +} +``` + +Output: + +```console +--outfile=./bundle.js --minify --external:react --external:react-dom --loader:.js=jsx --loader:.png=base64 ./index.js +``` + +Notice how the entry, `./index.js`, has been moved to the end. esbuild-config also takes care of escaping the parameters as needed (e.g. by adding quotes). + +## Contribute + +```console +# Run the app +cargo run + +# Run the tests +cargo test + +# Generate the code coverage report +cargo tarpaulin -o Html +``` + +## Special thanks + +[esbuild](https://github.com/evanw/esbuild) and [its author](https://github.com/evanw) obviously, not only for esbuild itself but also for its approach to [install a platform-specific binary through npm](https://github.com/evanw/esbuild/blob/1336fbcf9bcca2f2708f5f575770f13a8440bde3/lib/install.ts), that esbuild-config is also using. + +## License + +[MIT](./LICENSE) diff --git a/npm/esbuild-config/bin/esbuild-config b/npm/esbuild-config/bin/esbuild-config new file mode 100644 index 0000000..aca4a29 --- /dev/null +++ b/npm/esbuild-config/bin/esbuild-config @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +throw new Error('esbuild-config: Failed to install correctly') diff --git a/npm/esbuild-config/install.js b/npm/esbuild-config/install.js new file mode 100644 index 0000000..3dd3b98 --- /dev/null +++ b/npm/esbuild-config/install.js @@ -0,0 +1,246 @@ +// This is a slightly modified version of the esbuild install script: +// https://github.com/evanw/esbuild/blob/1336fbcf9bcca2f2708f5f575770f13a8440bde3/lib/install.ts + +const fs = require('fs') +const os = require('os') +const path = require('path') +const zlib = require('zlib') +const https = require('https') +const child_process = require('child_process') + +const version = require('./package.json').version +const binPath = path.join(__dirname, 'bin', 'esbuild-config') +const stampPath = path.join(__dirname, 'stamp.txt') + +async function installBinaryFromPackage(name, fromPath, toPath) { + // It turns out that some package managers (e.g. yarn) sometimes re-run the + // postinstall script for this package after we have already been installed. + // That means this script must be idempotent. Let's skip the install if it's + // already happened. + if (fs.existsSync(stampPath)) { + return + } + + // Try to install from the cache if possible + const cachePath = getCachePath(name) + try { + // Copy from the cache + fs.copyFileSync(cachePath, toPath) + fs.chmodSync(toPath, 0o755) + + // Mark the cache entry as used for LRU + const now = new Date() + fs.utimesSync(cachePath, now, now) + + // Mark the operation as successful so this script is idempotent + fs.writeFileSync(stampPath, '') + return + } catch {} + + // Next, try to install using npm. This should handle various tricky cases + // such as environments where requests to npmjs.org will hang (in which case + // there is probably a proxy and/or a custom registry configured instead). + let buffer + let didFail = false + try { + buffer = installUsingNPM(name, fromPath) + } catch (err) { + didFail = true + console.error(`Trying to install "${name}" using npm`) + console.error( + `Failed to install "${name}" using npm: ${(err && err.message) || err}` + ) + } + + // If that fails, the user could have npm configured incorrectly or could not + // have npm installed. Try downloading directly from npm as a last resort. + if (!buffer) { + const url = `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz` + console.error(`Trying to download ${JSON.stringify(url)}`) + try { + buffer = extractFileFromTarGzip(await fetch(url), fromPath) + } catch (err) { + console.error( + `Failed to download ${JSON.stringify(url)}: ${ + (err && err.message) || err + }` + ) + } + } + + // Give up if none of that worked + if (!buffer) { + console.error(`Install unsuccessful`) + process.exit(1) + } + + // Write out the binary executable that was extracted from the package + fs.writeFileSync(toPath, buffer, { mode: 0o755 }) + + // Mark the operation as successful so this script is idempotent + fs.writeFileSync(stampPath, '') + + // Also try to cache the file to speed up future installs + try { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }) + fs.copyFileSync(toPath, cachePath) + cleanCacheLRU(cachePath) + } catch {} + + if (didFail) console.error(`Install successful`) +} + +function getCachePath(name) { + const home = os.homedir() + const common = ['esbuild-config', 'bin', `${name}@${version}`] + if (process.platform === 'darwin') + return path.join(home, 'Library', 'Caches', ...common) + if (process.platform === 'win32') + return path.join(home, 'AppData', 'Local', 'Cache', ...common) + return path.join(home, '.cache', ...common) +} + +function cleanCacheLRU(fileToKeep) { + // Gather all entries in the cache + const dir = path.dirname(fileToKeep) + const entries = [] + for (const entry of fs.readdirSync(dir)) { + const entryPath = path.join(dir, entry) + try { + const stats = fs.statSync(entryPath) + entries.push({ path: entryPath, mtime: stats.mtime }) + } catch {} + } + + // Only keep the most recent entries + entries.sort((a, b) => +b.mtime - +a.mtime) + for (const entry of entries.slice(5)) { + try { + fs.unlinkSync(entry.path) + } catch {} + } +} + +function fetch(url) { + return new Promise((resolve, reject) => { + https + .get(url, (res) => { + if ( + (res.statusCode === 301 || res.statusCode === 302) && + res.headers.location + ) + return fetch(res.headers.location).then(resolve, reject) + if (res.statusCode !== 200) + return reject(new Error(`Server responded with ${res.statusCode}`)) + let chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('end', () => resolve(Buffer.concat(chunks))) + }) + .on('error', reject) + }) +} + +function extractFileFromTarGzip(buffer, file) { + try { + buffer = zlib.unzipSync(buffer) + } catch (err) { + throw new Error( + `Invalid gzip data in archive: ${(err && err.message) || err}` + ) + } + let str = (i, n) => + String.fromCharCode(...buffer.subarray(i, i + n)).replace(/\0.*$/, '') + let offset = 0 + file = `package/${file}` + while (offset < buffer.length) { + let name = str(offset, 100) + let size = parseInt(str(offset + 124, 12), 8) + offset += 512 + if (!isNaN(size)) { + if (name === file) return buffer.subarray(offset, offset + size) + offset += (size + 511) & ~511 + } + } + throw new Error(`Could not find ${JSON.stringify(file)} in archive`) +} + +function installUsingNPM(name, file) { + const installDir = path.join(__dirname, '.install') + fs.mkdirSync(installDir) + fs.writeFileSync(path.join(installDir, 'package.json'), '{}') + + // Erase "npm_config_global" so that "npm install --global esbuild-config" + // works. Otherwise this nested "npm install" will also be global, and the + // install will deadlock waiting for the global installation lock. + const env = { ...process.env, npm_config_global: undefined } + + child_process.execSync( + `npm install --loglevel=error --prefer-offline --no-audit --progress=false ${name}@${version}`, + { cwd: installDir, stdio: 'pipe', env } + ) + const buffer = fs.readFileSync( + path.join(installDir, 'node_modules', name, file) + ) + removeRecursive(installDir) + return buffer +} + +function removeRecursive(dir) { + for (const entry of fs.readdirSync(dir)) { + const entryPath = path.join(dir, entry) + let stats + try { + stats = fs.lstatSync(entryPath) + } catch (e) { + continue // Guard against https://github.com/nodejs/node/issues/4760 + } + if (stats.isDirectory()) removeRecursive(entryPath) + else fs.unlinkSync(entryPath) + } + fs.rmdirSync(dir) +} + +function installOnUnix(name) { + installBinaryFromPackage(name, 'bin/esbuild-config', binPath).catch((e) => + setImmediate(() => { + throw e + }) + ) +} + +function installOnWindows(name) { + fs.writeFileSync( + binPath, + `#!/usr/bin/env node +const path = require('path'); +const esbuild_config_exe = path.join(__dirname, '..', 'esbuild-config.exe'); +const child_process = require('child_process'); +child_process.spawnSync(esbuild_config_exe, process.argv.slice(2), { stdio: 'inherit' }); +` + ) + const exePath = path.join(__dirname, 'esbuild-config.exe') + installBinaryFromPackage(name, 'esbuild-config.exe', exePath).catch((e) => + setImmediate(() => { + throw e + }) + ) +} + +const key = `${process.platform} ${os.arch()} ${os.endianness()}` +const knownWindowsPackages = { + 'win32 x64 LE': 'esbuild-config-windows-64', +} +const knownUnixlikePackages = { + 'darwin x64 LE': 'esbuild-config-darwin-64', + 'linux x64 LE': 'esbuild-config-linux-64', +} + +// Pick a package to install +if (key in knownWindowsPackages) { + installOnWindows(knownWindowsPackages[key]) +} else if (key in knownUnixlikePackages) { + installOnUnix(knownUnixlikePackages[key]) +} else { + console.error(`Unsupported platform: ${key}`) + process.exit(1) +} diff --git a/npm/esbuild-config/package.json b/npm/esbuild-config/package.json new file mode 100644 index 0000000..9407261 --- /dev/null +++ b/npm/esbuild-config/package.json @@ -0,0 +1,10 @@ +{ + "name": "esbuild-config", + "description": "Config files for esbuild.", + "version": "0.1.0", + "repository": "https://github.com/bpierre/esbuild-config", + "author": "Pierre Bertet ", + "license": "MIT", + "bin": "bin/esbuild-config", + "scripts": { "postinstall": "node install.js" } +} diff --git a/src/lib/args.rs b/src/lib/args.rs new file mode 100644 index 0000000..3518161 --- /dev/null +++ b/src/lib/args.rs @@ -0,0 +1,289 @@ +use super::errors; +use json; +use snailquote; + +// Get the esbuild arguments from the data structure +pub fn args_from_json_value(json: json::JsonValue) -> Result { + let mut args: Vec = vec![]; + let mut entries: Vec = vec![]; + let mut options: Vec = vec![]; + + if !json.is_object() { + return Err(errors::ConfigParseError::JsonError(json::Error::WrongType( + String::from("The JSON main type must be an object."), + ))); + } + + for (key, value) in json.entries() { + if key == "entry" { + match entries_from_config(value) { + Some(result) => entries = result, + None => (), + } + continue; + } + match option_from_config(key, value) { + Some(param) => options.push(param), + None => (), + } + } + + args.append(&mut options); + args.append(&mut entries); + Ok(args.join(" ")) +} + +// Get the entries in the config file +pub fn entries_from_config(value: &json::JsonValue) -> Option> { + if value.is_string() { + return Some(vec![quote_value(value.as_str().unwrap())]); + } + if value.is_array() { + let entries: Vec = value + .members() + .filter_map(|entry| match entry.as_str() { + Some(value) => Some(quote_value(value)), + None => None, + }) + .collect(); + return match entries.is_empty() { + false => Some(entries), + true => None, + }; + } + None +} + +// Parse a single config value from esbuild.config.json +pub fn option_from_config(key: &str, value: &json::JsonValue) -> Option { + if value.is_boolean() { + return option_from_bool(key, value); + } + if value.is_string() { + return option_from_string(key, value); + } + if value.is_array() { + return option_from_array(key, value); + } + if value.is_object() { + return option_from_object(key, value); + } + None +} + +// Parse a bool config value +pub fn option_from_bool(key: &str, value: &json::JsonValue) -> Option { + match value.as_bool() { + Some(value) => { + if value { + Some(["--", key].concat()) + } else { + None + } + } + None => None, + } +} + +// Parse a string config value +pub fn option_from_string(key: &str, value: &json::JsonValue) -> Option { + match value.as_str() { + Some(value) => Some(["--", key, "=", "e_value(value)].concat()), + None => None, + } +} + +// Parse an object config value +pub fn option_from_object(key: &str, value: &json::JsonValue) -> Option { + let mut options: Vec = vec![]; + + for (k, v) in value.entries() { + match v.as_str() { + Some(value) => options.push(["--", key, ":", k, "=", "e_value(value)].concat()), + None => (), + } + } + + if options.len() > 0 { + Some(options.join(" ")) + } else { + None + } +} + +// Parse an array config value +pub fn option_from_array(key: &str, value: &json::JsonValue) -> Option { + let mut options: Vec = vec![]; + + for param_value in value.members() { + match param_value.as_str() { + Some(value) => options.push(["--", key, ":", "e_value(value)].concat()), + None => (), + } + } + + if options.len() > 0 { + Some(options.join(" ")) + } else { + None + } +} + +// Quote a value if it contains a space +pub fn quote_value(value: &str) -> String { + let value = snailquote::escape(&value).to_string(); + if value == "" { + String::from("''") + } else { + value + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_args_from_json_value() { + let value = json::parse( + r#"{ + "entry": "index.js", + "a": true, + "b": "abc", + "c": ["def", "ghi"], + "d": { "e": "jkl", "f": "mno" } + }"#, + ) + .unwrap(); + assert_eq!( + args_from_json_value(value).unwrap(), + "--a --b=abc --c:def --c:ghi --d:e=jkl --d:f=mno index.js" + ); + } + + #[test] + fn test_entries_from_config() { + let value = json::parse("\"path/to/some/file.js\"").unwrap(); + let entries = entries_from_config(&value).unwrap(); + assert_eq!(entries[0], "path/to/some/file.js"); + + let value = json::parse("[\"path/to/some/file.js\"]").unwrap(); + let entries = entries_from_config(&value).unwrap(); + assert_eq!(entries[0], "path/to/some/file.js"); + + let value = json::parse( + r#"[ + "path/to/some/file.js", + "./path with spaces.js", + true + ]"#, + ) + .unwrap(); + let entries = entries_from_config(&value).unwrap(); + assert_eq!(entries[0], "path/to/some/file.js"); + assert_eq!(entries[1], "'./path with spaces.js'"); + assert!(entries.get(2).is_none()); + + let value = json::parse("[]").unwrap(); + assert!(entries_from_config(&value).is_none()); + + let value = json::parse("true").unwrap(); + assert!(entries_from_config(&value).is_none()); + } + + #[test] + fn test_option_from_config() { + let value = json::parse("true").unwrap(); + assert!(!option_from_config("name", &value).is_none()); + + let value = json::parse("false").unwrap(); + assert!(option_from_config("name", &value).is_none()); + + let value = json::parse("\"a\"").unwrap(); + assert!(!option_from_config("name", &value).is_none()); + + let value = json::parse("[\"a\"]").unwrap(); + assert!(!option_from_config("name", &value).is_none()); + + let value = json::parse("{\"a\": \"abc\"}").unwrap(); + assert!(!option_from_config("name", &value).is_none()); + + let value = json::parse("null").unwrap(); + assert!(option_from_config("name", &value).is_none()); + } + + #[test] + fn test_option_from_bool() { + let value = json::parse("true").unwrap(); + assert_eq!(option_from_bool("name", &value).unwrap(), "--name"); + + let value = json::parse("false").unwrap(); + assert!(option_from_bool("name", &value).is_none()); + + // Wrong types get ignored + let value = json::parse("1").unwrap(); + assert!(option_from_bool("name", &value).is_none()); + } + + #[test] + fn test_option_from_string() { + let value = json::parse("\"a\"").unwrap(); + assert_eq!(option_from_string("name", &value).unwrap(), "--name=a"); + + // Wrong types get ignored + let value = json::parse("1").unwrap(); + assert!(option_from_string("name", &value).is_none()); + + // Empty value + let value = json::parse("\"\"").unwrap(); + assert_eq!(option_from_string("name", &value).unwrap(), "--name=''"); + } + + #[test] + fn test_option_from_object() { + let value = json::parse("{ \"a\": \"abc\", \"b\": \"def\" }").unwrap(); + assert_eq!( + option_from_object("name", &value).unwrap(), + "--name:a=abc --name:b=def" + ); + + let value = json::parse("{}").unwrap(); + assert!(option_from_object("name", &value).is_none()); + + // Wrong types in the object get ignored + let value = json::parse("{ \"a\": \"abc\", \"b\": 123 }").unwrap(); + assert_eq!(option_from_object("name", &value).unwrap(), "--name:a=abc"); + } + + #[test] + fn test_option_from_array() { + let value = json::parse("[\"a\", \"b\", \"c\"]").unwrap(); + assert_eq!( + option_from_array("name", &value).unwrap(), + "--name:a --name:b --name:c" + ); + + // Empty arrays + let value = json::parse("[]").unwrap(); + assert!(option_from_array("name", &value).is_none()); + + // Wrong types in the array get ignored + let value = json::parse("[\"a\", 1, \"b\"]").unwrap(); + assert_eq!( + option_from_array("name", &value).unwrap(), + "--name:a --name:b" + ); + } + + #[test] + fn test_quote_value() { + assert_eq!(quote_value("value"), "value"); + + // Having a space should return the value with quotes + assert_eq!(quote_value("with space"), "'with space'"); + + // Having a quote should return the value with quotes + assert_eq!(quote_value("with\"quote"), "'with\"quote'"); + assert_eq!(quote_value("with'quote"), "\"with'quote\""); + } +} diff --git a/src/lib/errors.rs b/src/lib/errors.rs new file mode 100644 index 0000000..53a4e04 --- /dev/null +++ b/src/lib/errors.rs @@ -0,0 +1,43 @@ +use std::{error, fmt, io}; + +#[derive(Debug)] +pub enum EsbuildConfigError { + ConfigParseError, + ConfigPathError, + Io(io::Error), +} +impl fmt::Display for EsbuildConfigError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "An unknown error happened.") + } +} + +#[derive(Debug)] +pub enum ConfigPathError { + Io(io::Error), +} +impl fmt::Display for ConfigPathError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Couldn’t find or open the esbuild configuration file.") + } +} + +#[derive(Debug)] +pub enum ConfigParseError { + InvalidConfigError, + JsonError(json::Error), +} +impl fmt::Display for ConfigParseError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Invalid esbuild configuration format.") + } +} + +#[derive(Debug)] +pub struct InvalidConfigError; +impl error::Error for InvalidConfigError {} +impl fmt::Display for InvalidConfigError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Invalid esbuild configuration format.") + } +} diff --git a/src/lib/mod.rs b/src/lib/mod.rs new file mode 100644 index 0000000..1c6faf9 --- /dev/null +++ b/src/lib/mod.rs @@ -0,0 +1,67 @@ +mod args; +pub mod errors; +mod paths; + +use json; +use std::{fs, io, path}; + +pub fn esbuild_conf(args: Vec) -> Result { + let config_path = + paths::config_path(args.get(1)).map_err(|_| errors::EsbuildConfigError::ConfigPathError)?; + let config_content = esbuild_config_content(config_path) + .map_err(|_| errors::EsbuildConfigError::ConfigParseError)?; + parse_esbuild_config(config_content).map_err(|_| errors::EsbuildConfigError::ConfigParseError) +} + +pub fn esbuild_config_content(path: path::PathBuf) -> Result { + match fs::read_to_string(&path) { + Ok(content) => Ok(content), + Err(_) => Err(errors::EsbuildConfigError::Io(io::Error::new( + io::ErrorKind::Other, + [ + "Couldn’t read ", + path.into_os_string() + .into_string() + .expect("The provided path couldn’t get read.") + .as_str(), + ] + .concat(), + ))), + } +} + +// Parse the entire esbuild.config.json +pub fn parse_esbuild_config(content: String) -> Result { + match json::parse(&content) { + Ok(value) => args::args_from_json_value(value) + .map_err(|_| errors::ConfigParseError::InvalidConfigError), + Err(_) => return Err(errors::ConfigParseError::InvalidConfigError), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_esbuild_config() { + let value = r#" + { + "entry": "index.js", + "a": true, + "b": "abc", + "c": ["def", "ghi"], + "d": { "e": "jkl", "f": "mno" } + } + "#; + assert_eq!( + parse_esbuild_config(value.to_string()).unwrap(), + "--a --b=abc --c:def --c:ghi --d:e=jkl --d:f=mno index.js" + ); + + assert!(match parse_esbuild_config("true".to_string()) { + Ok(_) => false, + Err(_) => true, + }); + } +} diff --git a/src/lib/paths.rs b/src/lib/paths.rs new file mode 100644 index 0000000..cff7d21 --- /dev/null +++ b/src/lib/paths.rs @@ -0,0 +1,79 @@ +use super::errors; +use std::{env, io, path::PathBuf}; + +const CONFIG_FILE_NAME: &str = "esbuild.config.json"; + +// Return the path of the config file, based on the passed string or by detecting it. +pub fn config_path(path: Option<&String>) -> Result { + match path { + Some(path) => { + let esbuild_json = PathBuf::from(path); + if esbuild_json.exists() { + Ok(esbuild_json) + } else { + Err(errors::ConfigPathError::Io(io::Error::new( + io::ErrorKind::NotFound, + "The provided file doesn’t seem to exist.", + ))) + } + } + None => Ok(detect_config_path()?), + } +} + +// Get the first ancestor directory containing a package.json +pub fn pkg_root_path() -> Result { + let cwd = env::current_dir().map_err(errors::ConfigPathError::Io)?; + + for dir in cwd.ancestors() { + if dir.join("package.json").exists() { + return Ok(dir.to_path_buf()); + } + } + + Err(errors::ConfigPathError::Io(io::Error::new( + io::ErrorKind::NotFound, + "No package.json found.", + ))) +} + +// Detect the path of the config file from the current directory. +pub fn detect_config_path() -> Result { + let cwd = env::current_dir().map_err(errors::ConfigPathError::Io)?; + let local_esbuild_json = cwd.join(CONFIG_FILE_NAME); + + // Local esbuild.config.json + if local_esbuild_json.exists() { + return Ok(local_esbuild_json); + } + + // Project root esbuild.config.json + let local_esbuild_json = match pkg_root_path() { + Ok(pkg_root) => pkg_root.join(CONFIG_FILE_NAME), + Err(_) => { + return Err(errors::ConfigPathError::Io(io::Error::new( + io::ErrorKind::NotFound, + [ + "No ", + CONFIG_FILE_NAME, + " found in the current directory, and no project root found.", + ] + .concat(), + ))) + } + }; + + if local_esbuild_json.exists() { + Ok(local_esbuild_json) + } else { + Err(errors::ConfigPathError::Io(io::Error::new( + io::ErrorKind::NotFound, + [ + "No ", + CONFIG_FILE_NAME, + " found in the current directory nor in the project root.", + ] + .concat(), + ))) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f56a196 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,21 @@ +mod lib; + +use lib::errors::EsbuildConfigError; +use std::env; + +fn main() { + match lib::esbuild_conf(env::args().collect()) { + Ok(value) => println!("{}", value), + Err(err) => match err { + EsbuildConfigError::ConfigParseError => { + eprintln!("The configuration file is invalid."); + } + EsbuildConfigError::ConfigPathError => { + eprintln!("Couldn’t find or open the esbuild configuration file."); + } + _ => { + eprintln!("Error: {}", err); + } + }, + } +}