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
5 changes: 5 additions & 0 deletions .changeset/precompile-esm.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion declarations/precompile.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export type PrecompileOptions = {
* loads a schema an external `$ref` points to
*/
loadSchema?: ((uri: string) => Promise<AnySchemaObject>) | 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;
Expand All @@ -13,9 +17,11 @@ export type EXPECTED_ANY = any;
/**
* @typedef {object} PrecompileOptions
* @property {((uri: string) => Promise<AnySchemaObject>)=} 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<string>} source of the module
Expand Down
46 changes: 45 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ for the fourth argument of \`validate\`:

validate(schema, options, configuration, require("./options.check.js"));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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:

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`;

Expand All @@ -16,6 +23,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
Expand Down Expand Up @@ -89,7 +133,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}`,
Expand Down
32 changes: 26 additions & 6 deletions src/precompile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -117,17 +122,21 @@ 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) => {
if (!["equal", "ucs2length", "uri"].includes(name)) {
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}`;
},
);
}
Expand Down Expand Up @@ -226,10 +235,12 @@ async function loadFileSchema(uri) {
/**
* @typedef {object} PrecompileOptions
* @property {((uri: string) => Promise<AnySchemaObject>)=} 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<string>} source of the module
Expand All @@ -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
Expand All @@ -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`,
},
});

Expand Down Expand Up @@ -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
Expand All @@ -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
/*
Expand Down
118 changes: 118 additions & 0 deletions test/precompile.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { execFileSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");

Expand Down
Loading