Skip to content

Commit

Permalink
Add ESM build
Browse files Browse the repository at this point in the history
  • Loading branch information
drwpow committed Jan 12, 2025
1 parent 76d2e3c commit 89629cf
Show file tree
Hide file tree
Showing 5 changed files with 413 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.gitattributes
.github
scripts
test
**/*.test-d.ts
284 changes: 284 additions & 0 deletions index.mjs
Original file line number Diff line number Diff line change
@@ -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 }
18 changes: 17 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
40 changes: 40 additions & 0 deletions scripts/generate-esm.mjs
Original file line number Diff line number Diff line change
@@ -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))
}
Loading

0 comments on commit 89629cf

Please sign in to comment.