UNPKG

@sanity/pkg-utils

Version:

Simple utilities for modern npm packages.

550 lines (549 loc) 25.5 kB
import { createRequire } from "node:module"; import path, { resolve } from "node:path"; import { register } from "esbuild-register/dist/node"; import pkgUp from "pkg-up"; import findConfig from "find-config"; import { fileExists, isRecord } from "./isRecord.js"; import chalk from "chalk"; import { z, ZodError } from "zod"; import fs from "node:fs/promises"; import browserslist from "browserslist"; import config from "@sanity/browserslist-config"; import { existsSync } from "node:fs"; import ts from "typescript"; import { errorMap } from "zod-validation-error"; const CONFIG_FILE_NAMES = [ "package.config.ts", "package.config.js", "package.config.cjs", "package.config.mjs" ]; function findConfigFile(cwd) { const pkgJsonPath = findConfig("package.json", { cwd }); if (!pkgJsonPath) return; const pkgPath = path.dirname(pkgJsonPath); for (const fileName of CONFIG_FILE_NAMES) { const configPath = path.resolve(pkgPath, fileName); if (fileExists(configPath)) return configPath; } } const require2 = createRequire(import.meta.url); async function loadConfig(options) { const { cwd } = options, pkgPath = await pkgUp({ cwd }); if (!pkgPath) return; const root = path.dirname(pkgPath), configFile = await findConfigFile(root); if (!configFile || !configFile.startsWith(cwd)) return; const esbuildOptions = { extensions: [".js", ".mjs", ".ts"] }, { unregister } = globalThis.__DEV__ ? { unregister: () => { } } : register(esbuildOptions), mod = require2(configFile); return unregister(), mod?.default || mod || void 0; } function assertLast(a, arr) { const aIdx = arr.indexOf(a); return aIdx === -1 ? !0 : aIdx === arr.length - 1; } function assertOrder(a, b, arr) { const aIdx = arr.indexOf(a), bIdx = arr.indexOf(b); return aIdx === -1 || bIdx === -1 ? !0 : aIdx < bIdx; } const pkgSchema = z.object({ type: z.optional(z.enum(["commonjs", "module"])), name: z.string(), version: z.string(), license: z.string(), bin: z.optional(z.record(z.string())), dependencies: z.optional(z.record(z.string())), devDependencies: z.optional(z.record(z.string())), peerDependencies: z.optional(z.record(z.string())), source: z.optional(z.string()), main: z.optional(z.string()), browser: z.optional(z.record(z.string())), module: z.optional(z.string()), types: z.optional(z.string()), exports: z.optional( z.record( z.union([ z.custom((val) => /^\.\/.*\.json$/.test(val)), z.custom((val) => /^\.\/.*\.css$/.test(val)), z.object({ types: z.optional(z.string()), source: z.optional(z.string()), browser: z.optional( z.object({ source: z.string(), import: z.optional(z.string()), require: z.optional(z.string()) }) ), node: z.optional( z.object({ source: z.optional(z.string()), import: z.optional(z.string()), require: z.optional(z.string()) }) ), import: z.optional(z.string()), require: z.optional(z.string()), default: z.string() }), z.object({ types: z.optional(z.string()), svelte: z.string(), default: z.optional(z.string()) }) ]) ) ), browserslist: z.optional(z.union([z.string(), z.array(z.string())])), sideEffects: z.optional(z.union([z.boolean(), z.array(z.string())])), // @TODO type this properly typesVersions: z.optional(z.any()) }), typoMap = /* @__PURE__ */ new Map(); for (const key of pkgSchema.keyof()._def.values) typoMap.set(key.toUpperCase(), key); function validatePkg(input) { const pkg = pkgSchema.parse(input), invalidKey = Object.keys(input).find((key) => { const needle = key.toUpperCase(); return typoMap.has(needle) ? typoMap.get(needle) !== key : !1; }); if (invalidKey) throw new TypeError( ` - package.json: "${invalidKey}" is not a valid key. Did you mean "${typoMap.get(invalidKey.toUpperCase())}"?` ); return pkg; } async function loadPkg(options) { const { cwd } = options, pkgPath = await pkgUp({ cwd }); if (!pkgPath) throw new Error("no package.json found"); const buf = await fs.readFile(pkgPath), raw = JSON.parse(buf.toString()); return validatePkg(raw), raw; } async function loadPkgWithReporting(options) { const { cwd, logger, strict } = options; try { const pkg = await loadPkg({ cwd }); let shouldError = !1; if (strict && (pkg.type || (shouldError = !0, logger.error( 'the `type` field in `./package.json` must be either "module" or "commonjs")' ))), pkg.exports) { const _exports = Object.entries(pkg.exports); for (const [expPath, exp] of _exports) { if (typeof exp == "string" || "svelte" in exp) continue; const keys = Object.keys(exp); exp.types && (shouldError = !0, logger.error( `exports["${expPath}"]: the \`types\` condition shouldn't be used as dts files are generated in such a way that both CJS and ESM is supported` )), exp.module && (shouldError = !0, logger.error( `exports["${expPath}"]: the \`module\` condition shouldn't be used as it's not well supported in all bundlers.` )), exp.node ? (exp.import && exp.node.import && !assertOrder("node", "import", keys) && (shouldError = !0, logger.error( `exports["${expPath}"]: the \`node\` property should come before the \`import\` property` )), exp.node.module && (shouldError = !0, logger.error( `exports["${expPath}"]: the \`node.module\` condition shouldn't be used as it's not well supported in all bundlers. A better strategy is to refactor the codebase to no longer be vulnerable to the "dual package hazard"` )), !exp.node.source && exp.node.import && (exp.node.require || exp.require) && (exp.node.import.endsWith(".cjs.js") || exp.node.import.endsWith(".cjs.mjs")) && (shouldError = !0, logger.error( `exports["${expPath}"]: the \`node.import\` re-export pattern shouldn't be used as it's not well supported in all bundlers. A better strategy is to refactor the codebase to no longer be vulnerable to the "dual package hazard"` )), exp.require && exp.node.require && exp.require === exp.node.require ? (shouldError = !0, logger.error( `exports["${expPath}"]: the \`node.require\` property isn't necessary as it's identical to \`require\`` )) : exp.require && exp.node.require && !assertOrder("node", "require", keys) && (shouldError = !0, logger.error( `exports["${expPath}"]: the \`node\` property should come before the \`require\` property` ))) : assertOrder("import", "require", keys) || logger.warn( `exports["${expPath}"]: the \`import\` property should come before the \`require\` property` ), assertLast("default", keys) || (shouldError = !0, logger.error( `exports["${expPath}"]: the \`default\` property should be the last property` )); } } return shouldError && process.exit(1), pkg; } catch (err) { if (err instanceof ZodError) for (const issue of err.issues) { if (issue.code === "invalid_type") { logger.error( [ `\`${formatPath(issue.path)}\` `, `in \`./package.json\` must be of type ${chalk.magenta(issue.expected)} `, `(received ${chalk.magenta(issue.received)})` ].join("") ); continue; } logger.error(issue); } else logger.error(err); process.exit(1); } } function formatPath(segments) { return segments.map((s, idx) => idx === 0 ? s : typeof s == "number" ? `[${s}]` : s.startsWith(".") ? `["${s}"]` : `.${s}`).join(""); } function browserslistToEsbuild(browserslistConfig, options = {}) { if (!browserslistConfig) { const path2 = process.cwd(); browserslistConfig = browserslist.loadConfig({ path: path2, ...options }); } const SUPPORTED_ESBUILD_TARGETS = [ "es", "chrome", "edge", "firefox", "ios", "node", "safari", "opera", "ie" ], UNSUPPORTED = ["android 4"], replaces = { ios_saf: "ios", android: "chrome" }, separator = " "; return browserslist(browserslistConfig, options).filter((b) => !UNSUPPORTED.some((u) => b.startsWith(u))).map((b) => b === "safari TP" ? browserslist("last 1 safari version")[0] : b).map((b) => b.split(separator)).map((b) => (replaces[b[0]] && (b[0] = replaces[b[0]]), b)).map((b) => (b[1].includes("-") && (b[1] = b[1].slice(0, b[1].indexOf("-"))), b)).map((b) => (b[1].endsWith(".0") && (b[1] = b[1].slice(0, -2)), b)).filter((b) => /^\d+(\.\d+)*$/.test(b[1])).filter((b) => SUPPORTED_ESBUILD_TARGETS.includes(b[0])).reduce((acc, b) => { const existingIndex = acc.findIndex((br) => br[0] === b[0]); return existingIndex !== -1 ? acc[existingIndex][1] = b[1] : acc.push(b), acc; }, []).map((b) => b.join("")); } function resolveConfigProperty(prop, initialValue) { return prop ? typeof prop == "function" ? prop(initialValue) : prop : initialValue; } const DEFAULT_BROWSERSLIST_QUERY = config; function pathContains(containerPath, itemPath) { return !path.relative(containerPath, itemPath).startsWith(".."); } function findCommonDirPath(filePaths) { let ret; for (const filePath of filePaths) { let dirPath = path.dirname(filePath); if (!ret) { ret = dirPath; continue; } for (; dirPath !== ret && (dirPath = path.dirname(dirPath), dirPath !== ret); ) { if (pathContains(dirPath, ret)) { ret = dirPath; break; } if (dirPath === ".") return; } } return ret; } const fileEnding = /\.[mc]?js$/, dtsEnding = ".d.ts", defaultEnding = ".js", mjsEnding = ".mjs", cjsEnding = ".cjs", mtsEnding = ".d.mts", ctsEnding = ".d.cts"; function getTargetPaths(_type, expOrBundle) { const type = _type === "module" ? "module" : "commonjs", set = /* @__PURE__ */ new Set(); return expOrBundle?.import && set.add(expOrBundle.import.replace(fileEnding, type === "module" ? dtsEnding : mtsEnding)), expOrBundle?.require && set.add(expOrBundle.require.replace(fileEnding, type === "commonjs" ? dtsEnding : ctsEnding)), isPkgExport$1(expOrBundle) && (expOrBundle.browser?.source || (expOrBundle.browser?.import && set.add( expOrBundle.browser.import.replace(fileEnding, type === "module" ? dtsEnding : mtsEnding) ), expOrBundle.browser?.require && set.add( expOrBundle.browser.require.replace( fileEnding, type === "commonjs" ? dtsEnding : ctsEnding ) )), expOrBundle.node?.source || (expOrBundle.node?.import && set.add( expOrBundle.node.import.replace(fileEnding, type === "module" ? dtsEnding : mtsEnding) ), expOrBundle.node?.require && set.add( expOrBundle.node.require.replace(fileEnding, type === "commonjs" ? dtsEnding : ctsEnding) ))), Array.from(set); } function isPkgExport$1(exp) { return exp?.browser || exp?.node || exp?.default; } const pkgExtMap = { // pkg.type: "commonjs" commonjs: { commonjs: defaultEnding, esm: mjsEnding }, // pkg.type: "module" module: { commonjs: cjsEnding, esm: defaultEnding } }; function validateExports(_exports, options) { const { pkg } = options, type = pkg.type || "commonjs", ext = pkgExtMap[type], errors = []; for (const exp of _exports) exp.require && !exp.require.endsWith(ext.commonjs) && errors.push( `package.json with \`type: "${type}"\` - \`exports["${exp._path}"].require\` must end with "${ext.commonjs}"` ), exp.import && !exp.import.endsWith(ext.esm) && errors.push( `package.json with \`type: "${type}"\` - \`exports["${exp._path}"].import\` must end with "${ext.esm}"` ); return errors; } function parseExports(options) { const { cwd, pkg, strict, strictOptions: strictOptions2, logger } = options, type = pkg.type || "commonjs", errors = [], report = (kind, message) => { kind === "warn" ? logger.warn(message) : errors.push(message); }; if (!pkg.main && strict && strictOptions2.alwaysPackageJsonMain !== "off" && report(strictOptions2.alwaysPackageJsonMain, "package.json: `main` must be declared"), !Array.isArray(pkg.files) && strict && strictOptions2.alwaysPackageJsonFiles !== "off" && report( strictOptions2.alwaysPackageJsonFiles, "package.json: `files` should be used over `.npmignore`" ), pkg.source) { if (strict && pkg.exports?.["."] && typeof pkg.exports["."] == "object" && "source" in pkg.exports["."] && pkg.exports["."].source === pkg.source) errors.push( 'package.json: the "source" property can be removed, as it is equal to exports["."].source.' ); else if (!pkg.exports && pkg.main) { const extMap = pkgExtMap[type], importExport = pkg.main.replace(fileEnding, extMap.esm), requireExport = pkg.main.replace(fileEnding, extMap.commonjs), defaultExport = pkg.main.replace(fileEnding, defaultEnding), maybeBrowserCondition = []; if (pkg.browser) { const browserConditions = []; pkg.module && pkg.browser?.[pkg.module] ? browserConditions.push( ` "import": ${JSON.stringify(pkg.browser[pkg.module].replace(fileEnding, extMap.esm))}` ) : pkg.browser?.[pkg.main] && browserConditions.push( ` "import": ${JSON.stringify(pkg.browser[pkg.main].replace(fileEnding, extMap.esm))}` ), pkg.browser?.[pkg.main] && browserConditions.push( ` "require": ${JSON.stringify(pkg.browser[pkg.main].replace(fileEnding, extMap.commonjs))}` ), browserConditions.length && maybeBrowserCondition.push( ' "browser": {', ` "source": ${JSON.stringify(pkg.browser?.[pkg.source] || pkg.source)},`, ...browserConditions, " }" ); } errors.push( ...[ "package.json: `exports` are missing, it should be:", '"exports": {', ' ".": {', ` "source": ${JSON.stringify(pkg.source)},`, // If browser conditions are detected then add them to the suggestion ...maybeBrowserCondition.length > 0 ? maybeBrowserCondition : [], type === "commonjs" && ` "import": ${JSON.stringify(importExport)},`, type === "module" && ` "require": ${JSON.stringify(requireExport)},`, ` "default": ${JSON.stringify(defaultExport)}`, " },", ' "./package.json": "./package.json"', "}" ].filter(Boolean) ); } } if (errors.length) throw new Error(` - ` + errors.join(` - `)); if (!pkg.exports) throw new Error( ` - ` + [ "package.json: `exports` are missing, please set a minimal configuration, for example:", '"exports": {', ' ".": {', ' "source": "./src/index.js",', ' "default": "./dist/index.js"', " },", ' "./package.json": "./package.json"', "}" ].join(` - `) ); const _exports = []; strict && strictOptions2.noPackageJsonTypings !== "off" && "typings" in pkg && report(strictOptions2.noPackageJsonTypings, "package.json: `typings` should be `types`"), strict && strictOptions2.alwaysPackageJsonTypes !== "off" && !pkg.types && typeof pkg.exports?.["."] == "object" && "source" in pkg.exports["."] && pkg.exports["."].source?.endsWith(".ts") && report( strictOptions2.alwaysPackageJsonTypes, "package.json: `types` must be declared for the npm listing to show as a TypeScript module." ), strict && !pkg.exports["./package.json"] && errors.push('package.json: `exports["./package.json"] must be declared.'); for (const [exportPath, exportEntry] of Object.entries(pkg.exports)) if (exportPath.endsWith(".json") || typeof exportEntry == "string" && exportEntry.endsWith(".json")) exportPath === "./package.json" && exportEntry !== "./package.json" && errors.push('package.json: `exports["./package.json"]` must be "./package.json".'); else if (exportPath.endsWith(".css")) typeof exportEntry == "string" && !existsSync(resolve(cwd, exportEntry)) ? errors.push( `package.json: \`exports[${JSON.stringify(exportPath)}]\`: file does not exist.` ) : typeof exportEntry != "string" && errors.push( `package.json: \`exports[${JSON.stringify(exportPath)}]\`: export conditions not supported for CSS files.` ); else if (!(isRecord(exportEntry) && "svelte" in exportEntry)) if (isPkgExport(exportEntry)) { const exp = { _exported: !0, _path: exportPath, ...exportEntry }; if (!exp.default) { const fallback = type === "module" ? exp.import : exp.require; fallback && (exp.default = fallback); } !exp.require && type === "commonjs" && exp.default && (exp.require = exp.default), !exp.import && type === "module" && exp.default && (exp.import = exp.default), exportPath === "." && (exportEntry.require && pkg.main && exportEntry.require !== pkg.main && errors.push( 'package.json: mismatch between "main" and "exports.require". These must be equal.' ), exportEntry.import && pkg.module && exportEntry.import !== pkg.module && errors.push( 'package.json: mismatch between "module" and "exports.import" These must be equal.' )), _exports.push(exp); } else isRecord(exportEntry) || errors.push("package.json: exports must be an object"); if (errors.push(...validateExports(_exports, { pkg })), errors.length) throw new Error(` - ` + errors.join(` - `)); return _exports; } function isPkgExport(value) { return isRecord(value) && "source" in value && typeof value.source == "string"; } async function loadTSConfig(options) { const { cwd, tsconfigPath } = options, configPath = ts.findConfigFile(cwd, ts.sys.fileExists, tsconfigPath); if (!configPath) return; const configFile = ts.readConfigFile(configPath, ts.sys.readFile); return ts.parseJsonConfigFileContent(configFile.config, ts.sys, cwd); } function resolveBrowserTarget(versions) { const target = versions.filter( (version) => version.startsWith("chrome") || version.startsWith("edge") || version.startsWith("firefox") || version.startsWith("ios") || version.startsWith("safari") || version.startsWith("opera") ); if (target.length !== 0) return target; } function resolveNodeTarget(versions) { const target = versions.filter((version) => version.startsWith("node")); if (target.length !== 0) return target; } const toggle = z.union([z.literal("error"), z.literal("warn"), z.literal("off")]), strictOptions = z.object({ noPackageJsonTypings: toggle.default("error"), noImplicitSideEffects: toggle.default("warn"), noImplicitBrowsersList: toggle.default("warn"), alwaysPackageJsonTypes: toggle.default("error"), alwaysPackageJsonMain: toggle.default("error"), alwaysPackageJsonFiles: toggle.default("error"), noCheckTypes: toggle.default("warn") }).strict(), validationSchema = z.object({ strictOptions: strictOptions.default({}) }); function parseStrictOptions(input) { return validationSchema.parse({ strictOptions: input }, { errorMap }).strictOptions; } async function resolveBuildContext(options) { const { config: config2, cwd, emitDeclarationOnly = !1, logger, pkg, strict, tsconfig: tsconfigPath } = options, tsconfig = await loadTSConfig({ cwd, tsconfigPath }), strictOptions2 = parseStrictOptions(config2?.strictOptions ?? {}); if (strictOptions2.noCheckTypes !== "off" && tsconfig?.options && config2?.dts !== "rolldown" && tsconfig.options.noCheck !== !1 && !tsconfig.options.noCheck) { if (strictOptions2.noCheckTypes === "error") throw new Error( "`noCheck` is not set to `true` in the tsconfig.json file used by `package.config.ts`. This makes generating dts files slower than it needs to be, as it will perform type checking on the dts files while at it." ); logger.warn( "`noCheck` is not set to `true` in the tsconfig.json file used by `package.config.ts`. This makes generating dts files slower than it needs to be, as it will perform type checking on the dts files while at it." ); } let browserslist2 = pkg.browserslist; if (!browserslist2) { if (strict && strictOptions2.noImplicitBrowsersList !== "off") { if (strictOptions2.noImplicitBrowsersList === "error") throw new Error( '\n- package.json: "browserslist" is missing, set it to `"browserslist": "extends @sanity/browserslist-config"`' ); logger.warn( 'Could not detect a `browserslist` property in `package.json`, using default configuration. Add `"browserslist": "extends @sanity/browserslist-config"` to silence this warning.' ); } browserslist2 = DEFAULT_BROWSERSLIST_QUERY; } const targetVersions = browserslistToEsbuild(browserslist2); if (strict && strictOptions2.noImplicitSideEffects !== "off" && typeof pkg.sideEffects > "u") { const msg = "package.json: `sideEffects` is missing, see https://webpack.js.org/guides/tree-shaking/#clarifying-tree-shaking-and-sideeffects for how to define `sideEffects`"; if (strictOptions2.noImplicitSideEffects === "error") throw new Error(msg); logger.warn(msg); } const nodeTarget = resolveNodeTarget(targetVersions), webTarget = resolveBrowserTarget(targetVersions); if (!nodeTarget) throw new Error("no matching `node` target"); if (!webTarget) throw new Error("no matching `web` target"); const target = { "*": webTarget.concat(nodeTarget), browser: webTarget, node: nodeTarget }, parsedExports = parseExports({ cwd, pkg, strict, strictOptions: strictOptions2, logger }).reduce( (acc, { _path: exportPath, ...exportEntry }) => Object.assign(acc, { [exportPath]: exportEntry }), {} ), exports = resolveConfigProperty(config2?.exports, parsedExports), parsedExternal = [ ...pkg.dependencies ? Object.keys(pkg.dependencies) : [], ...pkg.peerDependencies ? Object.keys(pkg.peerDependencies) : [] ], external = config2 && Array.isArray(config2.external) ? [...parsedExternal, ...config2.external] : resolveConfigProperty(config2?.external, parsedExternal), externalWithTypes = /* @__PURE__ */ new Set([pkg.name, ...external, ...external.map(transformPackageName)]), bundledDependencies = (pkg.devDependencies ? Object.keys(pkg.devDependencies) : []).filter( // Do not bundle anything that is marked as external (_) => !externalWithTypes.has(_) ), bundledPackages = config2 && Array.isArray(config2.extract?.bundledPackages) ? [...bundledDependencies, ...config2.extract.bundledPackages] : resolveConfigProperty(config2?.extract?.bundledPackages, bundledDependencies), outputPaths = Object.values(exports).flatMap((exportEntry) => [ exportEntry.import, exportEntry.require, exportEntry.browser?.import, exportEntry.browser?.require, exportEntry.node?.source && exportEntry.node.import, exportEntry.node?.source && exportEntry.node.require ].filter(Boolean)).map((p) => path.resolve(cwd, p)), commonDistPath = findCommonDirPath(outputPaths); if (commonDistPath === cwd) throw new Error( "all output files must share a common parent directory which is not the root package directory" ); if (commonDistPath && !pathContains(cwd, commonDistPath)) throw new Error("all output files must be located within the package"); const configDistPath = config2?.dist ? path.resolve(cwd, config2.dist) : void 0; if (configDistPath && commonDistPath && configDistPath !== commonDistPath && !pathContains(configDistPath, commonDistPath)) throw logger.log(`did you mean to configure \`dist: './${path.relative(cwd, commonDistPath)}'\`?`), new Error("all output files must be located with the configured `dist` path"); const distPath = configDistPath || commonDistPath; if (!distPath) throw new Error("could not detect `dist` path"); return { config: config2, cwd, distPath, emitDeclarationOnly, exports, external, bundledPackages, files: [], logger, pkg, runtime: config2?.runtime ?? "*", strict, target, ts: { config: tsconfig, configPath: tsconfigPath }, dts: config2?.dts === "rolldown" ? "rolldown" : "api-extractor" }; } function transformPackageName(packageName) { if (packageName.startsWith("@types/")) return packageName; if (packageName.startsWith("@")) { const [scope, name] = packageName.split("/"); return `@types/${scope?.slice(1)}__${name}`; } else return `@types/${packageName}`; } function createConsoleSpy(options) { const { onRestored } = options || {}, original = { log: console.log, warn: console.warn, error: console.error }, messages = []; return console.log = (...args) => messages.push({ type: "log", args }), console.warn = (...args) => messages.push({ type: "warn", args }), console.error = (...args) => messages.push({ type: "error", args }), { messages, restore: () => { console.log = original.log, console.warn = original.warn, console.error = original.error, onRestored?.(messages); } }; } export { DEFAULT_BROWSERSLIST_QUERY, createConsoleSpy, getTargetPaths, loadConfig, loadPkg, loadPkgWithReporting, parseExports, parseStrictOptions, pkgExtMap, resolveBuildContext, resolveConfigProperty }; //# sourceMappingURL=consoleSpy.js.map