publint
Version:
Lint packaging errors
303 lines (294 loc) • 16.7 kB
JavaScript
import picocolors from 'picocolors'
import { formatMessagePath as fp, getPkgPathValue, replaceLast } from './utils.js'
const EMPTY_MSG = '<empty>'
/** @type {import('../utils.d.ts').formatMessage} */
export function formatMessage(m, pkg, opts = {}) {
const h = getHighlighter(opts.color)
/** @param {string[]} path */
const pv = (path) => {
try {
const str = String(getPkgPathValue(pkg, path))
return str === '' ? EMPTY_MSG : str
} catch {
return 'undefined'
}
}
switch (m.code) {
case 'IMPLICIT_INDEX_JS_INVALID_FORMAT':
return `index.js should be ${m.args.expectFormat} but it is ${m.args.actualFormat}`
case 'FILE_INVALID_FORMAT': {
const relativePath = m.args.actualFilePath ?? pv(m.path)
let start = ''
if (opts.reference || m.path[0] === 'name') {
start = h.bold(relativePath)
} else {
const is = pv(m.path).includes('*') ? 'matches' : 'is'
start = `${h.bold(fp(m.path))} ${is} ${h.bold(relativePath)} and`
}
return `${start} is written in ${h.warn(m.args.actualFormat)}, but is interpreted as ${h.warn(m.args.expectFormat)}. Consider using the ${h.warn(m.args.expectExtension)} extension, e.g. ${h.bold(replaceLast(relativePath, '.js', m.args.expectExtension))}`
}
case 'FILE_INVALID_EXPLICIT_FORMAT': {
const relativePath = m.args.actualFilePath ?? pv(m.path)
let start = ''
if (opts.reference || m.path[0] === 'name') {
start = h.bold(relativePath)
} else {
const is = pv(m.path).includes('*') ? 'matches' : 'is'
start = `${h.bold(fp(m.path))} ${is} ${h.bold(relativePath)} and`
}
return `${start} ends with the ${h.warn(m.args.actualExtension)} extension, but the code is written in ${h.warn(m.args.actualFormat)}. Consider using the ${h.warn(m.args.expectExtension)} extension, e.g. ${h.bold(replaceLast(relativePath, m.args.actualExtension, m.args.expectExtension))}`
}
case 'FILE_INVALID_JSX_EXTENSION': {
const relativePath = m.args.globbedFilePath ?? pv(m.path)
let start = ''
if (opts.reference || m.path[0] === 'name') {
start = h.bold(relativePath)
} else {
const is = m.args.globbedFilePath ? 'matches' : 'is'
start = `${h.bold(fp(m.path))} ${is} ${h.bold(relativePath)} and`
}
return `${start} uses an invalid ${h.bold(m.args.actualExtension)} extension. You don't need to split ESM and CJS formats for JSX. You should write a single file in ESM with the ${h.bold('.jsx')} extension instead, e.g. ${h.bold(replaceLast(pv(m.path), m.args.actualExtension, '.jsx'))}`
}
case 'FILE_DOES_NOT_EXIST':
if (opts.reference) {
return `File does not exist`
} else {
let value = pv(m.path)
// `@types/*` packages have empty `"main"` field by default which can be confusing,
// so specifically improve its message here.
if (value === EMPTY_MSG && m.path[0] === 'main') {
value += ' (implies index.js)'
}
return `${h.bold(fp(m.path))} is ${h.bold(value)} but the file does not exist.`
}
case 'FILE_NOT_PUBLISHED':
if (opts.reference) {
return `File is not published. Is it specified in ${h.bold('pkg.files')}?`
} else {
return `${h.bold(fp(m.path))} is ${h.bold(pv(m.path))} but the file is not published. Is it specified in ${h.bold('pkg.files')}?`
}
case 'HAS_ESM_MAIN_BUT_NO_EXPORTS':
return `${h.bold('pkg.main')} is an ESM file, but it is usually better to use ${h.bold('pkg.exports')} instead. If you don't support Node.js 12.6 and below, you can also remove ${h.bold('pkg.main')}. (This may be a breaking change)`
case 'HAS_MODULE_BUT_NO_EXPORTS':
return `${h.bold('pkg.module')} is used to output ESM, but ${h.bold('pkg.exports')} is not defined. As Node.js doesn't read ${h.bold('pkg.module')}, the ESM output may be skipped. Consider adding ${h.bold('pkg.exports')} to export the ESM output. ${h.bold('pkg.module')} can usually be removed alongside too. (This may be a breaking change)`
case 'MODULE_SHOULD_BE_ESM':
case 'EXPORTS_MODULE_SHOULD_BE_ESM':
case 'IMPORTS_MODULE_SHOULD_BE_ESM': {
const start = opts.reference ? 'Should' : `${h.bold(fp(m.path))} should`
return `${start} be ESM, but the code is written in CJS.`
}
case 'EXPORTS_GLOB_NO_MATCHED_FILES':
case 'IMPORTS_GLOB_NO_MATCHED_FILES': {
const start = opts.reference
? 'Does'
: `${h.bold(fp(m.path))} is ${h.bold(pv(m.path))} but does`
return `${start} not match any files.`
}
case 'EXPORTS_GLOB_NO_DEPRECATED_SUBPATH_MAPPING':
case 'IMPORTS_GLOB_NO_DEPRECATED_SUBPATH_MAPPING':
return `${h.bold(fp(m.path))} maps to a path that ends with ${h.bold('/')} which is a removed feature. Use ${h.bold(fp(m.args.expectPath))}: "${h.bold(m.args.expectValue)}" instead.`
case 'EXPORTS_TYPES_SHOULD_BE_FIRST': {
const start = opts.reference ? 'Should' : `${h.bold(fp(m.path))} should`
return `${start} be the first in the object as conditions are order-sensitive so it can be resolved by TypeScript.`
}
case 'EXPORTS_MODULE_SHOULD_PRECEDE_REQUIRE':
case 'IMPORTS_MODULE_SHOULD_PRECEDE_REQUIRE': {
const start = opts.reference ? 'Should' : `${h.bold(fp(m.path))} should`
return `${start} come before the "require" condition so it can take precedence when used by a bundler.`
}
case 'EXPORTS_DEFAULT_SHOULD_BE_LAST':
case 'IMPORTS_DEFAULT_SHOULD_BE_LAST': {
const start = opts.reference ? 'Should' : `${h.bold(fp(m.path))} should`
return `${start} be the last in the object so it doesn't take precedence over the keys following it.`
}
case 'EXPORTS_VALUE_INVALID':
case 'IMPORTS_VALUE_INVALID': {
const start = opts.reference
? h.bold(pv(m.path))
: `${h.bold(fp(m.path))} is ${h.bold(pv(m.path))} but`
return `${start} is invalid as it does not start with "${h.bold('./')}". Use ${h.bold(m.args.suggestValue)} instead.`
}
case 'EXPORTS_FALLBACK_ARRAY_USE':
case 'IMPORTS_FALLBACK_ARRAY_USE': {
const start = opts.reference ? 'The value' : h.bold(fp(m.path))
return `${start} uses fallback arrays which is not recommended. It picks the first value that can be parsed and does not have a use case in Node.js currently. It also works differently in some tools and may face inconsistent behaviors.`
}
case 'EXPORTS_MISSING_ROOT_ENTRYPOINT': {
const mainField = m.args.mainFields[0]
const start = opts.reference
? 'The root entrypoint is missing'
: `${h.bold(fp(m.path))} is missing the root entrypoint export`
return `${start}, which is defined in ${h.bold('pkg.' + mainField)}. Environments that support the ${h.bold('"exports"')} field will ignore ${h.bold('pkg.' + mainField)} as ${h.bold('"exports"')} takes the highest priority. Consider adding ${h.bold(fp(m.path.concat('.')))}: "${h.bold(pv([mainField]))}".`
}
case 'USE_EXPORTS_BROWSER':
return (
`${h.bold('pkg.browser')} with a string value can be refactored to use ${h.bold('pkg.exports')} and the ${h.bold('"browser"')} condition to declare browser-specific exports. ` +
`e.g. ${h.bold('pkg.exports["."].browser')}: "${h.bold(pv(m.path))}". (This may be a breaking change)`
)
case 'USE_EXPORTS_OR_IMPORTS_BROWSER':
return `${h.bold('pkg.browser')} with an object value can be refactored to use ${h.bold('pkg.exports')}/${h.bold('pkg.imports')} and the ${h.bold('"browser"')} condition to declare browser-specific exports. (This may be a breaking change)`
case 'USE_FILES':
return `The package ${h.bold('publishes internal tests or config files')}. You can use ${h.bold('pkg.files')} to only publish certain files and save user bandwidth.`
case 'USE_SIDE_EFFECTS':
return `The package appears to be consumed by bundlers but does not specify ${h.bold('"sideEffects"')}. Consider adding ${h.bold('"sideEffects"')}: ${h.bold('false')} so bundlers can optimize tree-shaking if your package has no side effects.`
case 'USE_TYPE':
return `The package does not specify the ${h.bold('"type"')} field. Node.js may attempt to detect the package type causing a small performance hit. Consider adding ${h.bold('"type"')}: "${h.bold('commonjs')}".`
case 'USE_ENGINES_NODE':
return `The package does not specify the ${h.bold('"engines.node"')} field. Consumers may install it on an unsupported Node.js version. Consider adding the field with your actual minimum supported Node.js version. (This may be a breaking change)`
case 'USE_LICENSE':
return `The package does not specify the ${h.bold('"license"')} field but a license file was detected at ${h.bold(m.args.licenseFilePath)}. Consider adding a ${h.bold('"license"')} field so it's displayed on npm.`
case 'TYPES_NOT_EXPORTED': {
const typesFilePath = exportsRel(m.args.typesFilePath)
const start = opts.reference ? 'The' : h.bold(fp(m.path))
if (m.args.actualExtension && m.args.expectExtension) {
return (
`${start} types is not exported. Consider adding ${h.bold(fp(m.path) + '.types')} to be compatible with TypeScript's ${h.bold('"moduleResolution": "bundler"')} compiler option. ` +
`Note that you cannot use "${h.bold(typesFilePath)}" because it has a mismatching format. Instead, you can duplicate the file and use the ${h.bold(m.args.expectExtension)} extension, e.g. ` +
`${h.bold(fp(m.path) + '.types')}: "${h.bold(replaceLast(typesFilePath, m.args.actualExtension, m.args.expectExtension))}"`
)
} else {
return `${start} types is not exported. Consider adding ${h.bold(fp(m.path) + '.types')}: "${h.bold(typesFilePath)}" to be compatible with TypeScript's ${h.bold('"moduleResolution": "bundler"')} compiler option.`
}
}
case 'EXPORTS_TYPES_INVALID_FORMAT': {
let additionalMessage = ''
// ambiguous default export
if (m.args.expectFormat === 'ESM' && m.args.actualFormat === 'CJS') {
additionalMessage = `This causes the types to be ambiguous when default importing the package due to its implied interop. `
}
// incorrect dynamic import restriction
else if (m.args.expectFormat === 'CJS' && m.args.actualFormat === 'ESM') {
additionalMessage = `This causes the types to only work when dynamically importing the package, even though the package exports CJS. `
}
const start = opts.reference ? 'The' : h.bold(fp(m.path))
return (
`${start} types is interpreted as ${m.args.actualFormat} when resolving with the "${h.bold(m.args.condition)}" condition. ` +
additionalMessage +
`Consider splitting out two ${h.bold('"types"')} conditions for ${h.bold('"import"')} and ${h.bold('"require"')}, and use the ${h.warn(m.args.expectExtension)} extension, ` +
`e.g. ${h.bold(fp(m.args.expectPath))}: "${h.bold(replaceLast(pv(m.path), m.args.actualExtension, m.args.expectExtension))}"`
)
}
case 'FIELD_INVALID_VALUE_TYPE': {
let expectStr = m.args.expectTypes[0]
for (let i = 1; i < m.args.expectTypes.length; i++) {
if (i === m.args.expectTypes.length - 1) {
expectStr += ` or ${m.args.expectTypes[i]}`
} else {
expectStr += `, ${m.args.expectTypes[i]}`
}
}
const start = opts.reference
? 'The field value has'
: `${h.bold(fp(m.path))} is ${h.bold(pv(m.path))} which is`
return `${start} an invalid ${h.bold(m.args.actualType)} type. Expected a ${h.bold(expectStr)} type instead.`
}
case 'EXPORTS_VALUE_CONFLICTS_WITH_BROWSER': {
const start = opts.reference
? `${h.bold(pv(m.path))} matches`
: `${h.bold(fp(m.path))} is ${h.bold(pv(m.path))} which also matches`
return `${start} ${h.bold(fp(m.args.browserPath))}: "${h.bold(pv(m.args.browserPath))}", which overrides the path when building the library with the "${h.bold(m.args.browserishCondition)}" condition. This is usually unintentional and may cause build issues. Consider using a different file name for ${h.bold(pv(m.path))}.`
}
case 'DEPRECATED_FIELD_JSNEXT':
return `${h.bold(fp(m.path))} is deprecated. ${h.bold('pkg.module')} should be used instead.`
case 'INVALID_REPOSITORY_VALUE':
switch (m.args.type) {
case 'invalid-string-shorthand': {
const start = opts.reference
? 'The field value'
: `${h.bold(fp(m.path))} is ${h.bold(pv(m.path))} which`
return `${start} isn't a valid shorthand value supported by npm. Consider using an object that references a repository.`
}
case 'invalid-git-url': {
const start = opts.reference
? 'The field value'
: `${h.bold(fp(m.path))} is ${h.bold(pv(m.path))} which`
return `${start} isn't a valid git URL. A valid git URL is usually in the form of "${h.bold('git+https://example.com/user/repo.git')}".`
}
case 'deprecated-github-git-protocol': {
const start = opts.reference
? 'The field value'
: `${h.bold(fp(m.path))} is ${h.bold(pv(m.path))} which`
return `${start} uses the git:// protocol that is deprecated by GitHub due to security concerns. Consider replacing the protocol with https://.`
}
case 'shorthand-git-sites': {
const start = opts.reference
? 'The field value'
: `${h.bold(fp(m.path))} is ${h.bold(pv(m.path))} but`
return `${start} could be a full git URL like "${h.bold(m.args.suggestValue)}".`
}
}
case 'LOCAL_DEPENDENCY':
if (opts.reference) {
return `This dependency references a local package that will likely not work when installed by end-users.`
} else {
return `The "${h.bold(m.path[m.path.length - 1])}" dependency references "${h.bold(pv(m.path))}" that will likely not work when installed by end-users.`
}
case 'BIN_FILE_NOT_EXECUTABLE': {
const start = opts.reference
? 'This bin file'
: `${h.bold(fp(m.path))} is ${h.bold(pv(m.path))} but the file`
return `${start} is not executable. It should start with a shebang, e.g. ${h.bold('#!/usr/bin/env node')}.`
}
case 'IMPORTS_KEY_INVALID':
const start = opts.reference
? `The imports key is invalid as it`
: `${h.bold(fp(m.path))} is invalid as the imports key`
return `${start} does not start with "${h.bold('#')}". Use ${h.bold(m.args.suggestKey)} instead.`
case 'CJS_WITH_ESMODULE_DEFAULT_EXPORT': {
const start =
m.path[0] === 'name' && m.args.filePath
? h.bold(m.args.filePath)
: opts.reference
? 'This file'
: `${h.bold(fp(m.path))}`
return `${start} sets both ${h.bold('exports.__esModule = true')} and ${h.bold('exports.default')}. This pattern breaks default imports when importing from an ESM-interpreted file in some bundlers. Consider using ESM syntax instead of CommonJS with ${h.bold('__esModule')} or avoid the default export.`
}
case 'NESTED_PACKAGE_JSON_FIELD_IGNORED': {
return `${h.bold(m.args.filePath)} has an ${h.bold(`"${m.args.field}"`)} field, but it is ignored by Node.js. The field only works in root ${h.bold('package.json')} files and not nested ones. Some bundlers may still pick them up, leading to inconsistent resolution. Consider removing it.`
}
default:
return
}
}
/** @type { import('picocolors/types.js').Colors | undefined } */
let _picocolorsWithForcedColor
/**
* @param {import('../utils.js').FormatMessageOptions['color']} color
*/
function getHighlighter(color) {
/** @type {(s: string) => string} */
let bold
/** @type {(s: string) => string} */
let warn
switch (color) {
case 'html':
bold = (s) => `<strong>${s}</strong>`
warn = (s) => `<strong>${s}</strong>`
break
case true:
_picocolorsWithForcedColor ??= picocolors.createColors(true)
// @ts-expect-error
bold = (s) => _picocolorsWithForcedColor.bold(s)
// @ts-expect-error
warn = (s) => _picocolorsWithForcedColor.yellow(s)
break
case false:
bold = (s) => s
warn = (s) => s
break
default:
bold = (s) => picocolors.bold(s)
warn = (s) => picocolors.yellow(s)
break
}
return { bold, warn }
}
/**
* Make sure s is an `"exports"` compatible relative path
* @param {string} s
*/
function exportsRel(s) {
if (s[0] === '.') return s
if (s[0] === '/') return '.' + s
return './' + s
}