Comparing version 0.1.5 to 0.1.6
190
lib/cli.js
#!/usr/bin/env node | ||
import fs from 'node:fs/promises' | ||
import fsSync from 'node:fs' | ||
import path from 'node:path' | ||
@@ -10,9 +11,24 @@ import { createRequire } from 'node:module' | ||
import { printMessage } from '../src/message.js' | ||
import { createPromiseQueue } from '../src/utils.js' | ||
const version = createRequire(import.meta.url)('../package.json').version | ||
const cli = sade('publint', false).version(version) | ||
sade('publint [dir]', true) | ||
.version(version) | ||
cli | ||
.command('run [dir]', 'Lint a directory (defaults to current directory)', { | ||
default: true | ||
}) | ||
.action(async (dir) => { | ||
const pkgDir = dir ? path.resolve(dir) : process.cwd() | ||
const { logs, success } = await lintDir(pkgDir) | ||
if (!success) process.exitCode = 1 | ||
logs.forEach((l) => console.log(l)) | ||
}) | ||
cli | ||
.command('deps [dir]', 'Lint dependencies declared in package.json') | ||
.option('-P, --prod', 'Only check dependencies') | ||
.option('-D, --dev', 'Only check devDependencies') | ||
.action(async (dir, opts) => { | ||
const pkgDir = dir ? path.resolve(dir) : process.cwd() | ||
const rootPkgContent = await fs | ||
@@ -23,38 +39,152 @@ .readFile(path.join(pkgDir, 'package.json'), 'utf8') | ||
}) | ||
if (!rootPkgContent) return | ||
if (!rootPkgContent) { | ||
process.exitCode = 1 | ||
return | ||
} | ||
const rootPkg = JSON.parse(rootPkgContent) | ||
const messages = await publint({ pkgDir }) | ||
/** @type {string[]} */ | ||
const deps = [] | ||
if (!opts.dev) deps.push(...Object.keys(rootPkg.dependencies || {})) | ||
if (!opts.prod) deps.push(...Object.keys(rootPkg.devDependencies || {})) | ||
console.log(`${c.bold(rootPkg.name)} lint results:`) | ||
if (deps.length === 0) { | ||
console.log(c.yellow('No dependencies found')) | ||
return | ||
} | ||
if (messages.length) { | ||
const suggestions = messages.filter((v) => v.type === 'suggestion') | ||
if (suggestions.length) { | ||
console.log(c.bold(c.blue('Suggestions:'))) | ||
suggestions.forEach((m, i) => | ||
console.log(c.dim(`${i + 1}. `) + printMessage(m, rootPkg)) | ||
) | ||
const pq = createPromiseQueue() | ||
let waitingDepIndex = 0 | ||
const waitingDepIndexListeners = [] | ||
const listenWaitingDepIndex = (cb) => { | ||
waitingDepIndexListeners.push(cb) | ||
// unlisten | ||
return () => { | ||
const i = waitingDepIndexListeners.indexOf(cb) | ||
if (i > -1) waitingDepIndexListeners.splice(i, 1) | ||
} | ||
} | ||
const warnings = messages.filter((v) => v.type === 'warning') | ||
if (warnings.length) { | ||
console.log(c.bold(c.yellow('Warnings:'))) | ||
warnings.forEach((m, i) => | ||
console.log(c.dim(`${i + 1}. `) + printMessage(m, rootPkg)) | ||
) | ||
} | ||
// lint deps in parallel, but log results in order asap | ||
for (let i = 0; i < deps.length; i++) { | ||
pq.push(async () => { | ||
const depDir = await findDepPath(deps[i], pkgDir) | ||
const { logs, success } = await lintDir(depDir, true) | ||
if (!success) process.exitCode = 1 | ||
// log this lint result | ||
const log = () => { | ||
logs.forEach((l, j) => console.log((j > 0 ? ' ' : '') + l)) | ||
waitingDepIndex++ | ||
waitingDepIndexListeners.forEach((cb) => cb()) | ||
} | ||
// log when it's our turn so that the results are ordered alphabetically, | ||
// though all deps are linted in parallel | ||
if (waitingDepIndex === i) { | ||
log() | ||
} else { | ||
const unlisten = listenWaitingDepIndex(() => { | ||
if (waitingDepIndex === i) { | ||
log() | ||
unlisten() | ||
} | ||
}) | ||
} | ||
}) | ||
} | ||
const errors = messages.filter((v) => v.type === 'error') | ||
if (errors.length) { | ||
console.log(c.bold(c.red('Errors:'))) | ||
errors.forEach((m, i) => | ||
console.log(c.dim(`${i + 1}. `) + printMessage(m, rootPkg)) | ||
) | ||
} | ||
await pq.wait() | ||
}) | ||
process.exitCode = 1 | ||
cli.parse(process.argv) | ||
/** | ||
* @param {string} pkgDir | ||
* @param {boolean} [compact] | ||
*/ | ||
async function lintDir(pkgDir, compact = false) { | ||
/** @type {string[]} */ | ||
const logs = [] | ||
const rootPkgContent = await fs | ||
.readFile(path.join(pkgDir, 'package.json'), 'utf8') | ||
.catch(() => { | ||
logs.push(c.red(`Unable to read package.json at ${pkgDir}`)) | ||
}) | ||
if (!rootPkgContent) return { logs, success: false } | ||
const rootPkg = JSON.parse(rootPkgContent) | ||
const messages = await publint({ pkgDir }) | ||
if (messages.length) { | ||
const suggestions = messages.filter((v) => v.type === 'suggestion') | ||
if (suggestions.length) { | ||
logs.push(c.bold(c.blue('Suggestions:'))) | ||
suggestions.forEach((m, i) => | ||
logs.push(c.dim(`${i + 1}. `) + printMessage(m, rootPkg)) | ||
) | ||
} | ||
const warnings = messages.filter((v) => v.type === 'warning') | ||
if (warnings.length) { | ||
logs.push(c.bold(c.yellow('Warnings:'))) | ||
warnings.forEach((m, i) => | ||
logs.push(c.dim(`${i + 1}. `) + printMessage(m, rootPkg)) | ||
) | ||
} | ||
const errors = messages.filter((v) => v.type === 'error') | ||
if (errors.length) { | ||
logs.push(c.bold(c.red('Errors:'))) | ||
errors.forEach((m, i) => | ||
logs.push(c.dim(`${i + 1}. `) + printMessage(m, rootPkg)) | ||
) | ||
} | ||
if (compact) { | ||
logs.unshift(`${c.red('x')} ${c.bold(rootPkg.name)}`) | ||
} else { | ||
console.log(c.bold(c.green('All good!'))) | ||
logs.unshift(`${c.bold(rootPkg.name)} lint results:`) | ||
} | ||
}) | ||
.parse(process.argv) | ||
return { logs, success: false } | ||
} else { | ||
if (compact) { | ||
logs.unshift(`${c.green('✓')} ${c.bold(rootPkg.name)}`) | ||
} else { | ||
logs.unshift(`${c.bold(rootPkg.name)} lint results:`) | ||
logs.push(c.bold(c.green('All good!'))) | ||
} | ||
return { logs, success: true } | ||
} | ||
} | ||
/** @type {import('pnpapi')} */ | ||
let pnp | ||
if (process.versions.pnp) { | ||
try { | ||
const { createRequire } = (await import('module')).default | ||
pnp = createRequire(import.meta.url)('pnpapi') | ||
} catch {} | ||
} | ||
/** | ||
* | ||
* @param {string} dep | ||
* @param {string} parent | ||
* @returns | ||
*/ | ||
async function findDepPath(dep, parent) { | ||
if (pnp) { | ||
const depRoot = pnp.resolveToUnqualified(dep, parent) | ||
if (!depRoot) return undefined | ||
} else { | ||
const depRoot = path.join(parent, 'node_modules', dep) | ||
try { | ||
await fs.access(depRoot) | ||
return fsSync.realpathSync(depRoot) | ||
} catch { | ||
return undefined | ||
} | ||
} | ||
} |
@@ -22,3 +22,2 @@ export type MessageType = 'suggestion' | 'warning' | 'error' | ||
// TODO: Check nodejs modules usage | ||
export type Message = | ||
@@ -64,2 +63,3 @@ | BaseMessage< | ||
| BaseMessage<'EXPORTS_MODULE_SHOULD_BE_ESM'> | ||
| BaseMessage<'EXPORTS_VALUE_INVALID', { suggestValue: string }> | ||
| BaseMessage<'USE_EXPORTS_BROWSER'> | ||
@@ -66,0 +66,0 @@ |
{ | ||
"name": "publint", | ||
"version": "0.1.5", | ||
"version": "0.1.6", | ||
"description": "Lint packaging errors", | ||
@@ -32,3 +32,3 @@ "type": "module", | ||
"funding": "https://bjornlu.com/sponsor", | ||
"homepage": "https://publint.bjornlu.com", | ||
"homepage": "https://publint.dev", | ||
"repository": { | ||
@@ -35,0 +35,0 @@ "type": "git", |
@@ -16,3 +16,3 @@ <br> | ||
<p align="center"> | ||
<a href="https://publint.bjornlu.com"> | ||
<a href="https://publint.dev"> | ||
<strong>Try it online</strong> | ||
@@ -24,3 +24,3 @@ </a> | ||
This package contains a CLI and API to lint packages locally. The package to be linted must exist and be built locally for the lint to succeed. To test other npm packages, try https://publint.bjornlu.com. | ||
This package contains a CLI and API to lint packages locally. The package to be linted must exist and be built locally for the lint to succeed. To test other npm packages, try https://publint.dev. | ||
@@ -32,12 +32,14 @@ ## Usage | ||
```bash | ||
$ publint --help | ||
# Lint your library project | ||
$ npx publint | ||
Usage | ||
$ publint [dir] [options] | ||
# Lint a dependency | ||
$ npx publint ./node_modules/some-lib | ||
Options | ||
-v, --version Displays current version | ||
-h, --help Displays this message | ||
# Lint your project's dependencies based on package.json | ||
$ npx publint deps | ||
``` | ||
Use `npx publint --help` for more information. | ||
### API | ||
@@ -53,3 +55,3 @@ | ||
*/ | ||
pkgDir: './path/to/package' | ||
pkgDir: './path/to/package', | ||
/** | ||
@@ -56,0 +58,0 @@ * A virtual file-system object that handles fs/path operations. |
@@ -6,3 +6,4 @@ import { | ||
getCodeFormatExtension, | ||
isExplicitExtension | ||
isExplicitExtension, | ||
createPromiseQueue | ||
} from './utils.js' | ||
@@ -262,10 +263,2 @@ | ||
function createPromiseQueue() { | ||
const promises = [] | ||
return { | ||
push: (fn) => promises.push(fn()), | ||
wait: () => Promise.all(promises) | ||
} | ||
} | ||
/** | ||
@@ -304,2 +297,3 @@ * @param {string | Record<string, any>} fieldValue | ||
promiseQueue.push(async () => { | ||
// warn deprecated subpath mapping | ||
// https://nodejs.org/docs/latest-v16.x/api/packages.html#subpath-folder-mappings | ||
@@ -323,2 +317,14 @@ if (exports.endsWith('/')) { | ||
// error incorrect exports value | ||
if (!exports.startsWith('./')) { | ||
messages.push({ | ||
code: 'EXPORTS_VALUE_INVALID', | ||
args: { | ||
suggestValue: './' + exports.replace(/^[\/]+/, '') | ||
}, | ||
path: currentPath, | ||
type: 'error' | ||
}) | ||
} | ||
const isGlob = exports.includes('*') | ||
@@ -394,3 +400,5 @@ const exportsFiles = await getExportsFiles(exports) | ||
}) | ||
} else { | ||
} | ||
// `exports` could be null to disallow exports of globs from another key | ||
else if (exports) { | ||
const exportsKeys = Object.keys(exports) | ||
@@ -397,0 +405,0 @@ |
@@ -19,4 +19,8 @@ import c from 'picocolors' | ||
const relativePath = m.args.actualFilePath ?? pv(m.path) | ||
const start = | ||
m.path[0] === 'name' | ||
? c.bold(relativePath) | ||
: `${c.bold(fp(m.path))} ${is} ${c.bold(relativePath)} and` | ||
// prettier-ignore | ||
return `${c.bold(fp(m.path))} ${is} ${c.bold(relativePath)} and is written in ${c.yellow(m.args.actualFormat)}, but is interpreted as ${c.yellow(m.args.expectFormat)}. Consider using the ${c.yellow(m.args.expectExtension)} extension, e.g. ${c.bold(relativePath.replace('.js', m.args.expectExtension))}` | ||
return `${start} is written in ${c.yellow(m.args.actualFormat)}, but is interpreted as ${c.yellow(m.args.expectFormat)}. Consider using the ${c.yellow(m.args.expectExtension)} extension, e.g. ${c.bold(relativePath.replace('.js', m.args.expectExtension))}` | ||
} | ||
@@ -26,4 +30,8 @@ case 'FILE_INVALID_EXPLICIT_FORMAT': { | ||
const relativePath = m.args.actualFilePath ?? pv(m.path) | ||
const start = | ||
m.path[0] === 'name' | ||
? c.bold(relativePath) | ||
: `${c.bold(fp(m.path))} ${is} ${c.bold(relativePath)} which` | ||
// prettier-ignore | ||
return `${c.bold(fp(m.path))} ${is} ${c.bold(relativePath)} which ends with the ${c.yellow(m.args.actualExtension)} extension, but the code is written in ${c.yellow(m.args.actualFormat)}. Consider using the ${c.yellow(m.args.expectExtension)} extension, e.g. ${c.bold(relativePath.replace(m.args.actualExtension, m.args.expectExtension))}` | ||
return `${start} ends with the ${c.yellow(m.args.actualExtension)} extension, but the code is written in ${c.yellow(m.args.actualFormat)}. Consider using the ${c.yellow(m.args.expectExtension)} extension, e.g. ${c.bold(relativePath.replace(m.args.actualExtension, m.args.expectExtension))}` | ||
} | ||
@@ -57,2 +65,5 @@ case 'FILE_DOES_NOT_EXIST': | ||
return `${c.bold(fp(m.path))} should be ESM, but the code is written in CJS.` | ||
case 'EXPORTS_VALUE_INVALID': | ||
// prettier-ignore | ||
return `${c.bold(fp(m.path))} is ${c.bold(pv(m.path))} but is invalid as it does not start with "${c.bold('./')}". Use ${c.bold(m.args.suggestValue)} instead.` | ||
case 'USE_EXPORTS_BROWSER': | ||
@@ -59,0 +70,0 @@ // prettier-ignore |
@@ -179,1 +179,9 @@ /** | ||
} | ||
export function createPromiseQueue() { | ||
const promises = [] | ||
return { | ||
push: (fn) => promises.push(fn()), | ||
wait: () => Promise.all(promises) | ||
} | ||
} |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
36597
1008
66