From 6d68b9b551b67e27df62f89ffaf52ff97cead97c Mon Sep 17 00:00:00 2001 From: fraxken Date: Mon, 26 May 2025 21:55:48 +0200 Subject: [PATCH] feat(mama): implement moduleType getter along with inspectModuleType fn --- .changeset/crazy-drinks-sleep.md | 5 + workspaces/mama/README.md | 8 ++ workspaces/mama/src/ManifestManager.class.ts | 9 +- workspaces/mama/src/index.ts | 4 +- workspaces/mama/src/utils/index.ts | 1 + .../mama/src/utils/inspectModuleType.ts | 136 ++++++++++++++++++ workspaces/mama/test/ManifestManager.spec.ts | 9 ++ .../mama/test/inspectModuleType.spec.ts | 127 ++++++++++++++++ 8 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 .changeset/crazy-drinks-sleep.md create mode 100644 workspaces/mama/src/utils/inspectModuleType.ts create mode 100644 workspaces/mama/test/inspectModuleType.spec.ts diff --git a/.changeset/crazy-drinks-sleep.md b/.changeset/crazy-drinks-sleep.md new file mode 100644 index 00000000..766b3356 --- /dev/null +++ b/.changeset/crazy-drinks-sleep.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/mama": minor +--- + +implement Manifest module type detection (with cjs, esm, dual, faux esm and dts) diff --git a/workspaces/mama/README.md b/workspaces/mama/README.md index db03b146..9dacc283 100644 --- a/workspaces/mama/README.md +++ b/workspaces/mama/README.md @@ -65,6 +65,14 @@ Default values are injected if they are not present in the document. This behavi ### getEntryFiles(): IterableIterator< string > Deeply extract entry files from package `main` and Node.js `exports` fields. +### moduleType + +Return the type of the module + +```ts +type PackageModuleType = "dts" | "faux" | "dual" | "esm" | "cjs"; +``` + ### spec Return the NPM specification (which is the combinaison of `name@version`). diff --git a/workspaces/mama/src/ManifestManager.class.ts b/workspaces/mama/src/ManifestManager.class.ts index 04cd7eb7..49a4516d 100644 --- a/workspaces/mama/src/ManifestManager.class.ts +++ b/workspaces/mama/src/ManifestManager.class.ts @@ -12,7 +12,10 @@ import type { } from "@nodesecure/npm-types"; // Import Internal Dependencies -import { packageJSONIntegrityHash } from "./utils/index.js"; +import { + packageJSONIntegrityHash, + inspectModuleType +} from "./utils/index.js"; type WithRequired = T & { [P in K]-?: T[P] }; @@ -88,6 +91,10 @@ export class ManifestManager< .some((script) => kUnsafeNPMScripts.has(script.toLowerCase())); } + get moduleType() { + return inspectModuleType(this.document); + } + get hasZeroSemver() { if (typeof this.document.version === "string") { return /^0(\.\d+)*$/ diff --git a/workspaces/mama/src/index.ts b/workspaces/mama/src/index.ts index d6c3c052..5f645f8a 100644 --- a/workspaces/mama/src/index.ts +++ b/workspaces/mama/src/index.ts @@ -1,4 +1,6 @@ export * from "./ManifestManager.class.js"; export { - packageJSONIntegrityHash + packageJSONIntegrityHash, + inspectModuleType, + type PackageModuleType } from "./utils/index.js"; diff --git a/workspaces/mama/src/utils/index.ts b/workspaces/mama/src/utils/index.ts index f99c4ab7..3586f5a1 100644 --- a/workspaces/mama/src/utils/index.ts +++ b/workspaces/mama/src/utils/index.ts @@ -1 +1,2 @@ export * from "./integrity-hash.js"; +export * from "./inspectModuleType.js"; diff --git a/workspaces/mama/src/utils/inspectModuleType.ts b/workspaces/mama/src/utils/inspectModuleType.ts new file mode 100644 index 00000000..e4e5ccab --- /dev/null +++ b/workspaces/mama/src/utils/inspectModuleType.ts @@ -0,0 +1,136 @@ +// Ported and modified from: https://github.com/wooorm/npm-esm-vs-cjs/blob/main/script/crawl.js +// AND https://github.com/antfu/node-modules-inspector/blob/main/packages/node-modules-tools/src/analyze-esm.ts#L8 +// Copyright (c) Titus Wormer +// MIT Licensed + +// Import Third-party Dependencies +import type { + PackageJSON, + WorkspacesPackageJSON, + PackumentVersion +} from "@nodesecure/npm-types"; + +export type PackageModuleType = "dts" | "faux" | "dual" | "esm" | "cjs"; + +export function inspectModuleType( + packageJson: PackageJSON | WorkspacesPackageJSON | PackumentVersion +): PackageModuleType { + // We aggressively assume `@types/` are all type-only packages. + if (packageJson.name?.startsWith("@types/")) { + return "dts"; + } + + const { exports, main, type } = packageJson; + let cjs: boolean | undefined; + let esm: boolean | undefined; + const fauxEsm = Boolean(packageJson.module); + + // Check exports map. + if (exports && typeof exports === "object") { + for (const exportId in exports) { + if (Object.hasOwn(exports, exportId) && typeof exportId === "string") { + const value = exports[exportId]; + analyzeThing(value, `${packageJson.name}#exports`); + } + } + } + + // Explicit `commonjs` set, with a explicit `import` or `.mjs` too. + if (esm && type === "commonjs") { + cjs = true; + } + + // Explicit `module` set, with explicit `require` or `.cjs` too. + if (cjs && type === "module") { + esm = true; + } + + // If there are no explicit exports: + if (cjs === undefined && esm === undefined) { + if (type === "module" || (main && /\.mjs$/.test(main))) { + esm = true; + } + else if (main) { + cjs = true; + } + // If main is not yet, it might be a type only/cli only package. + } + + if (esm && cjs) { + return "dual"; + } + if (esm) { + return "esm"; + } + if (fauxEsm) { + return "faux"; + } + if (!esm && !cjs && !packageJson.main && !packageJson.exports && packageJson.types) { + return "dts"; + } + + return "cjs"; + + function analyzeThing(value: any, path: string): void { + if (value && typeof value === "object") { + if (Array.isArray(value)) { + const values = value; + let index = -1; + while (++index < values.length) { + analyzeThing(values[index], `${path}[${index}]`); + } + } + else { + let dots = false; + const record = value; + for (const [key, subvalue] of Object.entries(value)) { + if (key.charAt(0) !== ".") { + break; + } + analyzeThing(subvalue, `${path}["${key}"]`); + dots = true; + } + + if (dots) { + return; + } + + let explicit = false; + const conditionImport = Boolean("import" in record && record.import); + const conditionRequire = Boolean("require" in record && record.require); + const conditionDefault = Boolean("default" in record && record.default); + + if (conditionImport || conditionRequire) { + explicit = true; + } + + if (conditionImport || (conditionRequire && conditionDefault)) { + esm = true; + } + + if (conditionRequire || (conditionImport && conditionDefault)) { + cjs = true; + } + + const defaults = record.node || record.default; + + if (typeof defaults === "string" && !explicit) { + if (/\.mjs$/.test(defaults)) { + esm = true; + } + if (/\.cjs$/.test(defaults)) { + cjs = true; + } + } + } + } + else if (typeof value === "string") { + if (/\.mjs$/.test(value)) { + esm = true; + } + if (/\.cjs$/.test(value)) { + cjs = true; + } + } + } +} diff --git a/workspaces/mama/test/ManifestManager.spec.ts b/workspaces/mama/test/ManifestManager.spec.ts index f0010810..f2df8daa 100644 --- a/workspaces/mama/test/ManifestManager.spec.ts +++ b/workspaces/mama/test/ManifestManager.spec.ts @@ -138,6 +138,15 @@ describe("ManifestManager", () => { }); }); + describe("get moduleType", () => { + test("return cjs for a minimal PackageJSON", () => { + const mama = new ManifestManager({ + ...kMinimalPackageJSON + }); + assert.deepEqual(mama.moduleType, "cjs"); + }); + }); + describe("get dependencies", () => { test("Given a PackageJSON with no dependencies, it must return an empty Array", () => { const packageJSON: PackageJSON = { diff --git a/workspaces/mama/test/inspectModuleType.spec.ts b/workspaces/mama/test/inspectModuleType.spec.ts new file mode 100644 index 00000000..7ec2e1d9 --- /dev/null +++ b/workspaces/mama/test/inspectModuleType.spec.ts @@ -0,0 +1,127 @@ +// Import Node.js Dependencies +import assert from "node:assert"; +import { describe, test } from "node:test"; + +// Import Third-party Dependencies +import type { + PackageJSON +} from "@nodesecure/npm-types"; + +// Import Internal Dependencies +import { inspectModuleType } from "../src/utils/index.js"; + +// CONSTANTS +const kMinimalPackageJSON = { + name: "foobar", + version: "1.0.0" +} as const; + +describe("inspectModuleType", () => { + test("package with the absolute minimal properties must return 'cjs' by default", () => { + assert.strictEqual( + inspectModuleType(kMinimalPackageJSON), + "cjs" + ); + }); + + test("package name starting with @types should be detected as dts", () => { + const packageJSON: PackageJSON = { + ...kMinimalPackageJSON, + name: "@types/foobar" + }; + + assert.strictEqual( + inspectModuleType(packageJSON), + "dts" + ); + }); + + test("package with absolutely no exports defined but types is should return 'dts'", () => { + const packageJSON: PackageJSON = { + ...kMinimalPackageJSON, + types: "./index.d.ts" + }; + + assert.strictEqual( + inspectModuleType(packageJSON), + "dts" + ); + }); + + test("package with type equal 'commonjs' should return 'cjs'", () => { + const packageJSON: PackageJSON = { + ...kMinimalPackageJSON, + type: "commonjs" + }; + + assert.strictEqual( + inspectModuleType(packageJSON), + "cjs" + ); + }); + + test("package with type equal 'module' should return 'esm'", () => { + const packageJSON: PackageJSON = { + ...kMinimalPackageJSON, + type: "module" + }; + + assert.strictEqual( + inspectModuleType(packageJSON), + "esm" + ); + }); + + test("package with a main containing a .mjs file should return 'esm'", () => { + const packageJSON: PackageJSON = { + ...kMinimalPackageJSON, + main: "./index.mjs" + }; + + assert.strictEqual( + inspectModuleType(packageJSON), + "esm" + ); + }); + + test("package with a main not containing .mjs file should return cjs", () => { + const packageJSON: PackageJSON = { + ...kMinimalPackageJSON, + main: "./index.js" + }; + + assert.strictEqual( + inspectModuleType(packageJSON), + "cjs" + ); + }); + + test("package with legacy module property set should return 'faux'", () => { + const packageJSON: PackageJSON = { + ...kMinimalPackageJSON, + module: true + }; + + assert.strictEqual( + inspectModuleType(packageJSON), + "faux" + ); + }); + + test("package with dual CJS & ESM exports must return 'dual'", () => { + const packageJSON: PackageJSON = { + ...kMinimalPackageJSON, + exports: { + ".": { + require: "./dist/index.cjs", + import: "./dist/index.js" + } + } + }; + + assert.strictEqual( + inspectModuleType(packageJSON), + "dual" + ); + }); +});