diff --git a/docs/guide/environments.md b/docs/guide/environments.md index f3da43b97..1e43f8a36 100644 --- a/docs/guide/environments.md +++ b/docs/guide/environments.md @@ -94,6 +94,15 @@ const ajv = new Ajv({code: {es5: true}}) See [Advanced options](https://github.com/ajv-validator/ajv/blob/master/docs/api.md#advanced-options). +## CJS vs ESM exports + +The default configuration of AJV is to generate code in ES6 with Common JS (CJS) exports. This can be changed by setting +the ES Modules(ESM) flag. + +```javascript +const ajv = new Ajv({code: {esm: true}}) +``` + ## Other JavaScript environments Ajv is used in other JavaScript environments, including Electron apps, WeChat mini-apps and many others, where the same considerations apply as above: diff --git a/docs/options.md b/docs/options.md index ed0db7b02..557cf36a8 100644 --- a/docs/options.md +++ b/docs/options.md @@ -65,6 +65,7 @@ const defaultOptions = { code: { // NEW es5: false, + esm: false, lines: false, source: false, process: undefined, // (code: string) => string @@ -347,6 +348,9 @@ Code generation options: ```typescript type CodeOptions = { es5?: boolean // to generate es5 code - by default code is es6, with "for-of" loops, "let" and "const" + esm?: boolean // how functions should be exported - by default CJS is used, so the validate function(s) + // file can be `required`. Set this value to true to export the validate function(s) as ES Modules, enabling + // bunlers to do their job. lines?: boolean // add line-breaks to code - to simplify debugging of generated functions source?: boolean // add `source` property (see Source below) to validating function. process?: (code: string, schema?: SchemaEnv) => string // an optional function to process generated code diff --git a/lib/compile/codegen/code.ts b/lib/compile/codegen/code.ts index bd9b38502..b17701973 100644 --- a/lib/compile/codegen/code.ts +++ b/lib/compile/codegen/code.ts @@ -155,6 +155,14 @@ export function getProperty(key: Code | string | number): Code { return typeof key == "string" && IDENTIFIER.test(key) ? new _Code(`.${key}`) : _`[${key}]` } +//Does best effort to format the name properly +export function getEsmExportName(key: Code | string | number): Code { + if (typeof key == "string" && IDENTIFIER.test(key)) { + return new _Code(`${key}`) + } + throw new Error(`CodeGen: invalid export name: ${key}, use explicit $id name mapping`) +} + export function regexpCode(rx: RegExp): Code { return new _Code(rx.toString()) } diff --git a/lib/core.ts b/lib/core.ts index 2cf93c62c..9e0bb326c 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -140,6 +140,7 @@ export interface CurrentOptions { export interface CodeOptions { es5?: boolean + esm?: boolean lines?: boolean optimize?: boolean | number formats?: Code // code to require (or construct) map of available formats - for standalone code diff --git a/lib/standalone/index.ts b/lib/standalone/index.ts index d205ac09a..b6129ce9e 100644 --- a/lib/standalone/index.ts +++ b/lib/standalone/index.ts @@ -2,7 +2,7 @@ import type AjvCore from "../core" import type {AnyValidateFunction, SourceCode} from "../types" import type {SchemaEnv} from "../compile" import {UsedScopeValues, UsedValueState, ValueScopeName, varKinds} from "../compile/codegen/scope" -import {_, nil, _Code, Code, getProperty} from "../compile/codegen/code" +import {_, nil, _Code, Code, getProperty, getEsmExportName} from "../compile/codegen/code" function standaloneCode( ajv: AjvCore, @@ -30,6 +30,10 @@ function standaloneCode( const usedValues: UsedScopeValues = {} const n = source?.validateName const vCode = validateCode(usedValues, source) + if (ajv.opts.code.esm) { + // Always do named export as `validate` rather than the variable `n` which is `validateXX` for known export value + return `"use strict";${_n}export const validate = ${n};${_n}export default ${n};${_n}${vCode}` + } return `"use strict";${_n}module.exports = ${n};${_n}module.exports.default = ${n};${_n}${vCode}` } @@ -43,7 +47,10 @@ function standaloneCode( const v = getValidateFunc(schemas[name] as T) if (v) { const vCode = validateCode(usedValues, v.source) - code = _`${code}${_n}exports${getProperty(name)} = ${v.source?.validateName};${_n}${vCode}` + const exportSyntax = ajv.opts.code.esm + ? _`export const ${getEsmExportName(name)}` + : _`exports${getProperty(name)}` + code = _`${code}${_n}${exportSyntax} = ${v.source?.validateName};${_n}${vCode}` } } return `${code}` diff --git a/package.json b/package.json index a1588cec2..a986f6891 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "karma-mocha": "^2.0.0", "lint-staged": "^12.1.1", "mocha": "^9.0.2", + "module-from-string": "^3.1.3", "node-fetch": "^3.0.0", "nyc": "^15.0.0", "prettier": "^2.3.1", diff --git a/spec/standalone.spec.ts b/spec/standalone.spec.ts index 925b45a69..45e3dad18 100644 --- a/spec/standalone.spec.ts +++ b/spec/standalone.spec.ts @@ -4,8 +4,29 @@ import _Ajv from "./ajv" import standaloneCode from "../dist/standalone" import ajvFormats from "ajv-formats" import requireFromString = require("require-from-string") +import {importFromStringSync} from "module-from-string" import assert = require("assert") +function testExportTypeEsm(moduleCode: string, singleExport: boolean) { + //Must have + assert.strictEqual(moduleCode.includes("export const"), true) + if (singleExport) { + assert.strictEqual(moduleCode.includes("export default"), true) + } + //Must not have + assert.strictEqual(moduleCode.includes("module.exports"), false) +} +function testExportTypeCjs(moduleCode: string, singleExport: boolean) { + //Must have + if (singleExport) { + assert.strictEqual(moduleCode.includes("module.exports"), true) + } else { + assert.strictEqual(moduleCode.includes("exports.") || moduleCode.includes("exports["), true) + } + //Must not have + assert.strictEqual(moduleCode.includes("export const"), false) +} + describe("standalone code generation", () => { describe("multiple exports", () => { let ajv: Ajv @@ -21,24 +42,40 @@ describe("standalone code generation", () => { } describe("without schema keys", () => { - beforeEach(() => { + it("should generate module code with named export - CJS", () => { ajv = new _Ajv({code: {source: true}}) ajv.addSchema(numSchema) ajv.addSchema(strSchema) + const moduleCode = standaloneCode(ajv, { + validateNumber: "https://example.com/number.json", + validateString: "https://example.com/string.json", + }) + testExportTypeCjs(moduleCode, false) + const m = requireFromString(moduleCode) + assert.strictEqual(Object.keys(m).length, 2) + testExports(m) }) - it("should generate module code with named exports", () => { + it("should generate module code with named export - ESM", () => { + ajv = new _Ajv({code: {source: true, esm: true}}) + ajv.addSchema(numSchema) + ajv.addSchema(strSchema) const moduleCode = standaloneCode(ajv, { validateNumber: "https://example.com/number.json", validateString: "https://example.com/string.json", }) - const m = requireFromString(moduleCode) + testExportTypeEsm(moduleCode, false) + const m = importFromStringSync(moduleCode) assert.strictEqual(Object.keys(m).length, 2) testExports(m) }) - it("should generate module code with all exports", () => { + it("should generate module code with all exports - CJS", () => { + ajv = new _Ajv({code: {source: true}}) + ajv.addSchema(numSchema) + ajv.addSchema(strSchema) const moduleCode = standaloneCode(ajv) + testExportTypeCjs(moduleCode, false) const m = requireFromString(moduleCode) assert.strictEqual(Object.keys(m).length, 2) testExports({ @@ -46,6 +83,27 @@ describe("standalone code generation", () => { validateString: m["https://example.com/string.json"], }) }) + + it("should generate module code with all exports - ESM", () => { + ajv = new _Ajv({code: {source: true, esm: true}}) + ajv.addSchema(numSchema) + ajv.addSchema(strSchema) + + try { + standaloneCode(ajv) + } catch (err) { + if (err instanceof Error) { + const isMappingErr = + `CodeGen: invalid export name: ${numSchema.$id}, use explicit $id name mapping` === + err.message || + `CodeGen: invalid export name: ${strSchema.$id}, use explicit $id name mapping` === + err.message + assert.strictEqual(isMappingErr, true) + } else { + throw err + } + } + }) }) describe("with schema keys", () => { @@ -223,13 +281,14 @@ describe("standalone code generation", () => { } }) - it("should generate module code with a single export (ESM compatible)", () => { + it("should generate module code with a single export - CJS", () => { const ajv = new _Ajv({code: {source: true}}) const v = ajv.compile({ type: "number", minimum: 0, }) const moduleCode = standaloneCode(ajv, v) + testExportTypeCjs(moduleCode, true) const m = requireFromString(moduleCode) testExport(m) testExport(m.default) @@ -242,6 +301,26 @@ describe("standalone code generation", () => { } }) + it("should generate module code with a single export - ESM", () => { + const ajv = new _Ajv({code: {source: true, esm: true}}) + const v = ajv.compile({ + type: "number", + minimum: 0, + }) + const moduleCode = standaloneCode(ajv, v) + testExportTypeEsm(moduleCode, true) + const m = importFromStringSync(moduleCode) + testExport(m.validate) + testExport(m.default) + + function testExport(validate: AnyValidateFunction) { + assert.strictEqual(validate(1), true) + assert.strictEqual(validate(0), true) + assert.strictEqual(validate(-1), false) + assert.strictEqual(validate("1"), false) + } + }) + describe("standalone code with ajv-formats", () => { const schema = { $schema: "http://json-schema.org/draft-07/schema#",