Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/versioning-and-exports.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ CJS:
const { parse } = require("@cldmv/jsonv/2021");
```

`require()` is synchronous and returns the same exports as `import` (existing `await require(...)` code keeps working). It loads the ESM build through Node's `require(esm)`, so it needs Node.js ^20.19.0 or >=22.12.0; on older Node.js, use `import()`.

Root alias:
```js
import { parse } from "@cldmv/jsonv"; // latest year
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@
"build:plugin": "node scripts/build-plugin.mjs",
"build:types": "tsc --project .configs/tsconfig.types.json",
"build:ci": "npm run lint --if-present && npm run format:check --if-present && npm run build",
"test": "node tests/run-vitest.mjs",
"test": "node tests/run-vitest.mjs && npm run test:cjs",
"test:cjs": "npm run build && node --test tests/cjs/entry.test.cjs",
"test:watch": "vitest --config .configs/vitest.config.mjs",
"test:types": "tsc --project .configs/tsconfig.build.json --noEmit",
"coverage": "node tests/run-vitest.mjs --coverage-quiet",
"coverage": "node tests/run-vitest.mjs --coverage-quiet && npm run test:cjs",
"ci:coverage": "npm run coverage",
"lint": "eslint --config .configs/eslint.config.v9.mjs src tests",
"lint:v8": "set ESLINT_USE_FLAT_CONFIG=true&& eslint --config .configs/eslint.config.mjs src tests",
Expand Down
164 changes: 62 additions & 102 deletions scripts/build-cjs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
*/

/**
* Build CJS wrappers for CommonJS compatibility
* Creates .cjs files that load the ESM modules
* Build CJS wrappers for CommonJS compatibility.
*
* Each .cjs file is a thin wrapper that loads its ESM counterpart through Node's
* synchronous require(esm), so `require("@cldmv/jsonv")` returns the same module
* namespace object that `import` gives. The ESM graph therefore must not use
* top-level await (require(esm) rejects it with ERR_REQUIRE_ASYNC_MODULE).
*/

import { mkdirSync, writeFileSync, existsSync, readdirSync } from "fs";
import { mkdirSync, writeFileSync, readdirSync, rmSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";

Expand All @@ -27,127 +31,83 @@ const __dirname = dirname(__filename);
const rootDir = join(__dirname, "..");
const distDir = join(rootDir, "dist");
const cjsDir = join(distDir, "cjs");

console.log("\nπŸ“¦ Building CJS wrappers...\n");

// Ensure CJS directory exists
if (!existsSync(cjsDir)) {
mkdirSync(cjsDir, { recursive: true });
}

// Create CJS loader that dynamically imports ESM
const loaderContent = `/**
* CommonJS loader for @cldmv/jsonv
* Dynamically imports ESM modules
*/

module.exports = (async () => {
const esm = await import('../index.mjs');
return esm;
})();
`;

const loaderFile = join(cjsDir, "loader.cjs");
writeFileSync(loaderFile, loaderContent, "utf8");
console.log(`βœ“ Created CJS loader β†’ ${loaderFile}`);

// Create main CJS entry point
const indexContent = `/**
* CommonJS entry point for @cldmv/jsonv
*/

const loader = require('./loader.cjs');

module.exports = loader;
`;

const indexFile = join(cjsDir, "index.cjs");
writeFileSync(indexFile, indexContent, "utf8");
console.log(`βœ“ Created CJS index β†’ ${indexFile}`);

// Prepare CJS types directory
const cjsTypesDir = join(distDir, "types", "cjs");
if (!existsSync(cjsTypesDir)) {
mkdirSync(cjsTypesDir, { recursive: true });
}

// Create CJS wrappers for year modules
const cjsYearsDir = join(cjsDir, "years");
const cjsTypesDir = join(distDir, "types", "cjs");
const cjsYearsTypesDir = join(cjsTypesDir, "years");

if (!existsSync(cjsYearsDir)) {
mkdirSync(cjsYearsDir, { recursive: true });
}
if (!existsSync(cjsYearsTypesDir)) {
mkdirSync(cjsYearsTypesDir, { recursive: true });
}
console.log("\nπŸ“¦ Building CJS wrappers...\n");

// Get all year .mjs files from dist/years/
const distYearsDir = join(distDir, "years");
const yearFiles = readdirSync(distYearsDir)
.filter((f) => f.match(/^\d{4}\.mjs$/))
.map((f) => parseInt(f.replace(".mjs", "")));
// Start from a clean dist/cjs so a wrapper removed here (e.g. the old async loader.cjs)
// never lingers from an earlier build.
rmSync(cjsDir, { recursive: true, force: true });
mkdirSync(cjsYearsDir, { recursive: true });
mkdirSync(cjsYearsTypesDir, { recursive: true });

for (const year of yearFiles) {
const yearCjsContent = `/**
* CommonJS wrapper for @cldmv/jsonv/${year}
/**
* Source of a CJS wrapper that synchronously requires an ESM file.
* @param {string} specifier - Package specifier the wrapper stands for (for the comment and error message).
* @param {string} esmPath - Path of the ESM file, relative to the wrapper.
* @returns {string} The wrapper source.
*/

const loader = (async () => {
const esm = await import('../../years/${year}.mjs');
return esm;
})();

module.exports = loader;
`;

const yearFile = join(cjsYearsDir, `${year}.cjs`);
writeFileSync(yearFile, yearCjsContent, "utf8");
function wrapper(specifier, esmPath) {
return `/**
* CommonJS entry for ${specifier}
*/
"use strict";

// A thin wrapper: it loads the ESM build through Node's synchronous require(esm), so
// require() returns the same module namespace object as import. Node.js versions without
// require(esm) would fail with a bare ERR_REQUIRE_ESM, so fail early with a message that
// says what to do instead.
if (!process.features?.require_module) {
const error = new Error(
\`@cldmv/jsonv: require() needs Node.js ^20.19.0 or >=22.12.0 (this is \${process.version}). On older Node.js, load the package with import() instead.\`
);
error.code = "ERR_REQUIRE_ESM";
throw error;
}
console.log(`βœ“ Created ${yearFiles.length} CJS year modules`);

// Generate CJS type declarations for all years
for (const year of yearFiles) {
const yearDts = `export * from '../../years/${year}.mjs';
import jsonv from '../../years/${year}.mjs';
export default jsonv;
module.exports = require("${esmPath}");
`;
writeFileSync(join(cjsYearsTypesDir, `${year}.d.cts`), yearDts, "utf8");
}
console.log(`βœ“ Created ${yearFiles.length} CJS year types`);

// Create CJS loader utility
const loaderCjsContent = `/**
* CommonJS wrapper for @cldmv/jsonv/loader
*/
// Main CJS entry point
writeFileSync(join(cjsDir, "index.cjs"), wrapper("@cldmv/jsonv", "../index.mjs"), "utf8");
console.log("βœ“ Created CJS index β†’ dist/cjs/index.cjs");

const loader = (async () => {
const esm = await import('../../years/loader.mjs');
return esm;
})();
// CJS wrappers for every module under dist/years/: the year modules plus the
// loader and year-resolver utilities, all reachable through the "./*" export.
const yearModules = readdirSync(join(distDir, "years"))
.filter((f) => f.endsWith(".mjs"))
.map((f) => f.slice(0, -".mjs".length));

module.exports = loader;
`;

const loaderYearFile = join(cjsYearsDir, "loader.cjs");
writeFileSync(loaderYearFile, loaderCjsContent, "utf8");
console.log(`βœ“ Created CJS loader β†’ dist/cjs/years/loader.cjs`);
for (const name of yearModules) {
writeFileSync(join(cjsYearsDir, `${name}.cjs`), wrapper(`@cldmv/jsonv/${name}`, `../../years/${name}.mjs`), "utf8");
}
console.log(`βœ“ Created ${yearModules.length} CJS year/utility wrappers β†’ dist/cjs/years/`);

// Create .d.cts type declarations for CJS modules
// CJS type declarations. require(esm) returns the ESM namespace, so the
// declarations re-export the ESM types (default export included when present).
console.log("\nπŸ“ Generating CJS type declarations...\n");

// Main index.d.cts
const indexDts = `export * from '../index.mjs';
import jsonv from '../index.mjs';
export default jsonv;
`;
writeFileSync(join(cjsTypesDir, "index.d.cts"), indexDts, "utf8");
console.log(`βœ“ Created CJS types β†’ dist/types/cjs/index.d.cts`);
console.log("βœ“ Created CJS types β†’ dist/types/cjs/index.d.cts");

// Loader type
const loaderDts = `export * from '../../years/loader.mjs';
for (const name of yearModules) {
const hasDefault = /^\d{4}$/.test(name);
const dts = hasDefault
? `export * from '../../years/${name}.mjs';
import jsonv from '../../years/${name}.mjs';
export default jsonv;
`
: `export * from '../../years/${name}.mjs';
`;
writeFileSync(join(cjsYearsTypesDir, "loader.d.cts"), loaderDts, "utf8");
console.log(`βœ“ Created CJS loader types β†’ dist/types/cjs/years/loader.d.cts`);
writeFileSync(join(cjsYearsTypesDir, `${name}.d.cts`), dts, "utf8");
}
console.log(`βœ“ Created ${yearModules.length} CJS year/utility types β†’ dist/types/cjs/years/`);

console.log("\nβœ… CJS wrappers built successfully\n");
80 changes: 80 additions & 0 deletions tests/cjs/entry.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
*
* @Project: @cldmv/jsonv
* @Filename: /tests/cjs/entry.test.cjs
* @Date: 2026-10-03T10:28:33-07:00 (1791048513)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-10-03T10:29:28-07:00 (1791048568)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*
*/

/**
* CommonJS entry tests. These run under Node's own test runner (`node --test`), not Vitest:
* Vitest loads files through its own module runner, so it cannot show whether a plain
* `require()` of the built package works the way it does for a CommonJS consumer.
* They test the built dist/ output, so `npm run test:cjs` builds first.
*/
"use strict";

const { test } = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const path = require("node:path");

const repoRoot = path.resolve(__dirname, "../..");

/**
* Assert that a require() result exposes exactly the ESM module's exports. Node's require(esm)
* returns the namespace itself, or - for a module with a default export - an object carrying
* the same bindings plus `__esModule: true` for bundler interop, so compare member by member.
* @param {object} required - What require() returned.
* @param {object} esm - The namespace import() returned.
*/
function assertSameExports(required, esm) {
assert.ok(Object.keys(esm).length > 0);
for (const key of Object.keys(esm)) {
assert.equal(required[key], esm[key], `export "${key}" differs`);
}
const extra = Object.keys(required).filter((key) => !(key in esm));
assert.deepEqual(
extra.filter((key) => key !== "__esModule"),
[]
);
}

test("require() of the CJS entry returns the same namespace as import", async () => {
const required = require("../../dist/cjs/index.cjs");
const esm = await import("../../dist/index.mjs");

assert.equal(typeof required.then, "undefined", "require() must not return a Promise");
assertSameExports(required, esm);
assert.deepEqual(required.parse('{"a":1}'), { a: 1 });
});

test("require() of a year wrapper returns the same namespace as import", async () => {
const required = require("../../dist/cjs/years/2021.cjs");
const esm = await import("../../dist/years/2021.mjs");

assert.equal(typeof required.then, "undefined", "require() must not return a Promise");
assertSameExports(required, esm);
assert.deepEqual(required.parse("{ value: 1_000 }"), { value: 1000 });
});

test("require() fails with a clear message where Node.js has no require(esm)", () => {
// --no-experimental-require-module turns require(esm) off, which is what Node.js
// versions before 20.19 / 22.12 look like to the entry.
const res = spawnSync(process.execPath, ["--no-experimental-require-module", "-e", "require('./dist/cjs/index.cjs')"], {
cwd: repoRoot,
encoding: "utf8"
});

assert.notEqual(res.status, 0);
assert.match(res.stderr, /ERR_REQUIRE_ESM/);
assert.match(res.stderr, /require\(\) needs Node\.js \^20\.19\.0 or >=22\.12\.0/);
assert.match(res.stderr, /import\(\)/);
});
Loading