Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add ESM build #23

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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 .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'
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dequal ships ESM, so this package would benefit from upgrading, too


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: [
Copy link
Author

@drwpow drwpow Jan 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: RegEx replacements can be brittle in larger setups, but this seemed ideal for this package’s current lightweight setup. It’s easier to modify simple string replacements than burdening this package with some robust AST traversal + mutation + reassembly script… that only replaces a couple lines in the end.

Of course, alternatives can be discussed!

[/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))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: this seems unnecessary—copying existing .d.ts declarations 1:1. But TypeScript does require all-new declaration types for different file extensions, i.e. it won’t apply index.d.ts to both index.js and index.mjs (not even with exports—I’ve tried). This is unfortunately TypeScript behavior, and it’s not easy to bypass.

Alternately, there could be some clever type forwarding, e.g. export * from './index', but in addition to being harder to autogenerate, something could slip through the cracks compared to copying 1:1

}
Loading
Loading