nhb-scripts
Version:
A collection of Node.js scripts to use in TypeScript & JavaScript projects
271 lines (234 loc) • 7.77 kB
JavaScript
// @ts-check
import { spinner } from '@clack/prompts';
import chalk from 'chalk';
import fs from 'node:fs';
import path from 'node:path';
import { parsePackageJson, writeToPackageJson } from './package-json-utils.mjs';
/**
* @typedef {Required<import('type-fest').PackageJson.Exports>} Exports
*
* @import { FixTypeExportsOptions } from '../types/index.d.ts';
*/
/**
* Normalize path to posix style and ensure leading './'
* @param {string} filePath
*/
const normalizeExportPath = (filePath) => {
const posixPath = filePath.split(path.sep).join('/');
return posixPath.startsWith('./') ? posixPath : `./${posixPath}`;
};
/**
* Recursively collect subdirectories at the first level under distPath
* @param {string} distPath
* @returns {string[]}
*/
const getModulePaths = (distPath) => {
return fs
.readdirSync(distPath, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
};
/**
* Resolve a type file within a module dir
* @param {string} distPath
* @param {string[]} candidates
* @param {string} module
* @returns {string | null}
*/
const resolveTypeFile = (distPath, candidates, module) => {
const basePath = path.join(distPath, module);
for (const candidate of candidates) {
const fullPath = path.join(basePath, candidate);
if (fs.existsSync(fullPath)) {
// make path relative to CWD
const rel = path.relative(process.cwd(), fullPath);
return normalizeExportPath(rel);
}
}
return null;
};
/**
* Walk dist folder and find matching patterns
* @param {string} base (distPath)
* @param {string} folderName
* @returns {Array<{name:string, rel:string}>}
*/
const walkForPattern = (base, folderName) => {
/** @type {Array<{name:string, rel:string}>} */
const found = [];
/** @param {string} dir */
const traverse = (dir) => {
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, d.name);
if (d.isDirectory()) {
if (d.name === folderName) {
for (const file of fs
.readdirSync(full)
.filter((f) => f.endsWith('.d.ts'))) {
const name = file.replace(/\.d\.ts$/, '');
const rel = path.relative(base, path.join(full, file));
found.push({ name, rel: rel.replace(/\\/g, '/') });
}
} else {
traverse(full);
}
}
}
};
traverse(base);
return found;
};
/**
* Create exports field
* @param {string} distPath
* @param {string[]} modules
* @param {string[]} candidates
* @param {{pattern:string, folderName:string}[]} patterns
* @param {Record<string, {types:string, import?:string, require?:string, default?:string}>} extraStatic
*/
const createExports = (distPath, modules, candidates, patterns, extraStatic) => {
const distRoot = path.relative(process.cwd(), path.dirname(distPath));
const dtsRoot = path.basename(distPath);
/** @type {Exports} */
const exportsField = {
'./package.json': './package.json',
'.': {
types: normalizeExportPath(path.join(distRoot, dtsRoot, 'index.d.ts')),
import: normalizeExportPath(path.join(distRoot, 'esm', 'index.js')),
require: normalizeExportPath(path.join(distRoot, 'cjs', 'index.js')),
},
...extraStatic,
};
/** @type {[string,string][]} */
const validModules = [];
for (const module of modules) {
const resolved = resolveTypeFile(distPath, candidates, module);
if (resolved) {
exportsField[`./${module}/types`] = {
types: resolved,
default: resolved,
};
validModules.push([module, resolved]);
}
}
/** @type {Array<{name:string,rel:string,pattern:string}>} */
const matchedPatterns = [];
for (const { pattern, folderName } of patterns) {
const matches = walkForPattern(distPath, folderName);
for (const match of matches) {
const typesPath = normalizeExportPath(path.join(distRoot, dtsRoot, match.rel));
const esmPath = normalizeExportPath(
path.join(distRoot, 'esm', match.rel.replace(/\.d\.ts$/, '.js'))
);
const cjsPath = normalizeExportPath(
path.join(distRoot, 'cjs', match.rel.replace(/\.d\.ts$/, '.js'))
);
exportsField[`./${pattern}/${match.name}`] = {
types: typesPath,
import: esmPath,
require: cjsPath,
};
matchedPatterns.push({ ...match, pattern });
}
}
return { exportsField, validModules, matchedPatterns };
};
/**
* Create typesVersions
* @param {string} distPath
* @param {[string,string][]} validModules
* @param {Array<{ name:string, rel:string, pattern:string }>} matchedPatterns
* @param {Record<string, {types:string}>} extraStatic
*/
const createTypesVersions = (distPath, validModules, matchedPatterns, extraStatic) => {
const distRoot = path.relative(process.cwd(), path.dirname(distPath));
const dtsRoot = path.basename(distPath);
/** @type {Record<string, [string]>} */
const versions = {};
for (const [key, value] of Object.entries(extraStatic)) {
if (value.types) {
versions[key.replace(/^\.\//, '')] = [value.types.replace(/^\.\//, '')];
}
}
for (const [module, resolved] of validModules) {
versions[`${module}/types`] = [resolved.replace(/^\.\//, '')];
}
for (const p of matchedPatterns) {
const rel = normalizeExportPath(path.join(distRoot, dtsRoot, p.rel));
versions[`${p.pattern}/${p.name}`] = [rel.replace(/^\.\//, '')];
}
return { '*': versions };
};
/**
* * Updates the `exports` and `typesVersions` fields of your `package.json` based on the generated type declaration files under your distribution folder.
*
* _This helper scans your `dist` (or custom) directory for modules and optionally plugin-like subpaths, then dynamically builds a `package.json` exports map._
*
* ✅ **Features:**
* - Automatically detects `types.d.ts` or `interfaces.d.ts` in module folders.
* - Adds them to `package.json` under `exports` and `typesVersions`.
* - Handles both ESM and CJS entry points.
* - Supports configurable `distPath` and `packageJsonPath`.
*
* @param {FixTypeExportsOptions} [options] - Optional configuration.
*
* @example
* // Using defaults (scans ./dist/dts and updates ./package.json)
* fixTypeExports();
*
* @example
* // Custom dist folder and package.json path
* fixTypeExports({
* distPath: 'build/types',
* packageJsonPath: './pkg/package.json',
* extraPatterns: ['addons', 'plugins']
* });
*
*/
export const fixTypeExports = async (options) => {
const distPath =
options?.distPath ?
path.isAbsolute(options.distPath) ?
options.distPath
: path.resolve(process.cwd(), options.distPath)
: path.resolve(process.cwd(), 'dist/dts');
const packageJsonPath =
options?.packageJsonPath ?
path.isAbsolute(options.packageJsonPath) ?
options.packageJsonPath
: path.resolve(process.cwd(), options.packageJsonPath)
: path.resolve(process.cwd(), 'package.json');
const s = spinner();
s.start(chalk.yellowBright(`Updating 'exports' and 'typeVersions' in ${packageJsonPath}`));
const candidates = options?.typeFileCandidates ?? ['types.d.ts', 'interfaces.d.ts'];
const patterns = options?.extraPatterns ?? [{ pattern: 'plugins', folderName: 'plugins' }];
const extraStatic = options?.extraStatic ?? {};
const modules = getModulePaths(distPath);
const { exportsField, validModules, matchedPatterns } = createExports(
distPath,
modules,
candidates,
patterns,
extraStatic
);
const pkg = parsePackageJson();
pkg.exports = exportsField;
pkg.typesVersions = createTypesVersions(
distPath,
validModules,
matchedPatterns,
extraStatic
);
await writeToPackageJson(pkg);
s.stop(
chalk.greenBright(
chalk.green('✓ ') +
chalk.yellow.bold('package.json ') +
chalk.green.bold('has been updated with ') +
chalk.yellowBright.bold('exports') +
chalk.green(' and ') +
chalk.yellowBright.bold('typesVersions') +
chalk.green(' fields!')
)
);
};