Skip to content

Instantly share code, notes, and snippets.

@nksaraf
Last active December 29, 2020 13:47
Show Gist options
  • Save nksaraf/4dc78286f8de3eb3d9bdde858c0e4820 to your computer and use it in GitHub Desktop.
Save nksaraf/4dc78286f8de3eb3d9bdde858c0e4820 to your computer and use it in GitHub Desktop.
distilt.js
#!/usr/bin/env node
const { existsSync, promises: fs } = require('fs')
const path = require('path')
if (require.main === module) {
main().catch((error) => {
console.error(error)
process.exit(1)
})
} else {
module.exports = main
}
function findPaths() {
const root = require('pkg-dir').sync() || require('project-root-directory')
const dist = path.resolve(root, 'dist')
const manifest = path.resolve(root, 'package.json')
const tsconfig = path.resolve(root, 'tsconfig.json')
return { root, dist, manifest, tsconfig: existsSync(tsconfig) && tsconfig }
}
async function main() {
const paths = findPaths()
const manifest = require(paths.manifest)
const resolveExtensions = ['.tsx', '.ts', '.jsx', '.mjs', '.js', '.cjs', '.css', '.json']
const bundleName = manifest.name.split('/').pop()
const globalName = manifest.globalName || manifest.name.replace('@', '').replace(/\//g, '.')
// TODO read from manifest.engines
const targets = {
node: 'node10.13',
browser: ['chrome79', 'firefox78', 'safari13.1', 'edge79'],
}
// Bundled dependencies are included in the the output bundle
const bundledDependencies = [
...(manifest.bundledDependencies || []),
...(manifest.bundleDependencies || []),
]
const external = Object.keys({
...manifest.dependencies,
...manifest.peerDependencies,
...manifest.devDependencies,
...manifest.optionalDependencies,
})
// external.forEach((id) => {
// external.push(`${id}/*`)
// })
console.log(`Bundling ${manifest.name}@${manifest.version}`)
await prepare()
const service = await require('esbuild').startService()
const typesDirectoryPromise = paths.tsconfig && generateTypescriptDeclarations()
try {
await Promise.all([
copyFiles(),
manifest.exports
? generateMultiBundles()
: generateBundles({
manifest,
bundleName,
globalName,
inputFile: path.resolve(paths.root, manifest.source || manifest.main),
}),
])
} finally {
service.stop()
const typesDirectory = await typesDirectoryPromise
typesDirectory && fs.rmdir(typesDirectory, { force: true, recursive: true })
}
if (manifest['size-limit']) {
await require('size-limit/run')(process)
}
async function prepare() {
// Cleanup old build
await fs.rmdir(paths.dist, { recursive: true, force: true })
// Prepare next one
await fs.mkdir(paths.dist, { recursive: true })
}
async function copyFiles() {
console.time('Copied files to ' + path.relative(process.cwd(), paths.dist))
const globby = require('globby')
/**
* Copy readme, license, changelog to dist
*/
const files = await globby(
[
...(manifest.files || []),
'{changes,changelog,history,license,licence,notice,readme}?(.md|.txt)',
],
{
cwd: paths.root,
absolute: false,
gitignore: true,
caseSensitiveMatch: false,
dot: true,
},
)
await Promise.all(
files.map((file) => fs.copyFile(path.resolve(paths.root, file), path.join(paths.dist, file))),
)
console.timeEnd('Copied files to ' + path.relative(process.cwd(), paths.dist))
}
async function generateMultiBundles() {
await Promise.all(
Object.entries(manifest.exports)
.filter(([entryPoint, inputFile]) => /\.([mc]js|[jt]sx?)$/.test(inputFile))
.map(async ([entryPoint, inputFile], index, entryPoints) => {
if (entryPoint === '.') {
const exports = {}
await Promise.all(
entryPoints.map(async ([subEntryPoint, subInputFile]) => {
const bundleName = path.relative(
'.',
path.join(subEntryPoint, path.basename(subEntryPoint)),
)
const outputs = await getOutputs({
inputFile: subInputFile,
manifest,
bundleName,
globalName: globalName + subEntryPoint.slice(1).replace(/\//g, '.'),
})
exports[subEntryPoint] = getExports({ outputs, bundleName })
}),
)
return generateBundles({
manifest: {
...manifest,
exports: {
...manifest.exports,
...exports,
},
},
bundleName,
globalName,
inputFile,
})
}
return generateBundles({
manifest: {
browser: manifest.browser,
exports: false,
},
manifestFile: path.relative('.', path.join(entryPoint, 'package.json')),
bundleName: path.relative('.', path.join(entryPoint, path.basename(entryPoint))),
globalName: globalName + entryPoint.slice(1).replace(/\//g, '.'),
inputFile,
plugins: [
{
name: 'external:parent',
setup(build) {
// Match all parent imports and mark them as external
// match: '..', '../', '../..', '../index'
// no match: '../helper' => this will be included in all bundles referencing it
build.onResolve(
{ filter: /^\.\.(\/\.\.)*(\/|\/index(?:\.(?:[mc]js|[jt]sx?))?)?$/ },
({ path }) => ({
path: path.replace(/\/index(?:\.(?:[mc]js|[jt]sx?))?$/, ''),
external: true,
}),
)
},
},
],
})
}),
)
}
async function getOutputs({ inputFile, manifest, bundleName, globalName }) {
const outputs = {}
if (manifest.browser !== true) {
Object.assign(outputs, {
// Used by nodejs
node: {
outfile: `./${bundleName}.cjs`,
platform: 'node',
target: targets.node,
format: 'cjs',
define: {
'process.browser': 'false',
},
},
})
}
if (
manifest.browser !== false &&
// Do not create browser bundle node modules
!/^\/\* eslint-env node\b/.test(
await fs.readFile(path.resolve(paths.root, inputFile), 'utf-8'),
)
) {
Object.assign(outputs, {
browser: {
outfile: `./${bundleName}.js`,
platform: 'browser',
target: targets.browser,
format: 'esm',
minify: true,
},
// Can be used from a normal script tag without module system.
script: {
outfile: `./${bundleName}.umd.js`,
platform: 'browser',
target: 'es2015',
format: 'esm',
minify: true,
define: {
'process.env.NODE_ENV': '"production"',
'process.platform': '"browser"',
'process.browser': 'true',
},
rollup: {
external: () => true,
output: {
format: 'umd',
name: camelize(globalName),
globals: (id) => {
return { jquery: '$', lodash: '_' }[id] || camelize(id, globalName)
},
},
},
},
})
}
return outputs
}
function getExports({ outputs, bundleName, manifestFile = 'package.json' }) {
return {
// Only add if we have browser and node bundles
node: outputs.browser && outputs.node && relative(manifestFile, outputs.node.outfile),
script: outputs.script && relative(manifestFile, outputs.script.outfile),
types: paths.tsconfig ? relative(manifestFile, `./${bundleName}.d.ts`) : undefined,
default: relative(
manifestFile,
outputs.browser ? outputs.browser.outfile : outputs.node.outfile,
),
}
}
async function generateBundles({
manifest,
manifestFile = 'package.json',
bundleName,
globalName,
inputFile,
plugins,
}) {
const outputs = await getOutputs({ inputFile, manifest, bundleName, globalName })
const manifestPath = path.resolve(paths.dist, manifestFile)
const exports = getExports({ outputs, bundleName, manifestFile })
const publishManifest = {
...manifest,
// Define package loading
// https://gist.github.com/sokra/e032a0f17c1721c71cfced6f14516c62
exports:
manifest.exports === false
? undefined
: {
...manifest.exports,
'.': exports,
// Allow access to package.json
'./package.json': './package.json',
},
// Used by node
main: exports.node || (outputs.node && exports.default),
// Used by bundlers like rollup and CDNs
module: outputs.browser && exports.default,
unpkg: exports.script,
types: exports.types,
// Allow publish
private: undefined,
// Include all files in the dist folder
files: undefined,
// Default to cjs
type: undefined,
// These are not needed any more
source: undefined,
scripts: undefined,
devDependencies: undefined,
optionalDependencies: undefined,
// Reset bundledDependencies as esbuild includes those into the bundle
bundledDependencies: undefined,
bundleDependencies: undefined,
// Reset config sections
eslintConfig: undefined,
prettier: undefined,
np: undefined,
'size-limit': undefined,
}
// Resets comments
Object.keys(publishManifest).forEach(key => {
if (key.startsWith('//')) {
publishManifest[key] = undefined
}
})
await fs.mkdir(path.dirname(manifestPath), { recursive: true })
await fs.writeFile(manifestPath, JSON.stringify(publishManifest, null, 2))
await Promise.all([
exports.types &&
generateTypesBundle(inputFile, path.resolve(path.dirname(manifestPath), exports.types)),
...Object.entries(outputs)
.filter(([, output]) => output)
.map(async ([, { rollup, ...output }]) => {
const outfile = path.resolve(paths.dist, output.outfile)
const logKey = `Bundled ${path.relative(process.cwd(), inputFile)} -> ${path.relative(
process.cwd(),
outfile,
)} (${(rollup && rollup.output.format) || output.format} - ${output.target})`
console.time(logKey)
await service.build({
...output,
outfile,
entryPoints: [inputFile],
charset: 'utf8',
resolveExtensions,
bundle: true,
external: rollup
? external.filter((dependency) => !bundledDependencies.includes(dependency))
: external,
mainFields: [
'esnext',
output.platform === 'browser' && 'browser:module',
output.platform === 'browser' && 'browser',
'es2015',
'module',
'main',
].filter(Boolean),
sourcemap: true,
tsconfig: paths.tsconfig,
plugins,
})
if (rollup) {
const bundle = await require('rollup').rollup({
...rollup,
input: outfile,
})
await bundle.write({
...rollup.output,
file: outfile,
sourcemap: true,
preferConst: true,
exports: 'auto',
compact: true,
})
}
console.timeEnd(logKey)
}),
])
}
async function generateTypesBundle(inputFile, dtsFile) {
const typesDirectory = await typesDirectoryPromise
const logKey = `Bundled ${path.relative(process.cwd(), inputFile)} -> ${path.relative(
process.cwd(),
dtsFile,
)}`
console.time(logKey)
// './src/shim/index.ts'
// => '.types/src/shim/index.ts'
// => '.types/shim/index.ts'
// => '.types/index.ts'
const parts = inputFile.replace(/\.(ts|tsx)$/, '.d.ts').split('/')
let sourceDtsFile = path.resolve(typesDirectory, parts.join('/'))
for (let offset = 0; offset < parts.length && !existsSync(sourceDtsFile); offset++) {
sourceDtsFile = path.resolve(typesDirectory, parts.slice(offset).join('/'))
}
const bundle = await require('rollup').rollup({
input: path.relative(process.cwd(), sourceDtsFile),
plugins: [(0, require('rollup-plugin-dts').default)()],
})
await bundle.write({
format: 'esm',
file: dtsFile,
sourcemap: true,
preferConst: true,
exports: 'auto',
})
console.timeEnd(logKey)
}
async function generateTypescriptDeclarations() {
console.time('Generated typescript declarations')
const typesDirectory = path.resolve(paths.dist, '.types')
const tsconfig = path.resolve(path.dirname(paths.tsconfig), 'tsconfig.dist.json')
await fs.writeFile(
tsconfig,
JSON.stringify(
{
extends: './' + path.basename(paths.tsconfig),
exclude: [
'**/__mocks__/**',
'**/__fixtures__/**',
'**/__tests__/**',
'**/test/**',
'**/tests/**',
'**/*.test.ts',
'**/*.test.tsx',
'**/*.spec.ts',
'**/*.spec.tsx',
],
compilerOptions: {
target: 'ESNext',
module: manifest.browser === false ? 'CommonJS' : 'ESNext',
emitDeclarationOnly: true,
noEmit: false,
outDir: typesDirectory,
},
},
null,
2,
),
)
try {
// tsc --project tsconfig.dist.json
await require('execa')('tsc', ['--project', tsconfig], {
cwd: paths.root,
extendEnv: true,
stdout: 'inherit',
stderr: 'inherit',
})
} finally {
await fs.unlink(tsconfig)
}
console.timeEnd('Generated typescript declarations')
return typesDirectory
}
}
function relative(from, to) {
const p = path.relative(path.dirname(from), to)
return p[0] === '.' ? p : './' + p
}
function camelize(str, globalName) {
if (str.startsWith('.')) {
for (var i = globalName.split('.'), n = str.split('/'); '..' == n[0]; ) {
n.shift()
i.pop()
}
str = i.concat(n).join('.')
}
return str.replace(/\W/g, ' ').replace(/(?:^\w|[A-Z]|\b\w|\s+)/g, function (match, index) {
if (+match === 0) return '' // or if (/\s+/.test(match)) for white spaces
return index === 0 ? match.toLowerCase() : match.toUpperCase()
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment