From 89629cfbe56c52776fd8b7788752545a726cdc9c Mon Sep 17 00:00:00 2001 From: Drew Powers Date: Sat, 11 Jan 2025 22:49:51 -0700 Subject: [PATCH] Add ESM build --- .npmignore | 5 + index.mjs | 284 +++++++++++++++++++++++++++++++++++++++ package.json | 18 ++- scripts/generate-esm.mjs | 40 ++++++ types/index.d.mts | 67 +++++++++ 5 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 .npmignore create mode 100644 index.mjs create mode 100644 scripts/generate-esm.mjs create mode 100644 types/index.d.mts diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..204097f --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +.gitattributes +.github +scripts +test +**/*.test-d.ts diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..93a3769 --- /dev/null +++ b/index.mjs @@ -0,0 +1,284 @@ +'use strict' + +import { dequal as deepEqual } from 'dequal' + +const jsonSchemaRefSymbol = Symbol.for('json-schema-ref') + +class RefResolver { + #schemas + #derefSchemas + #insertRefSymbol + #allowEqualDuplicates + #cloneSchemaWithoutRefs + + constructor (opts = {}) { + this.#schemas = {} + this.#derefSchemas = {} + this.#insertRefSymbol = opts.insertRefSymbol ?? false + this.#allowEqualDuplicates = opts.allowEqualDuplicates ?? true + this.#cloneSchemaWithoutRefs = opts.cloneSchemaWithoutRefs ?? false + } + + addSchema (schema, rootSchemaId, isRootSchema = true) { + if (isRootSchema) { + if (schema.$id !== undefined && schema.$id.charAt(0) !== '#') { + // Schema has an $id that is not an anchor + rootSchemaId = schema.$id + } else { + // Schema has no $id or $id is an anchor + this.#insertSchemaBySchemaId(schema, rootSchemaId) + } + } + + const schemaId = schema.$id + if (schemaId !== undefined && typeof schemaId === 'string') { + if (schemaId.charAt(0) === '#') { + this.#insertSchemaByAnchor(schema, rootSchemaId, schemaId) + } else { + this.#insertSchemaBySchemaId(schema, schemaId) + rootSchemaId = schemaId + } + } + + const ref = schema.$ref + if (ref !== undefined && typeof ref === 'string') { + const { refSchemaId, refJsonPointer } = this.#parseSchemaRef(ref, rootSchemaId) + this.#schemas[rootSchemaId].refs.push({ + schemaId: refSchemaId, + jsonPointer: refJsonPointer + }) + } + + for (const key in schema) { + if (typeof schema[key] === 'object' && schema[key] !== null) { + this.addSchema(schema[key], rootSchemaId, false) + } + } + } + + getSchema (schemaId, jsonPointer = '#') { + const schema = this.#schemas[schemaId] + if (schema === undefined) { + throw new Error( + `Cannot resolve ref "${schemaId}${jsonPointer}". Schema with id "${schemaId}" is not found.` + ) + } + if (schema.anchors[jsonPointer] !== undefined) { + return schema.anchors[jsonPointer] + } + return getDataByJSONPointer(schema.schema, jsonPointer) + } + + hasSchema (schemaId) { + return this.#schemas[schemaId] !== undefined + } + + getSchemaRefs (schemaId) { + const schema = this.#schemas[schemaId] + if (schema === undefined) { + throw new Error(`Schema with id "${schemaId}" is not found.`) + } + return schema.refs + } + + getSchemaDependencies (schemaId, dependencies = {}) { + const schema = this.#schemas[schemaId] + + for (const ref of schema.refs) { + const dependencySchemaId = ref.schemaId + if ( + dependencySchemaId === schemaId || + dependencies[dependencySchemaId] !== undefined + ) continue + + dependencies[dependencySchemaId] = this.getSchema(dependencySchemaId) + this.getSchemaDependencies(dependencySchemaId, dependencies) + } + + return dependencies + } + + derefSchema (schemaId) { + if (this.#derefSchemas[schemaId] !== undefined) return + + const schema = this.#schemas[schemaId] + if (schema === undefined) { + throw new Error(`Schema with id "${schemaId}" is not found.`) + } + + if (!this.#cloneSchemaWithoutRefs && schema.refs.length === 0) { + this.#derefSchemas[schemaId] = { + schema: schema.schema, + anchors: schema.anchors + } + } + + const refs = [] + this.#addDerefSchema(schema.schema, schemaId, true, refs) + + const dependencies = this.getSchemaDependencies(schemaId) + for (const schemaId in dependencies) { + const schema = dependencies[schemaId] + this.#addDerefSchema(schema, schemaId, true, refs) + } + + for (const ref of refs) { + const { + refSchemaId, + refJsonPointer + } = this.#parseSchemaRef(ref.ref, ref.sourceSchemaId) + + const targetSchema = this.getDerefSchema(refSchemaId, refJsonPointer) + if (targetSchema === null) { + throw new Error( + `Cannot resolve ref "${ref.ref}". Ref "${refJsonPointer}" is not found in schema "${refSchemaId}".` + ) + } + + ref.targetSchema = targetSchema + ref.targetSchemaId = refSchemaId + } + + for (const ref of refs) { + this.#resolveRef(ref, refs) + } + } + + getDerefSchema (schemaId, jsonPointer = '#') { + let derefSchema = this.#derefSchemas[schemaId] + if (derefSchema === undefined) { + this.derefSchema(schemaId) + derefSchema = this.#derefSchemas[schemaId] + } + if (derefSchema.anchors[jsonPointer] !== undefined) { + return derefSchema.anchors[jsonPointer] + } + return getDataByJSONPointer(derefSchema.schema, jsonPointer) + } + + #parseSchemaRef (ref, schemaId) { + const sharpIndex = ref.indexOf('#') + if (sharpIndex === -1) { + return { refSchemaId: ref, refJsonPointer: '#' } + } + if (sharpIndex === 0) { + return { refSchemaId: schemaId, refJsonPointer: ref } + } + return { + refSchemaId: ref.slice(0, sharpIndex), + refJsonPointer: ref.slice(sharpIndex) + } + } + + #addDerefSchema (schema, rootSchemaId, isRootSchema, refs = []) { + const derefSchema = Array.isArray(schema) ? [...schema] : { ...schema } + + if (isRootSchema) { + if (schema.$id !== undefined && schema.$id.charAt(0) !== '#') { + // Schema has an $id that is not an anchor + rootSchemaId = schema.$id + } else { + // Schema has no $id or $id is an anchor + this.#insertDerefSchemaBySchemaId(derefSchema, rootSchemaId) + } + } + + const schemaId = derefSchema.$id + if (schemaId !== undefined && typeof schemaId === 'string') { + if (schemaId.charAt(0) === '#') { + this.#insertDerefSchemaByAnchor(derefSchema, rootSchemaId, schemaId) + } else { + this.#insertDerefSchemaBySchemaId(derefSchema, schemaId) + rootSchemaId = schemaId + } + } + + if (derefSchema.$ref !== undefined) { + refs.push({ + ref: derefSchema.$ref, + sourceSchemaId: rootSchemaId, + sourceSchema: derefSchema + }) + } + + for (const key in derefSchema) { + const value = derefSchema[key] + if (typeof value === 'object' && value !== null) { + derefSchema[key] = this.#addDerefSchema(value, rootSchemaId, false, refs) + } + } + + return derefSchema + } + + #resolveRef (ref, refs) { + const { sourceSchema, targetSchema } = ref + + if (!sourceSchema.$ref) return + if (this.#insertRefSymbol) { + sourceSchema[jsonSchemaRefSymbol] = sourceSchema.$ref + } + + delete sourceSchema.$ref + + if (targetSchema.$ref) { + const targetSchemaRef = refs.find(ref => ref.sourceSchema === targetSchema) + this.#resolveRef(targetSchemaRef, refs) + } + for (const key in targetSchema) { + if (key === '$id') continue + if (sourceSchema[key] !== undefined) { + if (deepEqual(sourceSchema[key], targetSchema[key])) continue + throw new Error( + `Cannot resolve ref "${ref.ref}". Property "${key}" is already exist in schema "${ref.sourceSchemaId}".` + ) + } + sourceSchema[key] = targetSchema[key] + } + ref.isResolved = true + } + + #insertSchemaBySchemaId (schema, schemaId) { + const foundSchema = this.#schemas[schemaId] + if (foundSchema !== undefined) { + if (this.#allowEqualDuplicates && deepEqual(schema, foundSchema.schema)) return + throw new Error(`There is already another schema with id "${schemaId}".`) + } + this.#schemas[schemaId] = { schema, anchors: {}, refs: [] } + } + + #insertSchemaByAnchor (schema, schemaId, anchor) { + const { anchors } = this.#schemas[schemaId] + if (anchors[anchor] !== undefined) { + throw new Error(`There is already another anchor "${anchor}" in a schema "${schemaId}".`) + } + anchors[anchor] = schema + } + + #insertDerefSchemaBySchemaId (schema, schemaId) { + const foundSchema = this.#derefSchemas[schemaId] + if (foundSchema !== undefined) return + + this.#derefSchemas[schemaId] = { schema, anchors: {} } + } + + #insertDerefSchemaByAnchor (schema, schemaId, anchor) { + const { anchors } = this.#derefSchemas[schemaId] + anchors[anchor] = schema + } +} + +function getDataByJSONPointer (data, jsonPointer) { + const parts = jsonPointer.split('/') + let current = data + for (const part of parts) { + if (part === '' || part === '#') continue + if (typeof current !== 'object' || current === null) { + return null + } + current = current[part] + } + return current ?? null +} + +export { RefResolver } diff --git a/package.json b/package.json index ad495e4..e238d52 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,31 @@ "main": "index.js", "type": "commonjs", "types": "types/index.d.ts", + "exports": { + ".": { + "import": { + "default": "./index.mjs", + "types": "./types/index.d.mts" + }, + "default": { + "default": "./index.js", + "types": "./types/index.d.ts" + } + }, + "./*": "./*" + }, "scripts": { "lint": "eslint", "lint:fix": "eslint --fix", "test:unit": "c8 --100 node --test", "test:typescript": "tsd", - "test": "npm run lint && npm run test:unit && npm run test:typescript" + "test": "npm run lint && npm run test:unit && npm run test:typescript", + "build": "node ./scripts/generate-esm.mjs", + "version": "npm run build" }, "precommit": [ "lint", + "build", "test" ], "repository": { diff --git a/scripts/generate-esm.mjs b/scripts/generate-esm.mjs new file mode 100644 index 0000000..bbf1bac --- /dev/null +++ b/scripts/generate-esm.mjs @@ -0,0 +1,40 @@ +import fs from 'node:fs' + +const CWD = new URL('../', import.meta.url) + +// list of all inputs/outputs, with replacements +const SOURCE_FILES = [ + { + source: './index.js', + types: './types/index.d.ts', + replacements: [ + [/const\s+([^=]+)=\s*require\(([^)]+)\)/g, (_, spec, moduleName) => { + return `import ${spec.trim().replace(/:\s*/g, ' as ')} from ${moduleName.trim()}` + }], + [/module\.exports\s*=\s*({[^}]+})/, 'export $1'], + ] + } +] + +// Build script +for (const { source, types, replacements } of SOURCE_FILES) { + // replace + let output = fs.readFileSync(new URL(source, CWD), 'utf8') + for (const [search, replaceValue] of replacements) { + output = output.replace(search, replaceValue) + } + + // verify + if (output.includes('require(')) { + throw new Error('Could not convert all require() statements') + } + if (output.includes('module.exports')) { + throw new Error('Could not convert module.exports statement') + } + + // write source + fs.writeFileSync(new URL(source.replace(/\.js$/, '.mjs'), CWD), output) + + // write types + fs.copyFileSync(new URL(types, CWD), new URL(types.replace(/\.d\.ts$/, '.d.mts'), CWD)) +} diff --git a/types/index.d.mts b/types/index.d.mts new file mode 100644 index 0000000..a802c39 --- /dev/null +++ b/types/index.d.mts @@ -0,0 +1,67 @@ +/** + * RefResolver class is used to resolve JSON references. + * @class + * @constructor + */ +declare class RefResolver { + /** + * @param {object} opts - Options for the resolver. + * @param {boolean} opts.allowEqualDuplicates - Whether to allow schemas with equal ids to be added to the resolver. + */ + constructor (opts?: { allowEqualDuplicates?: boolean }) + + /** + * Adds the given schema to the resolver. + * @param {any} schema - The schema to be added. + * @param {string} schemaId - The default schema id of the schema to be added. + */ + addSchema (schema: any, schemaId?: string): void + + /** + * Returns the schema by the given schema id and jsonPointer. + * If jsonPointer is not provided, returns the root schema. + * @param {string} schemaId - The schema id of the schema to be returned. + * @param {string} jsonPointer - The jsonPointer of the schema to be returned. + * @returns {any | null} The schema by the given schema id and jsonPointer. + */ + getSchema (schemaId: string, jsonPointer?: string): any | null + + /** + * Returns true if the schema by the given schema id is added to the resolver. + * @param {string} schemaId - The schema id of the schema to be checked. + * @returns {boolean} True if the schema by the given schema id is added to the resolver. + */ + hasSchema (schemaId: string): boolean + + /** + * Returns the schema references of the schema by the given schema id. + * @param {string} schemaId - The schema id of the schema whose references are to be returned. + * @returns {Array<{ schemaId: string; jsonPointer: string }>} The schema references of the schema by the given schema id. + */ + getSchemaRefs (schemaId: string): { schemaId: string; jsonPointer: string }[] + + /** + * Returns all the schema dependencies of the schema by the given schema id. + * @param {string} schemaId - The schema id of the schema whose dependencies are to be returned. + * @returns {object} The schema dependencies of the schema by the given schema id. + */ + getSchemaDependencies (schemaId: string): { [key: string]: any } + + /** + * Dereferences the schema by the given schema id. + * @param {string} schemaId - The schema id of the schema to be dereferenced. + */ + derefSchema (schemaId: string): void + + /** + * Returns the dereferenced schema by the given schema id and jsonPointer. + * If jsonPointer is not provided, returns the dereferenced root schema. + * If the schema is not dereferenced yet, dereferences it first. + * @param {string} schemaId - The schema id of the schema to be returned. + * @param {string} jsonPointer - The jsonPointer of the schema to be returned. + * @returns {any | null} The dereferenced schema by the given schema id and jsonPointer. + */ + getDerefSchema (schemaId: string, jsonPointer?: string): any | null +} + +export { RefResolver }