Comparing version 0.1.16 to 0.2.0
@@ -47,3 +47,3 @@ export type MessageType = 'suggestion' | 'warning' | 'error' | ||
> | ||
| BaseMessage<'FILE_DOES_NOT_EXIST', { filePath: string }> // TODO: remove `filePath` | ||
| BaseMessage<'FILE_DOES_NOT_EXIST'> | ||
| BaseMessage<'FILE_NOT_PUBLISHED'> | ||
@@ -67,3 +67,20 @@ | BaseMessage<'MODULE_SHOULD_BE_ESM'> | ||
| BaseMessage<'USE_EXPORTS_BROWSER'> | ||
| BaseMessage<'TYPES_NOT_EXPORTED', { typesFilePath: string }> | ||
| BaseMessage< | ||
'TYPES_NOT_EXPORTED', | ||
{ | ||
typesFilePath: string | ||
actualExtension?: string | ||
expectExtension?: string | ||
} | ||
> | ||
| BaseMessage< | ||
'EXPORT_TYPES_INVALID_FORMAT', | ||
{ | ||
condition: string | ||
actualFormat: string | ||
expectFormat: string | ||
actualExtension: string | ||
expectExtension: string | ||
} | ||
> | ||
@@ -105,2 +122,6 @@ export interface Options { | ||
export declare function publint(options?: Options): Promise<Message[]> | ||
export interface Result { | ||
messages: Message[] | ||
} | ||
export declare function publint(options?: Options): Promise<Result> |
@@ -10,3 +10,3 @@ #!/usr/bin/env node | ||
import { publint } from './node.js' | ||
import { printMessage } from '../src/message.js' | ||
import { formatMessage } from '../src/message.js' | ||
import { createPromiseQueue } from '../src/utils.js' | ||
@@ -123,3 +123,3 @@ | ||
const pkgName = rootPkg.name || path.basename(pkgDir) | ||
const messages = await publint({ pkgDir, level, strict }) | ||
const { messages } = await publint({ pkgDir, level, strict }) | ||
@@ -131,3 +131,3 @@ if (messages.length) { | ||
suggestions.forEach((m, i) => | ||
logs.push(c.dim(`${i + 1}. `) + printMessage(m, rootPkg)) | ||
logs.push(c.dim(`${i + 1}. `) + formatMessage(m, rootPkg)) | ||
) | ||
@@ -140,3 +140,3 @@ } | ||
warnings.forEach((m, i) => | ||
logs.push(c.dim(`${i + 1}. `) + printMessage(m, rootPkg)) | ||
logs.push(c.dim(`${i + 1}. `) + formatMessage(m, rootPkg)) | ||
) | ||
@@ -149,3 +149,3 @@ } | ||
errors.forEach((m, i) => | ||
logs.push(c.dim(`${i + 1}. `) + printMessage(m, rootPkg)) | ||
logs.push(c.dim(`${i + 1}. `) + formatMessage(m, rootPkg)) | ||
) | ||
@@ -152,0 +152,0 @@ process.exitCode = 1 |
export { formatMessagePath, getPkgPathValue } from '../src/utils.js' | ||
export { printMessage } from '../src/message.js' | ||
export { formatMessage } from '../src/message.js' |
@@ -5,6 +5,6 @@ export { formatMessagePath, getPkgPathValue } from '../src/utils.js' | ||
* no-op. leave users to implement themselves. | ||
* @type {import('../utils.d.ts').printMessage} | ||
* @type {import('../utils.d.ts').formatMessage} | ||
*/ | ||
export function printMessage() { | ||
export function formatMessage() { | ||
return | ||
} |
{ | ||
"name": "publint", | ||
"version": "0.1.16", | ||
"version": "0.2.0", | ||
"description": "Lint packaging errors", | ||
@@ -13,14 +13,10 @@ "type": "module", | ||
"types": "./index.d.ts", | ||
"import": { | ||
"browser": "./lib/browser.js", | ||
"node": "./lib/node.js", | ||
"default": "./src/index.js" | ||
} | ||
"browser": "./lib/browser.js", | ||
"node": "./lib/node.js", | ||
"default": "./src/index.js" | ||
}, | ||
"./utils": { | ||
"types": "./utils.d.ts", | ||
"import": { | ||
"node": "./lib/utils-node.js", | ||
"default": "./lib/utils.js" | ||
} | ||
"node": "./lib/utils-node.js", | ||
"default": "./lib/utils.js" | ||
} | ||
@@ -27,0 +23,0 @@ }, |
@@ -47,3 +47,3 @@ <br> | ||
const messages = await publint({ | ||
const { messages } = await publint({ | ||
/** | ||
@@ -78,3 +78,3 @@ * Path to your package that contains a package.json file. | ||
```js | ||
import { printMessage } from 'publint/utils' | ||
import { formatMessage } from 'publint/utils' | ||
import fs from 'node:fs/promises' | ||
@@ -89,3 +89,3 @@ | ||
// Useful for re-implementing the CLI in a programmatic way. | ||
printMessage(message, pkg) | ||
console.log(formatMessage(message, pkg)) | ||
} | ||
@@ -92,0 +92,0 @@ ``` |
136
src/index.js
@@ -11,3 +11,8 @@ import { | ||
isFilePathLintable, | ||
isFileContentLintable | ||
isFileContentLintable, | ||
getAdjacentDtsPath, | ||
resolveExports, | ||
isDtsFile, | ||
getDtsFilePathFormat, | ||
getDtsCodeFormatExtension | ||
} from './utils.js' | ||
@@ -26,3 +31,3 @@ | ||
* @param {Options} options | ||
* @returns {Promise<import('../index.js').Message[]>} | ||
* @returns {Promise<import('../index.d.ts').Result>} | ||
*/ | ||
@@ -39,3 +44,3 @@ export async function publint({ pkgDir, vfs, level, strict, _packedFiles }) { | ||
const rootPkgContent = await readFile(rootPkgPath, []) | ||
if (rootPkgContent === false) return messages | ||
if (rootPkgContent === false) return { messages } | ||
const rootPkg = JSON.parse(rootPkgContent) | ||
@@ -73,3 +78,3 @@ const [main, mainPkgPath] = getPublishedField(rootPkg, 'main') | ||
code: 'FILE_DOES_NOT_EXIST', | ||
args: { filePath: path }, | ||
args: {}, | ||
path: pkgPath, | ||
@@ -308,8 +313,8 @@ type: 'error' | ||
if (level === 'warning') { | ||
return messages.filter((m) => m.type !== 'suggestion') | ||
return { messages: messages.filter((m) => m.type !== 'suggestion') } | ||
} else if (level === 'error') { | ||
return messages.filter((m) => m.type === 'error') | ||
return { messages: messages.filter((m) => m.type === 'error') } | ||
} | ||
return messages | ||
return { messages } | ||
@@ -559,3 +564,3 @@ /** | ||
// else this `exports` may have multiple export entrypoints, check for '.' | ||
// TODO: check for other entrypoints | ||
// TODO: check for other entrypoints, move logic into `crawlExports` | ||
else if ('.' in exports) { | ||
@@ -577,15 +582,104 @@ checkTypesExported('.') | ||
if ( | ||
typesFilePath && // check has existing types? | ||
(typeof exportsRootValue === 'string' || // if the root value is just a string, types is not exported | ||
!objectHasKeyNested(exportsRootValue, 'types')) // else if object, check has types condition | ||
) { | ||
messages.push({ | ||
code: 'TYPES_NOT_EXPORTED', | ||
args: { typesFilePath }, | ||
path: exportsRootKey | ||
? exportsPkgPath.concat(exportsRootKey) | ||
: exportsPkgPath, | ||
type: 'warning' | ||
}) | ||
// detect if this package intend to ship types | ||
if (typesFilePath) { | ||
const exportsPath = exportsRootKey | ||
? exportsPkgPath.concat(exportsRootKey) | ||
: exportsPkgPath | ||
// keyed strings for seen resolved paths, so we don't trigger duplicate messages for the same thing | ||
const seenResolvedKeys = new Set() | ||
// NOTE: got lazy. here we check for the import/require result in different environments | ||
// to make sure we cover possible cases. however, a better way it to resolve the exports | ||
// and scan also the possible environment conditions, and return an array instead. | ||
for (const env of [undefined, 'node', 'browser', 'worker']) { | ||
for (const format of ['import', 'require']) { | ||
const result = resolveExports( | ||
exportsRootValue, | ||
// @ts-expect-error till this day, ts still doesn't understand `filter(Boolean)` | ||
['types', format, env].filter(Boolean), | ||
exportsPath | ||
) | ||
if (!result) continue | ||
// check if we've seen this resolve before. we also key by format as we want to distinguish | ||
// incorrect exports, but only when the "exports -> path" contains that format, otherwise | ||
// it's intentional fallback behaviour by libraries and we don't want to trigger a false alarm. | ||
// e.g. libraries that only `"exports": "./index.mjs"` means it's ESM only, so we don't key | ||
// the format, so the next run with `"require"` condition is skipped. | ||
// different env can share the same key as code can usually be used for multiple environments. | ||
const seenKey = | ||
result.path.join('.') + (result.dualPublish ? format : '') | ||
if (seenResolvedKeys.has(seenKey)) continue | ||
seenResolvedKeys.add(seenKey) | ||
const resolvedPath = vfs.pathJoin(pkgDir, result.value) | ||
// if path doesn't exist, let the missing file error message take over instead | ||
if (!(await vfs.isPathExist(resolvedPath))) continue | ||
if (isDtsFile(result.value)) { | ||
// if we have resolve to a dts file, it might not be ours because typescript requires | ||
// `.d.mts` and `.d.cts` for esm and cjs (`.js` and nearest type: module behaviour applies). | ||
// check if we're hitting this case :( | ||
const dtsActualFormat = await getDtsFilePathFormat( | ||
resolvedPath, | ||
vfs | ||
) | ||
// get the intended format from the conditions. yes, while the `import` condition can actually | ||
// point to a CJS file, what we're checking here is the intent of the expected format. | ||
// otherwise the package could currently have the wrong format (which we would emit a message above), | ||
// but when we reach here, we don't want to base on that incorrect format. | ||
const dtsExpectFormat = format === 'import' ? 'ESM' : 'CJS' | ||
if (dtsActualFormat !== dtsExpectFormat) { | ||
messages.push({ | ||
code: 'EXPORT_TYPES_INVALID_FORMAT', | ||
args: { | ||
condition: format, | ||
actualFormat: dtsActualFormat, | ||
expectFormat: dtsExpectFormat, | ||
actualExtension: vfs.getExtName(result.value), | ||
expectExtension: getDtsCodeFormatExtension(dtsExpectFormat) | ||
}, | ||
path: result.path, | ||
type: 'warning' | ||
}) | ||
} | ||
} else { | ||
// adjacent dts file here is always in the correct format | ||
const hasAdjacentDtsFile = await vfs.isPathExist( | ||
vfs.pathJoin(pkgDir, getAdjacentDtsPath(result.value)) | ||
) | ||
// if there's no adjacent dts file, it's likely they don't support moduleResolution: bundler. | ||
// try to provide a warning. | ||
if (!hasAdjacentDtsFile) { | ||
// before we recommend using `typesFilePath` for this export condition, we need to make sure | ||
// it's of a matching format | ||
const dtsActualFormat = await getDtsFilePathFormat( | ||
vfs.pathJoin(pkgDir, typesFilePath), | ||
vfs | ||
) | ||
const dtsExpectFormat = format === 'import' ? 'ESM' : 'CJS' | ||
// if it's a matching format, we can recommend using the types file for this exports condition too. | ||
// if not, we need to tell them to create a `.d.[mc]ts` file and not use `typesFilePath`. | ||
// this is signalled in `matchingFormat`, where the message handler should check for it. | ||
const isMatchingFormat = dtsActualFormat === dtsExpectFormat | ||
messages.push({ | ||
code: 'TYPES_NOT_EXPORTED', | ||
args: { | ||
typesFilePath, | ||
actualExtension: isMatchingFormat | ||
? undefined | ||
: vfs.getExtName(typesFilePath), | ||
expectExtension: isMatchingFormat | ||
? undefined | ||
: getDtsCodeFormatExtension(dtsExpectFormat) | ||
}, | ||
path: result.path, | ||
type: 'warning' | ||
}) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
@@ -592,0 +686,0 @@ }) |
@@ -8,3 +8,3 @@ import c from 'picocolors' | ||
*/ | ||
export function printMessage(m, pkg) { | ||
export function formatMessage(m, pkg) { | ||
/** @param {string[]} path */ | ||
@@ -77,10 +77,26 @@ const pv = (path) => getPkgPathValue(pkg, path) | ||
case 'TYPES_NOT_EXPORTED': { | ||
let target = fp(m.path) | ||
if (target.endsWith('.exports')) { | ||
target = 'The library' | ||
const typesFilePath = exportsRel(m.args.typesFilePath) | ||
if (m.args.actualExtension && m.args.expectExtension) { | ||
// prettier-ignore | ||
return `${c.bold(fp(m.path))} types is not exported. Consider adding ${c.bold(fp(m.path) + '.types')} to be compatible with TypeScript's ${c.bold('"moduleResolution": "bundler"')} compiler option. ` | ||
+ `Note that you cannot use "${c.bold(typesFilePath)}" because it has a mismatching format. Instead, you can duplicate the file and use the ${c.bold(m.args.expectExtension)} extension, e.g. ` | ||
+ `${c.bold(fp(m.path) + '.types')}: "${c.bold(typesFilePath.replace(m.args.actualExtension, m.args.expectExtension))}"` | ||
} else { | ||
target = c.bold(target) | ||
// prettier-ignore | ||
return `${c.bold(fp(m.path))} types is not exported. Consider adding ${c.bold(fp(m.path) + '.types')}: "${c.bold(typesFilePath)}" to be compatible with TypeScript's ${c.bold('"moduleResolution": "bundler"')} compiler option.` | ||
} | ||
} | ||
case 'EXPORT_TYPES_INVALID_FORMAT': { | ||
// convert ['exports', 'types'] -> ['exports', '<condition>', 'types'] | ||
// convert ['exports', 'types', 'node'] -> ['exports', 'types', 'node', '<condition>'] | ||
const expectPath = m.path.slice() | ||
const typesIndex = m.path.findIndex((p) => p === 'types') | ||
if (typesIndex === m.path.length - 1) { | ||
expectPath.splice(typesIndex, 0, m.args.condition) | ||
} else { | ||
expectPath.push(m.args.condition) | ||
} | ||
// prettier-ignore | ||
return `${target} has types at ${c.bold(m.args.typesFilePath)} but it is not exported from ${c.bold('pkg.exports')}. Consider adding it to ${c.bold(fp(m.path) + '.types')} to be compatible with TypeScript's ${c.bold('"moduleResolution": "bundler"')} compiler option.` | ||
return `${c.bold(fp(m.path))} types is an invalid format when resolving with the "${c.bold(m.args.condition)}" condition. Consider splitting out two ${c.bold("types")} conditions for ${c.bold("import")} and ${c.bold("require")}, and use the ${c.yellow(m.args.expectExtension)} extension, ` | ||
+ `e.g. ${c.bold(fp(expectPath))}: "${c.bold(pv(m.path).replace(m.args.actualExtension, m.args.expectExtension))}"` | ||
} | ||
@@ -91,1 +107,11 @@ default: | ||
} | ||
/** | ||
* 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 | ||
} |
@@ -149,3 +149,3 @@ /** | ||
* @param {import('../index.d.ts').Vfs} vfs | ||
* @returns {Promise<CodeFormat>} | ||
* @returns {Promise<Exclude<CodeFormat, 'mixed' | 'unknown'>>} | ||
*/ | ||
@@ -166,2 +166,14 @@ export async function getFilePathFormat(filePath, vfs) { | ||
/** | ||
* @param {string} filePath | ||
* @param {import('../index.d.ts').Vfs} vfs | ||
* @returns {Promise<Exclude<CodeFormat, 'mixed' | 'unknown'>>} | ||
*/ | ||
export async function getDtsFilePathFormat(filePath, vfs) { | ||
if (filePath.endsWith('.d.mts')) return 'ESM' | ||
if (filePath.endsWith('.d.cts')) return 'CJS' | ||
const nearestPkg = await getNearestPkg(filePath, vfs) | ||
return nearestPkg?.type === 'module' ? 'ESM' : 'CJS' | ||
} | ||
/** | ||
* @param {CodeFormat} format | ||
@@ -181,2 +193,16 @@ */ | ||
/** | ||
* @param {CodeFormat} format | ||
*/ | ||
export function getDtsCodeFormatExtension(format) { | ||
switch (format) { | ||
case 'ESM': | ||
return '.mts' | ||
case 'CJS': | ||
return '.cts' | ||
default: | ||
return '.ts' | ||
} | ||
} | ||
/** | ||
* @param {string} path | ||
@@ -304,1 +330,66 @@ */ | ||
} | ||
/** | ||
* @param {string} filePath | ||
*/ | ||
export function getAdjacentDtsPath(filePath) { | ||
// foo.js -> foo.d.ts | ||
// foo.mjs -> foo.d.mts | ||
// foo.cjs -> foo.d.cts | ||
// foo.jsx -> foo.d.ts | ||
return filePath.replace(/\.([mc]?)jsx?$/, '.d.$1ts') | ||
} | ||
const DTS_RE = /\.d\.[mc]?ts$/ | ||
/** | ||
* @param {string} filePath | ||
*/ | ||
export function isDtsFile(filePath) { | ||
return filePath.endsWith('.d.ts') | ||
} | ||
/** | ||
* simplified `exports` field resolver that expects `exportsValue` to be the path value directly. | ||
* no path matching will happen. `exportsValue` should be an object that contains only conditions | ||
* and their values, or a string | ||
* @param {Record<string, any> | string | string[]} exportsValue | ||
* @param {string[]} conditions | ||
* @param {string[]} [currentPath] matched conditions while resolving the exports | ||
* @param {{ dualPublish: boolean }} [_metadata] | ||
* @returns {{ value: string, path: string[], dualPublish: boolean } | undefined} | ||
*/ | ||
export function resolveExports( | ||
exportsValue, | ||
conditions, | ||
currentPath = [], | ||
_metadata = { dualPublish: false } | ||
) { | ||
if (typeof exportsValue === 'string') { | ||
// prettier-ignore | ||
return { value: exportsValue, path: currentPath, dualPublish: _metadata.dualPublish } | ||
} else if (Array.isArray(exportsValue)) { | ||
// prettier-ignore | ||
return { value: exportsValue[0], path: currentPath, dualPublish: _metadata.dualPublish } | ||
} | ||
// while traversing the exports object, also keep info it the path we're traversing | ||
// intends to dual export. helpful for better logging heuristics. | ||
if ( | ||
_metadata.dualPublish === false && | ||
'import' in exportsValue && | ||
'require' in exportsValue | ||
) { | ||
_metadata.dualPublish = true | ||
} | ||
for (const key in exportsValue) { | ||
if (conditions.includes(key) || key === 'default') { | ||
return resolveExports( | ||
exportsValue[key], | ||
conditions, | ||
currentPath.concat(key), | ||
_metadata | ||
) | ||
} | ||
} | ||
} |
@@ -27,3 +27,2 @@ import fs from 'node:fs' | ||
}, | ||
// TODO: Manually create these | ||
pathJoin(...parts) { | ||
@@ -30,0 +29,0 @@ return nodePath.join(...parts) |
@@ -1,2 +0,2 @@ | ||
import { Message } from './index.js' | ||
import type { Message } from './index.js' | ||
@@ -7,2 +7,5 @@ type Pkg = Record<string, any> | ||
export declare function getPkgPathValue(pkg: Pkg, path: string[]): any | ||
export declare function printMessage(msg: Message, pkg: Pkg): string | undefined | ||
export declare function formatMessage( | ||
msg: Message, | ||
pkg: Pkg | ||
): string | undefined |
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
57777
1519