diff --git a/lib/cli.ts b/lib/cli.ts index 33c6243..5a3d363 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -28,6 +28,13 @@ const options = [ description: 'Recursively look into files until the project is clean', default: false, }, + { + name: 'read-package-json', + alias: 'P', + type: 'boolean', + description: 'Load entrypoints defined in package.json', + default: false, + }, { name: 'include-d-ts', type: 'boolean', @@ -76,6 +83,9 @@ Examples: # Check unused code for a project with a custom tsconfig.json tsr --project tsconfig.app.json 'src/main\\.ts$' + # Load entrypoints from package.json + tsr --read-package-json + # Check unused code for a project with multiple entrypoints in src/pages tsr 'src/pages/.*\\.ts$' @@ -105,6 +115,7 @@ const main = () => { configFile: parsed.project || 'tsconfig.json', recursive: parsed.recursive, includeDts: parsed['include-d-ts'], + readPackageJson: parsed['read-package-json'], }).catch((error) => { if (error instanceof CheckResultError || error instanceof ArgError) { process.exitCode = 1; diff --git a/lib/tsr.ts b/lib/tsr.ts index 562900b..8f2fac8 100644 --- a/lib/tsr.ts +++ b/lib/tsr.ts @@ -8,6 +8,7 @@ import { relative, resolve } from 'node:path'; import { formatCount } from './util/formatCount.js'; import { CliOutput } from './util/CliOutput.js'; import { ArgError, CheckResultError } from './util/error.js'; +import { loadEntrypoints } from './util/loadEntrypoints.js'; const createNodeJsLogger = (): Logger => 'isTTY' in stdout && stdout.isTTY @@ -34,6 +35,7 @@ export type Config = { system?: ts.System; logger?: Logger; includeDts?: boolean; + readPackageJson?: boolean; }; // is async for backwards compatibility @@ -46,6 +48,7 @@ export const tsr = async ({ system = ts.sys, logger = createNodeJsLogger(), includeDts = false, + readPackageJson = false, }: Config) => { const configPath = resolve(projectRoot, configFile); @@ -63,6 +66,12 @@ export const tsr = async ({ fileNames.map((n) => [n, system.readFile(n) || '']), ); + if (readPackageJson) { + fileService.set('/package.json', system.readFile('package.json') || ''); + entrypoints = entrypoints.concat(loadEntrypoints({ options, fileService })); + fileService.delete('/package.json'); + } + const entrypointFiles = fileNames.filter( (fileName) => entrypoints.some((regex) => regex.test(fileName)) || diff --git a/lib/util/loadEntrypoints.test.ts b/lib/util/loadEntrypoints.test.ts new file mode 100644 index 0000000..213197e --- /dev/null +++ b/lib/util/loadEntrypoints.test.ts @@ -0,0 +1,32 @@ +import { describe, it } from 'node:test'; +import { MemoryFileService } from './MemoryFileService.js'; +import assert from 'node:assert/strict'; +import ts from 'typescript'; +import { loadEntrypoints } from './loadEntrypoints.js'; + +describe('loadEntrypoints', () => { + it('should load entrypoints from package.json using tsconfig as reference', async () => { + const options: ts.CompilerOptions = { + rootDir: '/src', + outDir: '/dist', + }; + const fileService = new MemoryFileService(); + fileService.set( + '/package.json', + JSON.stringify({ + bin: './dist/cli.js', + main: './dist/index.js', + exports: { '.': 'dist/default-export.js' }, + }), + ); + fileService.set( + '/src/index.ts', + `export const a = 'a'; export const a2 = 'a2';`, + ); + assert.deepEqual(loadEntrypoints({ options, fileService }), [ + new RegExp(/^\/src\/cli\.(js|jsx|ts|tsx|mjs|mts)$/), + new RegExp(/^\/src\/index\.(js|jsx|ts|tsx|mjs|mts)$/), + new RegExp(/^\/src\/default\x2dexport\.(js|jsx|ts|tsx|mjs|mts)$/), + ]); + }); +}); diff --git a/lib/util/loadEntrypoints.ts b/lib/util/loadEntrypoints.ts new file mode 100644 index 0000000..1129963 --- /dev/null +++ b/lib/util/loadEntrypoints.ts @@ -0,0 +1,68 @@ +import path from 'node:path'; +import type ts from 'typescript'; +import { FileService } from './FileService.js'; +import escapeStringRegexp from 'escape-string-regexp'; + +// circular references https://github.com/microsoft/TypeScript/issues/3496#issuecomment-128553540 +type PackageJsonExports = string | PackageJsonExportsObject; +interface PackageJsonExportsObject { + [x: string]: PackageJsonExports; +} + +interface PackageJson { + // https://nodejs.org/api/packages.html#package-entry-points + // https://nodejs.org/api/packages.html#conditional-exports + bin?: string; + main?: string; + exports?: PackageJsonExports; +} + +function findPackageJsonExportsEntries( + exports: PackageJsonExports, + results: string[], +): void { + if (typeof exports === 'string') results.push(exports); + if (exports !== null && typeof exports === 'object') + for (const value of Object.values(exports)) + findPackageJsonExportsEntries(value, results); +} + +export const loadEntrypoints = ({ + options, + fileService, +}: { + options: ts.CompilerOptions; + fileService: FileService; +}): RegExp[] => { + if (!options.outDir || !options.rootDir) return []; + + const packageJson = JSON.parse( + fileService.get('/package.json'), + ) as PackageJson; + + const entries = []; + + if (packageJson.bin) entries.push(packageJson.bin); + if (packageJson.main) entries.push(packageJson.main); + if (packageJson.exports) + findPackageJsonExportsEntries(packageJson.exports, entries); + + const outDir = path.basename(options.outDir); + + return entries.map((entry) => { + const ext = path.extname(entry); + + const relativePath = path.relative(outDir, entry); + const rootDirRelative = path.join(options.rootDir!, relativePath); + + // remove extension + const dirname = path.dirname(rootDirRelative); + const base = path.basename(rootDirRelative, ext); + + const fullPathNoExt = path.join(dirname, base); + + return new RegExp( + `^${escapeStringRegexp(fullPathNoExt)}\\.(js|jsx|ts|tsx|mjs|mts)$`, + ); + }); +}; diff --git a/package-lock.json b/package-lock.json index 46f0b29..325d355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "tsr", "license": "Apache-2.0", "dependencies": { + "escape-string-regexp": "^5.0.0", "mri": "^1.2.0", "picocolors": "^1.1.1" }, @@ -1053,13 +1054,12 @@ } }, "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "peer": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -1227,6 +1227,20 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2820,11 +2834,9 @@ } }, "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "peer": true + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" }, "eslint": { "version": "9.10.0", @@ -2887,6 +2899,13 @@ "supports-color": "^7.1.0" } }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "peer": true + }, "strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", diff --git a/package.json b/package.json index cdaad3c..4388bd9 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "typescript-eslint": "^8.14.0" }, "dependencies": { + "escape-string-regexp": "^5.0.0", "mri": "^1.2.0", "picocolors": "^1.1.1" },