From 919e95da4266c637bd60401ae04231da84842aaa Mon Sep 17 00:00:00 2001 From: sheo13666q Date: Sun, 18 Oct 2026 15:55:53 +0000 Subject: [PATCH 1/2] feat: precompile into an ES module for a type module package A check written as CommonJS cannot be loaded from a package with "type": "module" (less-loader, sass-loader, stylus-loader). `schema-utils precompile` now writes an ES module there, the way Node.js reads a .js file, and `precompile` takes an `esm` option. ajv keeps `require` for its runtime helpers in an ES module, so they are imported from `schema-utils/dist/runtime.js`, with the extension Node.js needs in a package without an `exports` field. CommonJS output is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QYPkbvwyRWt2SHgXDoYRdo --- .changeset/precompile-esm.md | 5 ++ README.md | 7 ++- declarations/precompile.d.ts | 8 ++- src/cli.js | 42 ++++++++++++- src/precompile.js | 32 ++++++++-- test/precompile.test.js | 118 +++++++++++++++++++++++++++++++++++ 6 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 .changeset/precompile-esm.md diff --git a/.changeset/precompile-esm.md b/.changeset/precompile-esm.md new file mode 100644 index 0000000..fedd71a --- /dev/null +++ b/.changeset/precompile-esm.md @@ -0,0 +1,5 @@ +--- +"schema-utils": minor +--- + +`schema-utils precompile` writes an ES module check for a package with `"type": "module"`, and `precompile` takes an `esm` option. diff --git a/README.md b/README.md index 2964750..dc85569 100644 --- a/README.md +++ b/README.md @@ -286,11 +286,12 @@ not, `validate` runs the full validation to report what is wrong, so the errors without a check. Run `schema-utils precompile --check src/options.json` in CI to fail when the check is missing or -out of date with its schema. The generated module needs `schema-utils` as a dependency of the -package and is CommonJS. +out of date with its schema. The generated module may need `schema-utils` as a dependency of the +package. It is an ES module when the nearest `package.json` has `"type": "module"`, the way Node.js +reads a `.js` file there, and CommonJS otherwise. A relative `$ref` to another file resolves against the schema. To compile a schema from code, use -`precompile(schema, { loadSchema })`, it returns the source of the module. +`precompile(schema, { loadSchema, esm })`, it returns the source of the module. ### Allow to disable and enable validation (the `validate` function do nothing) diff --git a/declarations/precompile.d.ts b/declarations/precompile.d.ts index 7b6f8d2..0413c1f 100644 --- a/declarations/precompile.d.ts +++ b/declarations/precompile.d.ts @@ -4,6 +4,10 @@ export type PrecompileOptions = { * loads a schema an external `$ref` points to */ loadSchema?: ((uri: string) => Promise) | undefined; + /** + * generate an ES module instead of a CommonJS one + */ + esm?: boolean | undefined; }; export type Ajv = import("ajv").default; export type AnySchemaObject = import("ajv").AnySchemaObject; @@ -13,9 +17,11 @@ export type EXPECTED_ANY = any; /** * @typedef {object} PrecompileOptions * @property {((uri: string) => Promise)=} loadSchema loads a schema an external `$ref` points to + * @property {boolean=} esm generate an ES module instead of a CommonJS one */ /** - * Compiles a schema into the source of a CommonJS module that exports a check for it. + * Compiles a schema into the source of a module that exports a check for it, CommonJS unless + * `esm` is set. * @param {Schema} schema schema * @param {PrecompileOptions=} options options * @returns {Promise} source of the module diff --git a/src/cli.js b/src/cli.js index 17ab45f..85ce980 100644 --- a/src/cli.js +++ b/src/cli.js @@ -5,6 +5,9 @@ for the fourth argument of \`validate\`: validate(schema, options, configuration, require("./options.check.js")); +The check is an ES module when the nearest package.json has "type": "module", +the way Node.js reads a .js file there, and a CommonJS module otherwise. + Options: --check write nothing, fail when a check is missing or out of date`; @@ -16,6 +19,43 @@ function getCheckPath(schemaPath) { return `${schemaPath.replace(/\.json$/i, "")}.check.js`; } +/** + * @param {string} file path of a `.js` file + * @returns {boolean} whether Node.js loads it as an ES module + */ +function isModule(file) { + const fs = require("fs"); + const path = require("path"); + + let dir = path.dirname(file); + + for (;;) { + let content; + + try { + content = fs.readFileSync(path.join(dir, "package.json"), "utf8"); + } catch (_err) { + // No package.json here, look further up + } + + if (typeof content !== "undefined") { + try { + return JSON.parse(content).type === "module"; + } catch (_err) { + return false; + } + } + + const parent = path.dirname(dir); + + if (parent === dir) { + return false; + } + + dir = parent; + } +} + /** * @param {string} content content * @returns {string} content with LF line endings and without trailing whitespace @@ -89,7 +129,7 @@ async function cli(args, io) { schema.$id = pathToFileURL(schemaPath).href; } - code = await precompile(schema); + code = await precompile(schema, { esm: isModule(checkPath) }); } catch (error) { io.error( `Cannot precompile "${file}": ${/** @type {Error} */ (error).message}`, diff --git a/src/precompile.js b/src/precompile.js index 2eda84a..3a17d24 100644 --- a/src/precompile.js +++ b/src/precompile.js @@ -16,6 +16,11 @@ const RUNTIME = "schema-utils/dist/runtime"; +// What an ES module check imports the runtime helpers as, and from where: Node.js resolves a +// path in a package without an `exports` field only with its extension +const RUNTIME_BINDING = "schemaUtilsRuntime"; +const RUNTIME_MODULE = `${RUNTIME}.js`; + /** * @param {Ajv} ajv ajv * @returns {void} @@ -117,9 +122,10 @@ function addKeywords(ajv) { /** * @param {string} code generated code + * @param {boolean} esm whether the code is an ES module * @returns {string} code loading the runtime helpers from `schema-utils` */ -function useRuntime(code) { +function useRuntime(code, esm) { return code.replace( /require\("ajv\/dist\/runtime\/(\w+)"\)\.default/g, (match, name) => { @@ -127,7 +133,10 @@ function useRuntime(code) { throw new Error(`Unsupported "ajv" runtime helper "${name}"`); } - return `require(${JSON.stringify(RUNTIME)}).${name}`; + // ajv keeps `require` for its helpers in an ES module too + return esm + ? `${RUNTIME_BINDING}.${name}` + : `require(${JSON.stringify(RUNTIME)}).${name}`; }, ); } @@ -226,10 +235,12 @@ async function loadFileSchema(uri) { /** * @typedef {object} PrecompileOptions * @property {((uri: string) => Promise)=} loadSchema loads a schema an external `$ref` points to + * @property {boolean=} esm generate an ES module instead of a CommonJS one */ /** - * Compiles a schema into the source of a CommonJS module that exports a check for it. + * Compiles a schema into the source of a module that exports a check for it, CommonJS unless + * `esm` is set. * @param {Schema} schema schema * @param {PrecompileOptions=} options options * @returns {Promise} source of the module @@ -239,7 +250,9 @@ async function precompile(schema, options = {}) { const standaloneCode = require("ajv/dist/standalone").default; const ajvKeywords = require("ajv-keywords").default; const addFormats = require("ajv-formats").default; - const { _ } = require("ajv"); + const { Name, _ } = require("ajv"); + + const esm = Boolean(options.esm); // The same options as the full validation in `validate.js`, without the ones that only shape // the errors - a check only says whether the value is valid @@ -250,7 +263,10 @@ async function precompile(schema, options = {}) { code: { source: true, optimize: true, - formats: _`require(${RUNTIME}).formats`, + esm, + formats: esm + ? _`${new Name(RUNTIME_BINDING)}.formats` + : _`require(${RUNTIME}).formats`, }, }); @@ -278,7 +294,7 @@ async function precompile(schema, options = {}) { const validate = await ajv.compileAsync(stripAnnotations(schema, keywords)); - let code = useRuntime(standaloneCode(ajv, validate)); + let code = useRuntime(standaloneCode(ajv, validate), esm); // The `$id` of a schema read from a file is its absolute URL, which ends up in the code, so the // URLs of the schemas are made relative to the schema for the generated file to be the same on @@ -302,6 +318,10 @@ async function precompile(schema, options = {}) { } } + if (esm && code.includes(`${RUNTIME_BINDING}.`)) { + code = `import ${RUNTIME_BINDING} from ${JSON.stringify(RUNTIME_MODULE)};\n${code}`; + } + return `/* eslint-disable */ // @ts-nocheck /* diff --git a/test/precompile.test.js b/test/precompile.test.js index 5a4c09a..1abadf6 100644 --- a/test/precompile.test.js +++ b/test/precompile.test.js @@ -1,3 +1,4 @@ +import { execFileSync } from "child_process"; import fs from "fs"; import os from "os"; import path from "path"; @@ -168,6 +169,84 @@ describe("precompile", () => { expect(code).toMatch(/require\("schema-utils\/dist\/runtime"\)\.equal/); }); + it("should generate an ES module", async () => { + const code = await precompile( + { + type: "object", + properties: { + name: { type: "string", minLength: 2 }, + date: { type: "string", format: "date" }, + mode: { enum: [{ a: 1 }] }, + }, + }, + { esm: true }, + ); + + expect(code).toContain( + 'import schemaUtilsRuntime from "schema-utils/dist/runtime.js";', + ); + expect(code).toMatch(/export default/); + expect(code).not.toMatch(/require\(/); + expect(code).not.toMatch(/module\.exports/); + }); + + it("should not import the runtime when an ES module needs none of it", async () => { + const code = await precompile({ type: "string" }, { esm: true }); + + expect(code).not.toMatch(/import /); + expect(code).toMatch(/export default/); + }); + + // ES modules need Node.js 14, and the published `dist` the check imports from + const supportsModules = + Number(process.versions.node.split(".")[0]) >= 14 && + fs.existsSync(path.resolve(__dirname, "../dist/runtime.js")); + + (supportsModules ? describe : describe.skip)("in Node.js", () => { + it("should run as an ES module in Node.js", async () => { + const code = await precompile( + { + type: "object", + properties: { + name: { type: "string", minLength: 2 }, + date: { type: "string", format: "date" }, + }, + additionalProperties: false, + }, + { esm: true }, + ); + + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ type: "module" }), + ); + fs.mkdirSync(path.join(dir, "node_modules")); + fs.symlinkSync( + path.resolve(__dirname, ".."), + path.join(dir, "node_modules/schema-utils"), + "junction", + ); + fs.writeFileSync(path.join(dir, "options.check.js"), code); + fs.writeFileSync( + path.join(dir, "run.js"), + `import check from "./options.check.js"; + console.log(JSON.stringify([ + check({ name: "ab", date: "2026-10-18" }), + check({ name: "a" }), + check({ date: "not a date" }), + check({ other: true }), + ]));`, + ); + + const output = execFileSync(process.execPath, ["run.js"], { + cwd: dir, + encoding: "utf8", + }); + + expect(JSON.parse(output)).toEqual([true, false, false, false]); + }); + }); + it("should expose every runtime helper", () => { expect(runtime.equal({ a: [1] }, { a: [1] })).toBe(true); expect(runtime.ucs2length("😀")).toBe(1); @@ -365,6 +444,45 @@ describe("precompile", () => { expect(fs.readFileSync(checkFile, "utf8")).toBe(code); }); + it("should write an ES module where package.json has type module", async () => { + writeJson("package.json", { type: "module" }); + + const file = writeJson("src/options.json", { type: "string" }); + + writeJson("other/package.json", { type: "commonjs" }); + + const other = writeJson("other/options.json", { type: "string" }); + const result = await runCli(["precompile", file, other]); + + expect(result.exitCode).toBe(0); + + const code = fs.readFileSync( + path.join(dir, "src/options.check.js"), + "utf8", + ); + const otherCode = fs.readFileSync( + path.join(dir, "other/options.check.js"), + "utf8", + ); + + expect(code).toMatch(/export default/); + expect(code).not.toMatch(/module\.exports/); + expect(otherCode).toMatch(/module\.exports/); + expect(otherCode).not.toMatch(/export default/); + }); + + it("should write CommonJS when package.json cannot be read", async () => { + fs.writeFileSync(path.join(dir, "package.json"), "{"); + + const file = writeJson("options.json", { type: "string" }); + const result = await runCli(["precompile", file]); + + expect(result.exitCode).toBe(0); + expect( + fs.readFileSync(path.join(dir, "options.check.js"), "utf8"), + ).toMatch(/module\.exports/); + }); + it("should report a schema it cannot precompile and go on", async () => { const broken = path.join(dir, "broken.json"); From e376425ca2bf595aa3d97fdc01a58ee083aeb50d Mon Sep 17 00:00:00 2001 From: sheo13666q Date: Sun, 18 Oct 2026 15:59:33 +0000 Subject: [PATCH 2/2] docs: show the import of an ES module check in the CLI usage Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QYPkbvwyRWt2SHgXDoYRdo --- src/cli.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli.js b/src/cli.js index 85ce980..77c6075 100644 --- a/src/cli.js +++ b/src/cli.js @@ -6,7 +6,11 @@ for the fourth argument of \`validate\`: validate(schema, options, configuration, require("./options.check.js")); The check is an ES module when the nearest package.json has "type": "module", -the way Node.js reads a .js file there, and a CommonJS module otherwise. +the way Node.js reads a .js file there, and a CommonJS module otherwise: + + import check from "./options.check.js"; + + validate(schema, options, configuration, check); Options: --check write nothing, fail when a check is missing or out of date`;