From c9ce0b0bc5e1e38734c72393f15cd28516703e19 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 14 Dec 2021 13:50:21 +0000 Subject: [PATCH] feat: validate build outputs against `package.json` (#33) --- src/auto.ts | 61 ++---------------------------------------- src/build.ts | 3 ++- src/utils.ts | 43 ++++++++++++++++++++++++++++++ src/validate.ts | 29 +++++++++++++++++++- test/auto.test.ts | 62 +------------------------------------------ test/utils.test.ts | 30 +++++++++++++++++++++ test/validate.test.ts | 33 +++++++++++++++++++++++ 7 files changed, 139 insertions(+), 122 deletions(-) create mode 100644 test/utils.test.ts create mode 100644 test/validate.test.ts diff --git a/src/auto.ts b/src/auto.ts index 34a91fb1..d6f5d251 100644 --- a/src/auto.ts +++ b/src/auto.ts @@ -2,10 +2,9 @@ import { normalize, join } from 'pathe' import consola from 'consola' import chalk from 'chalk' import type { PackageJson } from 'pkg-types' -import { listRecursively } from './utils' +import { extractExportFilenames, listRecursively } from './utils' import { BuildEntry, definePreset, MkdistBuildEntry } from './types' -type OutputDescriptor = { file: string, type?: 'esm' | 'cjs' } type InferEntriesResult = { entries: BuildEntry[], cjs?: boolean, dts?: boolean } export const autoPreset = definePreset(() => { @@ -43,7 +42,7 @@ export const autoPreset = definePreset(() => { */ export function inferEntries (pkg: PackageJson, sourceFiles: string[]): InferEntriesResult { // Come up with a list of all output files & their formats - const outputs: OutputDescriptor[] = extractExportFilenames(pkg.exports) + const outputs = extractExportFilenames(pkg.exports) if (pkg.bin) { const binaries = typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin) @@ -117,63 +116,7 @@ export function inferEntries (pkg: PackageJson, sourceFiles: string[]): InferEnt return { entries, cjs, dts } } -export function inferExportType (condition: string, previousConditions: string[] = [], filename = ''): 'esm' | 'cjs' { - if (filename) { - if (filename.endsWith('.d.ts')) { - return 'esm' - } - if (filename.endsWith('.mjs')) { - return 'esm' - } - if (filename.endsWith('.cjs')) { - return 'cjs' - } - } - switch (condition) { - case 'import': - return 'esm' - case 'require': - return 'cjs' - default: { - if (!previousConditions.length) { - // TODO: Check against type:module for default - return 'esm' - } - const [newCondition, ...rest] = previousConditions - return inferExportType(newCondition, rest, filename) - } - } -} - -export function extractExportFilenames (exports: PackageJson['exports'], conditions: string[] = []): OutputDescriptor[] { - if (!exports) { return [] } - if (typeof exports === 'string') { - return [{ file: exports, type: 'esm' }] - } - return Object.entries(exports).flatMap( - ([condition, exports]) => typeof exports === 'string' - ? { file: exports, type: inferExportType(condition, conditions, exports) } - : extractExportFilenames(exports, [...conditions, condition]) - ) -} - export const getEntrypointPaths = (path: string) => { const segments = normalize(path).split('/') return segments.map((_, index) => segments.slice(index).join('/')).filter(Boolean) } - -export const getEntrypointFilenames = (path: string, supportedExtensions = ['.ts', '.mjs', '.cjs', '.js', '.json']) => { - if (path.startsWith('./')) { path = path.slice(2) } - - const filenames = getEntrypointPaths(path).flatMap((path) => { - const basefile = path.replace(/\.\w+$/, '') - return [ - basefile, - `${basefile}/index` - ] - }) - - filenames.push('index') - - return filenames.flatMap(name => supportedExtensions.map(ext => `${name}${ext}`)) -} diff --git a/src/build.ts b/src/build.ts index 911c94d6..d1a09634 100644 --- a/src/build.ts +++ b/src/build.ts @@ -9,7 +9,7 @@ import prettyBytes from 'pretty-bytes' import mkdirp from 'mkdirp' import { dumpObject, rmdir, tryRequire, resolvePreset } from './utils' import type { BuildContext, BuildConfig, BuildOptions } from './types' -import { validateDependencies } from './validate' +import { validatePackage, validateDependencies } from './validate' import { rollupBuild } from './builder/rollup' import { typesBuild } from './builder/untyped' import { mkdistBuild } from './builder/mkdist' @@ -161,6 +161,7 @@ export async function build (rootDir: string, stub: boolean, inputConfig: BuildC // Validate validateDependencies(ctx) + validatePackage(pkg, rootDir) // Call build:done await ctx.hooks.callHook('build:done', ctx) diff --git a/src/utils.ts b/src/utils.ts index c7cdd993..35e856e8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,6 +5,7 @@ import { dirname, resolve } from 'pathe' import mkdirp from 'mkdirp' import _rimraf from 'rimraf' import jiti from 'jiti' +import type { PackageJson } from 'pkg-types' import { autoPreset } from './auto' import type { BuildPreset, BuildConfig } from './types' @@ -89,3 +90,45 @@ export function resolvePreset (preset: string | BuildPreset, rootDir: string): B } return preset as BuildConfig } + +export function inferExportType (condition: string, previousConditions: string[] = [], filename = ''): 'esm' | 'cjs' { + if (filename) { + if (filename.endsWith('.d.ts')) { + return 'esm' + } + if (filename.endsWith('.mjs')) { + return 'esm' + } + if (filename.endsWith('.cjs')) { + return 'cjs' + } + } + switch (condition) { + case 'import': + return 'esm' + case 'require': + return 'cjs' + default: { + if (!previousConditions.length) { + // TODO: Check against type:module for default + return 'esm' + } + const [newCondition, ...rest] = previousConditions + return inferExportType(newCondition, rest, filename) + } + } +} + +export type OutputDescriptor = { file: string, type?: 'esm' | 'cjs' } + +export function extractExportFilenames (exports: PackageJson['exports'], conditions: string[] = []): OutputDescriptor[] { + if (!exports) { return [] } + if (typeof exports === 'string') { + return [{ file: exports, type: 'esm' }] + } + return Object.entries(exports).flatMap( + ([condition, exports]) => typeof exports === 'string' + ? { file: exports, type: inferExportType(condition, conditions, exports) } + : extractExportFilenames(exports, [...conditions, condition]) + ) +} diff --git a/src/validate.ts b/src/validate.ts index 5ea8455e..121e5ab7 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,7 +1,10 @@ +import { existsSync } from 'fs' import chalk from 'chalk' import consola from 'consola' +import { resolve } from 'pathe' +import { PackageJson } from 'pkg-types' +import { extractExportFilenames, getpkg } from './utils' import { BuildContext } from './types' -import { getpkg } from './utils' export function validateDependencies (ctx: BuildContext) { const usedDependencies = new Set() @@ -32,3 +35,27 @@ export function validateDependencies (ctx: BuildContext) { consola.warn('Potential implicit dependencies found:', Array.from(implicitDependnecies).map(id => chalk.cyan(id)).join(', ')) } } + +export function validatePackage (pkg: PackageJson, rootDir: string) { + if (!pkg) { return } + + const filenames = new Set([ + ...typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin || {}), + pkg.main, + pkg.module, + pkg.types, + pkg.typings, + ...extractExportFilenames(pkg.exports).map(i => i.file) + ].map(i => i && resolve(rootDir, i.replace(/\/[^/]*\*.*$/, '')))) + + const missingOutputs = [] + + for (const filename of filenames) { + if (filename && !filename.includes('*') && !existsSync(filename)) { + missingOutputs.push(filename.replace(rootDir + '/', '')) + } + } + if (missingOutputs.length) { + consola.warn(`Potential missing package.json files: ${missingOutputs.map(o => chalk.cyan(o)).join(', ')}`) + } +} diff --git a/test/auto.test.ts b/test/auto.test.ts index f769ad5a..a5ecff65 100644 --- a/test/auto.test.ts +++ b/test/auto.test.ts @@ -1,8 +1,7 @@ import { expect } from 'chai' import jiti from 'jiti' -const { inferEntries, inferExportType, extractExportFilenames, getEntrypointPaths, getEntrypointFilenames } = - jiti(import.meta.url)('../src/auto') +const { inferEntries, getEntrypointPaths } = jiti(import.meta.url)('../src/auto') as typeof import('../src/auto') describe('inferEntries', () => { it('recognises main and module outputs', () => { @@ -126,32 +125,6 @@ describe('inferEntries', () => { }) }) -describe('inferExportType', () => { - it('infers export type by condition', () => { - expect(inferExportType('import')).to.equal('esm') - expect(inferExportType('require')).to.equal('cjs') - expect(inferExportType('node')).to.equal('esm') - expect(inferExportType('some_unknown_condition')).to.equal('esm') - }) - it('infers export type based on previous conditions', () => { - expect(inferExportType('import', ['require'])).to.equal('esm') - expect(inferExportType('node', ['require'])).to.equal('cjs') - expect(inferExportType('node', ['import'])).to.equal('esm') - expect(inferExportType('node', ['unknown', 'require'])).to.equal('cjs') - }) -}) - -describe('extractExportFilenames', () => { - it('handles strings', () => { - expect(extractExportFilenames('test')).to.deep.equal([{ file: 'test', type: 'esm' }]) - }) - it('handles nested objects', () => { - expect(extractExportFilenames({ require: 'test' })).to.deep.equal([{ file: 'test', type: 'cjs' }]) - // @ts-ignore TODO: fix pkg-types - expect(extractExportFilenames({ require: { node: 'test', other: { import: 'this', require: 'that' } } })).to.deep.equal([{ file: 'test', type: 'cjs' }, { file: 'this', type: 'esm' }, { file: 'that', type: 'cjs' }]) - }) -}) - describe('getEntrypointPaths', () => { it('produces a list of possible paths', () => { expect(getEntrypointPaths('./dist/foo/bar.js')).to.deep.equal([ @@ -165,36 +138,3 @@ describe('getEntrypointPaths', () => { ]) }) }) - -describe('getEntrypointFilenames', () => { - it('produces a list of possible source files', () => { - expect(getEntrypointFilenames('./dist/foo/bar.js', ['.ts'])).to.deep.equal([ - 'dist/foo/bar.ts', - 'dist/foo/bar/index.ts', - 'foo/bar.ts', - 'foo/bar/index.ts', - 'bar.ts', - 'bar/index.ts', - 'index.ts' - ]) - }) - it('uses default filenames', () => { - expect(getEntrypointFilenames('bar.js')).to.deep.equal([ - 'bar.ts', - 'bar.mjs', - 'bar.cjs', - 'bar.js', - 'bar.json', - 'bar/index.ts', - 'bar/index.mjs', - 'bar/index.cjs', - 'bar/index.js', - 'bar/index.json', - 'index.ts', - 'index.mjs', - 'index.cjs', - 'index.js', - 'index.json' - ]) - }) -}) diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 00000000..5867a395 --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai' +import jiti from 'jiti' + +const { extractExportFilenames, inferExportType } = jiti(import.meta.url)('../src/utils') as typeof import('../src/utils') + +describe('inferExportType', () => { + it('infers export type by condition', () => { + expect(inferExportType('import')).to.equal('esm') + expect(inferExportType('require')).to.equal('cjs') + expect(inferExportType('node')).to.equal('esm') + expect(inferExportType('some_unknown_condition')).to.equal('esm') + }) + it('infers export type based on previous conditions', () => { + expect(inferExportType('import', ['require'])).to.equal('esm') + expect(inferExportType('node', ['require'])).to.equal('cjs') + expect(inferExportType('node', ['import'])).to.equal('esm') + expect(inferExportType('node', ['unknown', 'require'])).to.equal('cjs') + }) +}) + +describe('extractExportFilenames', () => { + it('handles strings', () => { + expect(extractExportFilenames('test')).to.deep.equal([{ file: 'test', type: 'esm' }]) + }) + it('handles nested objects', () => { + expect(extractExportFilenames({ require: 'test' })).to.deep.equal([{ file: 'test', type: 'cjs' }]) + // @ts-ignore TODO: fix pkg-types + expect(extractExportFilenames({ require: { node: 'test', other: { import: 'this', require: 'that' } } })).to.deep.equal([{ file: 'test', type: 'cjs' }, { file: 'this', type: 'esm' }, { file: 'that', type: 'cjs' }]) + }) +}) diff --git a/test/validate.test.ts b/test/validate.test.ts new file mode 100644 index 00000000..71d68f5c --- /dev/null +++ b/test/validate.test.ts @@ -0,0 +1,33 @@ +import { fileURLToPath } from 'url' +import jiti from 'jiti' +import { expect } from 'chai' +import consola from 'consola' +import { join } from 'pathe' + +const { validatePackage } = jiti(import.meta.url)('../src/validate') as typeof import('../src/validate') + +describe('validatePackage', () => { + it('detects missing files', () => { + const logs: string[] = [] + consola.mock(type => type === 'warn' ? (str: string) => logs.push(str) : () => {}) + + validatePackage({ + main: './dist/test', + bin: { + './cli': './dist/cli' + }, + module: 'dist/mod', + exports: { + './runtime/*': './runtime/*.mjs', + '.': { node: './src/index.ts' } + } + }, join(fileURLToPath(import.meta.url), '../fixture')) + + expect(logs[0]).to.include('Potential missing') + expect(logs[0]).not.to.include('src/index.ts') + + for (const file of ['dist/test', 'dist/cli', 'dist/mod', 'runtime']) { + expect(logs[0]).to.include(file) + } + }) +})