@amritk/helpers
Advanced tools
| /** Boolean expression that is TRUE when `valueExpr` is a valid multiple of `divisor`. */ | ||
| export declare const multipleOfPassExpr: (valueExpr: string, divisor: number) => string; | ||
| /** Boolean expression that is TRUE when `valueExpr` is NOT a multiple of `divisor` (the error condition). */ | ||
| export declare const multipleOfFailExpr: (valueExpr: string, divisor: number) => string; |
| /** | ||
| * Emits code for a JSON Schema `multipleOf` check that agrees with the runtime | ||
| * interpreter (`packages/runtime-validators/src/interpreter/interpret.ts`). | ||
| * | ||
| * The naive `x % m === 0` is float-wrong: IEEE-754 makes `0.3 % 0.1` evaluate to | ||
| * `0.0999…`, so `0.3` would spuriously fail `multipleOf: 0.1`. The interpreter | ||
| * instead divides and compares the quotient to its nearest integer within a | ||
| * tolerance that *scales with the quotient's magnitude* — a fixed epsilon falsely | ||
| * rejects large values (e.g. `1234567.89` against `multipleOf: 0.01`) whose | ||
| * representation error in the quotient already exceeds it. Keeping the emitted | ||
| * check identical to the interpreter's keeps generated validators/parsers and the | ||
| * interpreter from disagreeing on the same document. | ||
| * | ||
| * `valueExpr` is inlined three times, so it must be a side-effect-free expression | ||
| * (a property read or a cached variable — which is all the generators ever pass). | ||
| */ | ||
| const quotientTolerance = (valueExpr, divisor) => { | ||
| const q = `${valueExpr} / ${divisor}`; | ||
| return { q, tol: `1e-8 * Math.max(1, Math.abs(${q}))` }; | ||
| }; | ||
| /** Boolean expression that is TRUE when `valueExpr` is a valid multiple of `divisor`. */ | ||
| export const multipleOfPassExpr = (valueExpr, divisor) => { | ||
| const { q, tol } = quotientTolerance(valueExpr, divisor); | ||
| return `Math.abs(${q} - Math.round(${q})) <= ${tol}`; | ||
| }; | ||
| /** Boolean expression that is TRUE when `valueExpr` is NOT a multiple of `divisor` (the error condition). */ | ||
| export const multipleOfFailExpr = (valueExpr, divisor) => { | ||
| const { q, tol } = quotientTolerance(valueExpr, divisor); | ||
| return `Math.abs(${q} - Math.round(${q})) > ${tol}`; | ||
| }; |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| import { isSchemaObject } from './schema-guards' | ||
| /** | ||
| * Builds a map of $dynamicRef anchor values to their corresponding $ref paths. | ||
| * | ||
| * JSON Schema 2020-12 uses $dynamicAnchor and $dynamicRef for late-binding references. | ||
| * In the OpenAPI spec, $dynamicAnchor: "meta" on the schema definition allows properties | ||
| * like media-type.schema to reference it via $dynamicRef: "#meta". | ||
| * | ||
| * This function scans all $defs for entries with $dynamicAnchor and builds a lookup | ||
| * so we can convert $dynamicRef values to concrete $ref paths. | ||
| * | ||
| * @example | ||
| * // Given a schema with $defs.schema having $dynamicAnchor: "meta" | ||
| * buildDynamicRefMap(rootSchema) | ||
| * // Returns: { "#meta": "#/$defs/schema" } | ||
| */ | ||
| export const buildDynamicRefMap = (rootSchema: JSONSchema): Record<string, string> => { | ||
| const map: Record<string, string> = {} | ||
| if (!isSchemaObject(rootSchema) || !('$defs' in rootSchema)) { | ||
| return map | ||
| } | ||
| const defs = rootSchema.$defs as Record<string, unknown> | ||
| for (const [key, value] of Object.entries(defs)) { | ||
| if ( | ||
| typeof value === 'object' && | ||
| value !== null && | ||
| '$dynamicAnchor' in value && | ||
| typeof (value as Record<string, unknown>)['$dynamicAnchor'] === 'string' | ||
| ) { | ||
| const anchor = (value as Record<string, unknown>)['$dynamicAnchor'] as string | ||
| map[`#${anchor}`] = `#/$defs/${key}` | ||
| } | ||
| } | ||
| return map | ||
| } |
| import { isObject } from './is-object' | ||
| /** | ||
| * Converts an arbitrary title string into a PascalCase TypeScript identifier. | ||
| * Splits on any run of non-alphanumeric characters, capitalizes the first | ||
| * letter of each word while preserving the rest (so acronyms like "API" or | ||
| * "JSON" survive intact), and drops leading digits since an identifier may not | ||
| * start with a number. | ||
| */ | ||
| const titleToPascalCase = (title: string): string => { | ||
| const words = title.split(/[^a-zA-Z0-9]+/).filter(Boolean) | ||
| const pascal = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join('') | ||
| return pascal.replace(/^\d+/, '') | ||
| } | ||
| /** | ||
| * Derives the root type name for a generated schema from its `title` keyword. | ||
| * | ||
| * We name the root after the schema itself instead of a generic "Document" so | ||
| * the generated types and parsers read naturally (e.g. an OpenAPI schema yields | ||
| * `OpenApi` / `parseOpenApi`). When the schema has no usable `title`, we fall | ||
| * back to "Document" to keep output deterministic. | ||
| * | ||
| * @param schema - The root JSON Schema. A boolean schema or one without a | ||
| * string `title` falls back to the default. | ||
| * @returns A PascalCase type name derived from the title, or "Document". | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * deriveRootTypeName({ title: 'OpenAPI Document' }) // 'OpenAPIDocument' | ||
| * deriveRootTypeName({ title: 'my-config' }) // 'MyConfig' | ||
| * deriveRootTypeName({ type: 'object' }) // 'Document' | ||
| * ``` | ||
| */ | ||
| export const deriveRootTypeName = (schema: unknown): string => { | ||
| if (!isObject(schema) || typeof schema['title'] !== 'string') { | ||
| return 'Document' | ||
| } | ||
| const name = titleToPascalCase(schema['title']) | ||
| return name || 'Document' | ||
| } |
| /** | ||
| * Escapes a JSON Schema `pattern` so it can be embedded between the slashes of | ||
| * a generated regex literal (`/…/`), and validates that the pattern is a legal | ||
| * regex at generation time. | ||
| * | ||
| * A `pattern` is an ECMA-262 regex *body*, and the generated text goes into a | ||
| * regex literal — not a string literal — so backslashes are regex syntax and | ||
| * must be left exactly as-is (doubling `\d` to `\\d` would change it from "a | ||
| * digit" to "a literal backslash followed by d"). Two things would otherwise | ||
| * corrupt the surrounding literal: | ||
| * - an *unescaped* `/`, which would close the literal early; and | ||
| * - a raw line terminator (LF, CR, U+2028, U+2029), which is not allowed | ||
| * inside a regex literal and would split the emitted `/…/` across lines (a | ||
| * syntax error). Each is rewritten to its backslash escape, which matches | ||
| * the identical character, so the regex's meaning is unchanged. | ||
| * | ||
| * Additionally, an *invalid* pattern (e.g. `([`) would be emitted verbatim as | ||
| * `/([/…` and produce output that does not parse. We compile the pattern with | ||
| * `new RegExp` here and throw a clear generator-time error instead, so a bad | ||
| * schema fails loudly during generation rather than emitting broken code. | ||
| * | ||
| * @example | ||
| * escapeRegexPattern('\\d{4}/\\d{2}') // → '\\d{4}\\/\\d{2}' (i.e. \d{4}\/\d{2}) | ||
| * @throws if `pattern` is not a valid regular expression. | ||
| */ | ||
| export const escapeRegexPattern = (pattern: string): string => { | ||
| // Validate at generation time — an invalid pattern must fail here, not emit | ||
| // a `/([/` literal that breaks the generated file. | ||
| try { | ||
| new RegExp(pattern) | ||
| } catch (error) { | ||
| throw new Error( | ||
| `Invalid regex pattern ${JSON.stringify(pattern)}: ${error instanceof Error ? error.message : String(error)}`, | ||
| ) | ||
| } | ||
| // Line terminators, keyed by code point, and the backslash escape each maps to. | ||
| // These are the four characters disallowed *raw* inside a regex literal. | ||
| const lineTerminatorEscapes: Record<number, string> = { | ||
| 0x0a: '\\n', // LF | ||
| 0x0d: '\\r', // CR | ||
| 0x2028: '\\u2028', // LINE SEPARATOR | ||
| 0x2029: '\\u2029', // PARAGRAPH SEPARATOR | ||
| } | ||
| // Match either an escape sequence (`\` + any char, kept verbatim) or a single | ||
| // character that would corrupt the literal (a bare `/` or a line terminator). | ||
| // The line terminators are written as `\u….` escapes so this source file is | ||
| // itself free of the raw characters it guards against. Consuming escape pairs | ||
| // first means the slash in `\/` is never seen as bare, so it is not | ||
| // double-escaped. | ||
| return pattern.replace(/\\[\s\S]|[/\n\r\u2028\u2029]/g, (match) => { | ||
| if (match === '/') return '\\/' | ||
| const terminatorEscape = lineTerminatorEscapes[match.charCodeAt(0)] | ||
| if (terminatorEscape !== undefined && match.length === 1) return terminatorEscape | ||
| // An escape pair like `\/` or `\d`: keep exactly as authored. | ||
| return match | ||
| }) | ||
| } |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| /** | ||
| * Collects `#/$defs/<key>` refs for every root definition that carries a | ||
| * `$dynamicAnchor`. | ||
| * | ||
| * These definitions are reachable only through `$dynamicRef`, which the ref | ||
| * walker does not follow directly (a `$dynamicRef` is rewritten to a concrete | ||
| * `$ref` by `resolveDynamicRefs` *after* the schema is extracted, so plain | ||
| * `extractRefs` never sees it). Seeding them explicitly guarantees a file is | ||
| * generated for each dynamic-anchor target — without this, a generator would | ||
| * emit code that imports a type whose file was never produced. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * // $defs.schema has $dynamicAnchor: "meta" | ||
| * extractDynamicAnchorDefs(rootSchema) // ['#/$defs/schema'] | ||
| * ``` | ||
| */ | ||
| export const extractDynamicAnchorDefs = (schema: JSONSchema): string[] => { | ||
| const refs: string[] = [] | ||
| if (typeof schema !== 'object' || schema === null) return refs | ||
| if (!('$defs' in schema) || typeof schema['$defs'] !== 'object' || schema['$defs'] === null) return refs | ||
| for (const [key, value] of Object.entries(schema['$defs'])) { | ||
| if (typeof value === 'object' && value !== null && '$dynamicAnchor' in value) { | ||
| refs.push(`#/$defs/${key}`) | ||
| } | ||
| } | ||
| return refs | ||
| } |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| /** | ||
| * Returns true if a $ref value should be queued for processing. | ||
| * | ||
| * Accepted forms: | ||
| * - Internal: `#/$defs/foo` or `#/definitions/foo` | ||
| * - URI key: `http://example.com/foo.json` (no fragment, or empty fragment) | ||
| * - URI with fragment: `http://example.com/foo.json#/definitions/bar` | ||
| * | ||
| * Excluded: | ||
| * - `#` alone (self-reference, not a standalone definition) | ||
| * - Relative path refs (e.g. `/components/messages/foo`) — these point into | ||
| * example data in the schema document, not into type definitions | ||
| */ | ||
| const isResolvableRef = (ref: string): boolean => { | ||
| if (ref === '#') return false | ||
| if (ref.startsWith('#')) return true | ||
| if (ref.startsWith('http://') || ref.startsWith('https://')) return true | ||
| return false | ||
| } | ||
| /** | ||
| * Extracts all $ref values from a JSON Schema recursively. | ||
| * Returns both internal (`#`-prefixed) and URI refs so the build pipeline | ||
| * can resolve and generate files for all referenced definitions. | ||
| * | ||
| * @param schema - The JSON Schema to extract refs from | ||
| * @returns A Set of unique ref strings found in the schema | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const schema = { | ||
| * type: 'object', | ||
| * properties: { | ||
| * contact: { $ref: '#/$defs/contact' }, | ||
| * channel: { $ref: 'http://example.com/channel.json' }, | ||
| * } | ||
| * } | ||
| * const refs = extractRefs(schema) | ||
| * // refs = Set(['#/$defs/contact', 'http://example.com/channel.json']) | ||
| * ``` | ||
| */ | ||
| export const extractRefs = (schema: JSONSchema): Set<string> => { | ||
| const refs = new Set<string>() | ||
| const traverse = (obj: unknown): void => { | ||
| if (typeof obj !== 'object' || obj === null) { | ||
| return | ||
| } | ||
| if (Array.isArray(obj)) { | ||
| for (const item of obj) { | ||
| traverse(item) | ||
| } | ||
| return | ||
| } | ||
| const record = obj as Record<string, unknown> | ||
| if ('$ref' in record && typeof record['$ref'] === 'string' && isResolvableRef(record['$ref'] as string)) { | ||
| refs.add(record['$ref'] as string) | ||
| } | ||
| // Recursively traverse all properties using for...in to avoid intermediate array allocation | ||
| for (const key in record) { | ||
| traverse(record[key]) | ||
| } | ||
| } | ||
| traverse(schema) | ||
| return refs | ||
| } |
| /** A generated file: its name (with extension) and TypeScript source. */ | ||
| export type IndexBarrelFile = { | ||
| filename: string | ||
| content: string | ||
| } | ||
| /** Options controlling how the barrel re-exports each module. */ | ||
| export type GenerateIndexBarrelOptions = { | ||
| /** | ||
| * When true, every re-export is type-only (`export type { ... }`). Used by the | ||
| * types-only parser output, where no runtime values exist to re-export. | ||
| * Defaults to `false`. | ||
| */ | ||
| readonly typesOnly?: boolean | ||
| } | ||
| // Generated files declare their public surface with these two forms, so we can | ||
| // recover the export names from the source text without parsing it. | ||
| const TYPE_EXPORT_RE = /^export type (\w+)/gm | ||
| const CONST_EXPORT_RE = /^export const (\w+)/gm | ||
| /** | ||
| * Builds the `index.ts` barrel that re-exports every generated module. This is | ||
| * the shared version of the near-identical barrel each generator used to build | ||
| * inline: it scans each file's source for `export type` / `export const` | ||
| * declarations and emits one re-export line per module, sorted by filename. | ||
| * | ||
| * Files under `_helpers/` are internal runtime helpers (embedded-mode output) | ||
| * and are never re-exported. Modules that expose nothing are skipped. | ||
| * | ||
| * @param files - The generated files to barrel (the `index.ts` itself excluded). | ||
| * @param options - See {@link GenerateIndexBarrelOptions}. | ||
| * @returns The `index.ts` file content. | ||
| */ | ||
| export const generateIndexBarrel = (files: IndexBarrelFile[], options: GenerateIndexBarrelOptions = {}): string => { | ||
| const typesOnly = options.typesOnly ?? false | ||
| const sortedFiles = files | ||
| .filter((file) => !file.filename.startsWith('_helpers/')) | ||
| .sort((a, b) => a.filename.localeCompare(b.filename)) | ||
| let indexContent = '' | ||
| for (const file of sortedFiles) { | ||
| const moduleName = file.filename.replace(/\.ts$/, '') | ||
| const typeNames: string[] = [] | ||
| const constNames: string[] = [] | ||
| for (const match of file.content.matchAll(TYPE_EXPORT_RE)) typeNames.push(match[1] as string) | ||
| for (const match of file.content.matchAll(CONST_EXPORT_RE)) constNames.push(match[1] as string) | ||
| if (typeNames.length === 0 && constNames.length === 0) continue | ||
| // `.js` extension so the barrel resolves under Node ESM, not only Bun. | ||
| if (typesOnly) { | ||
| indexContent += `export type { ${typeNames.join(', ')} } from './${moduleName}.js';\n` | ||
| } else { | ||
| const typeExports = typeNames.map((name) => `type ${name}`) | ||
| indexContent += `export { ${[...typeExports, ...constNames].join(', ')} } from './${moduleName}.js';\n` | ||
| } | ||
| } | ||
| return indexContent | ||
| } |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| import { getMjstBrand, getMjstInstanceOf, getMjstPrimitive } from './mjst-extension' | ||
| import { refToName } from './ref-to-name' | ||
| import { safeKey } from './safe-accessor' | ||
| import { isObjectSchema, isSchemaObject } from './schema-guards' | ||
| type ConditionalObjectResult = { | ||
| schema: JSONSchema.Object | ||
| thenRef: string | null | ||
| } | ||
| /** Options controlling generated type output. */ | ||
| export type TypeOptions = { | ||
| /** When true, every property, array, and record in the generated types is emitted as readonly. */ | ||
| readonly readonly?: boolean | ||
| /** | ||
| * Suffix appended to every generated type name derived from a `$ref`. | ||
| * Defaults to `''` (no suffix). Set to e.g. `'Object'` to emit `ContactObject`. | ||
| */ | ||
| readonly typeSuffix?: string | ||
| } | ||
| const getConditionalObjectSchema = (schema: JSONSchema): ConditionalObjectResult | null => { | ||
| if (!isSchemaObject(schema)) { | ||
| return null | ||
| } | ||
| if (!('if' in schema) || !('then' in schema)) { | ||
| return null | ||
| } | ||
| const ifSchema = schema.if | ||
| const thenSchema = schema.then | ||
| if (!isSchemaObject(ifSchema) || !isSchemaObject(thenSchema)) { | ||
| return null | ||
| } | ||
| const ifProperties = ifSchema.properties | ||
| const thenProperties = thenSchema.properties | ||
| const hasIfProperties = ifProperties && typeof ifProperties === 'object' | ||
| const hasThenProperties = thenProperties && typeof thenProperties === 'object' | ||
| if (!hasIfProperties && !hasThenProperties) { | ||
| return null | ||
| } | ||
| const properties = { | ||
| ...(hasIfProperties ? ifProperties : {}), | ||
| ...(hasThenProperties ? thenProperties : {}), | ||
| } | ||
| const required = new Set<string>() | ||
| if (Array.isArray(ifSchema.required)) { | ||
| for (const key of ifSchema.required) { | ||
| required.add(key) | ||
| } | ||
| } | ||
| if (hasIfProperties) { | ||
| for (const key in ifProperties) { | ||
| required.add(key) | ||
| } | ||
| } | ||
| if (Array.isArray(thenSchema.required)) { | ||
| for (const key of thenSchema.required) { | ||
| required.add(key) | ||
| } | ||
| } | ||
| if (hasThenProperties) { | ||
| for (const key in thenProperties) { | ||
| required.add(key) | ||
| } | ||
| } | ||
| const thenRef = typeof thenSchema.$ref === 'string' ? thenSchema.$ref : null | ||
| return { | ||
| schema: { | ||
| type: 'object', | ||
| properties, | ||
| ...(required.size > 0 ? { required: Array.from(required) } : {}), | ||
| }, | ||
| thenRef, | ||
| } | ||
| } | ||
| const isObjectLikeSchema = (schema: JSONSchema): schema is JSONSchema.Object => { | ||
| if (!isSchemaObject(schema)) { | ||
| return false | ||
| } | ||
| if (isObjectSchema(schema)) { | ||
| return true | ||
| } | ||
| return 'patternProperties' in schema || 'additionalProperties' in schema || ('if' in schema && 'then' in schema) | ||
| } | ||
| const getBooleanSubSchemaType = (schema: boolean): string => { | ||
| return schema ? 'unknown' : 'never' | ||
| } | ||
| /** | ||
| * Renders a possibly multi-line description as JSDoc body lines, prefixing each | ||
| * line with `${indent}* `. Blank lines become a bare `${indent}*` so we never | ||
| * emit trailing whitespace. Without this, embedded newlines would leave | ||
| * continuation lines unprefixed and break the comment block. | ||
| */ | ||
| const renderJsDocBody = (description: string, indent: string): string => | ||
| description | ||
| .split('\n') | ||
| .map((line) => (line.length > 0 ? `${indent}* ${line}\n` : `${indent}*\n`)) | ||
| .join('') | ||
| const buildJsDocBlock = (title: string, description: string, commentUrl?: string): string => { | ||
| let block = `/**\n` | ||
| block += `* ${title}\n` | ||
| block += `*\n` | ||
| block += renderJsDocBody(description, '') | ||
| if (commentUrl?.startsWith('http')) { | ||
| block += `* \n` | ||
| block += `* @see {@link ${commentUrl}}\n` | ||
| } | ||
| block += `*/\n` | ||
| return block | ||
| } | ||
| /** | ||
| * Builds the inline JSDoc comment that precedes a generated property. A | ||
| * single-line description stays compact (` /** text *\/`); a multi-line | ||
| * description expands into an asterisk-prefixed block so every line is | ||
| * commented. The returned string always ends with a newline, ready for the | ||
| * property declaration to follow. | ||
| */ | ||
| const buildInlinePropertyComment = (description: string): string => { | ||
| if (!description.includes('\n')) { | ||
| return ` /** ${description} */\n` | ||
| } | ||
| return ` /**\n${renderJsDocBody(description, ' ')} */\n` | ||
| } | ||
| /** | ||
| * Converts a JSON Schema type to its TypeScript equivalent, applying any | ||
| * `x-mjst` brand. Branding is type-level only, so we compute the underlying | ||
| * type and intersect it with a unique brand marker. This is the recursion entry | ||
| * point, so branded nested fields are wrapped too. | ||
| */ | ||
| const getTypeScriptType = (schema: JSONSchema, options: TypeOptions = {}): string => { | ||
| const base = getUnbrandedType(schema, options) | ||
| const brand = getMjstBrand(schema) | ||
| return brand ? `(${base} & { readonly __brand: '${brand}' })` : base | ||
| } | ||
| /** Wraps a `Record<...>` in `Readonly<...>` when readonly output is requested. */ | ||
| const recordType = (keyType: string, valueType: string, options: TypeOptions): string => | ||
| options.readonly ? `Readonly<Record<${keyType}, ${valueType}>>` : `Record<${keyType}, ${valueType}>` | ||
| /** | ||
| * Builds the `Record<…>` type for a schema's `patternProperties`. Every pattern's | ||
| * value type is unioned (deduplicated) rather than only the first being used, so | ||
| * `{ '^a': string-schema, '^b': number-schema }` becomes `Record<string, string | | ||
| * number>` instead of silently dropping the second pattern. The template-literal | ||
| * `` `x-${string}` `` key is kept only when the sole pattern is the `^x-` vendor | ||
| * convention. Returns `undefined` when there are no usable patterns. | ||
| */ | ||
| const patternPropertiesRecordType = ( | ||
| patternProperties: Record<string, JSONSchema | boolean>, | ||
| options: TypeOptions, | ||
| ): string | undefined => { | ||
| const entries = Object.entries(patternProperties) | ||
| if (entries.length === 0) return undefined | ||
| const seen = new Set<string>() | ||
| const valueTypes: string[] = [] | ||
| for (const [, value] of entries) { | ||
| const valueType = typeof value === 'boolean' ? getBooleanSubSchemaType(value) : getTypeScriptType(value, options) | ||
| if (!seen.has(valueType)) { | ||
| seen.add(valueType) | ||
| valueTypes.push(valueType) | ||
| } | ||
| } | ||
| if (valueTypes.length === 0) return undefined | ||
| const valueType = valueTypes.join(' | ') | ||
| // The `^x-` vendor-extension convention maps to a template-literal key, but only | ||
| // when it is the single pattern (a union of key patterns can't be expressed). | ||
| const keyType = entries.length === 1 && entries[0]?.[0] === '^x-' ? '`x-${string}`' : 'string' | ||
| return recordType(keyType, valueType, options) | ||
| } | ||
| /** | ||
| * Converts a JSON Schema type to its TypeScript equivalent, ignoring any brand. | ||
| * Recursively handles nested objects and arrays. | ||
| */ | ||
| const getUnbrandedType = (schema: JSONSchema, options: TypeOptions = {}): string => { | ||
| // Boolean schema: `true` means any value is valid (unknown), `false` means no value is valid (never) | ||
| if (typeof schema === 'boolean') { | ||
| return getBooleanSubSchemaType(schema) | ||
| } | ||
| // Check if schema is an object (not a boolean schema) | ||
| if (typeof schema !== 'object' || schema === null) { | ||
| return 'unknown' | ||
| } | ||
| // An x-mjst instanceOf hint means the value is a runtime class (e.g. Date) | ||
| // that JSON Schema cannot describe — emit the class name as the type directly. | ||
| const instanceOf = getMjstInstanceOf(schema) | ||
| if (instanceOf) { | ||
| return instanceOf | ||
| } | ||
| // An x-mjst primitive hint (e.g. bigint) names a non-JSON primitive — emit it | ||
| // directly as the TypeScript type. | ||
| const primitive = getMjstPrimitive(schema) | ||
| if (primitive) { | ||
| return primitive | ||
| } | ||
| // Handle $ref | ||
| if (schema.$ref) { | ||
| // External refs (e.g. http://json-schema.org/...) cannot be resolved locally — treat as unknown | ||
| if (!schema.$ref.startsWith('#')) { | ||
| return 'unknown' | ||
| } | ||
| return refToName(schema.$ref, options.typeSuffix) | ||
| } | ||
| // Handle $dynamicRef (used for recursive schemas) | ||
| if (schema.$dynamicRef) { | ||
| // For #meta, this refers to the Schema type itself (JSON Schema 2020-12 $dynamicAnchor pattern) | ||
| if (schema.$dynamicRef === '#meta') { | ||
| return `Schema${options.typeSuffix ?? ''}` | ||
| } | ||
| return refToName(schema.$dynamicRef, options.typeSuffix) | ||
| } | ||
| // Handle const - literal type | ||
| if (schema.const !== undefined) { | ||
| return JSON.stringify(schema.const) | ||
| } | ||
| // Handle enum - union of literal types | ||
| if (schema.enum && schema.enum.length > 0) { | ||
| if (schema.enum.length === 1) { | ||
| return JSON.stringify(schema.enum[0]) | ||
| } | ||
| let enumUnion = JSON.stringify(schema.enum[0]) | ||
| for (let i = 1; i < schema.enum.length; i++) { | ||
| enumUnion += ' | ' + JSON.stringify(schema.enum[i]) | ||
| } | ||
| return enumUnion | ||
| } | ||
| // Handle union types (oneOf, anyOf) - check this before returning unknown | ||
| if (schema.oneOf && Array.isArray(schema.oneOf) && schema.oneOf.length > 0) { | ||
| // schema.oneOf[0] is safe: we guard with .length > 0 above | ||
| let oneOfUnion = getTypeScriptType(schema.oneOf[0]!, options) | ||
| for (let i = 1; i < schema.oneOf.length; i++) { | ||
| oneOfUnion += ' | ' + getTypeScriptType(schema.oneOf[i]!, options) | ||
| } | ||
| return oneOfUnion | ||
| } | ||
| if (schema.anyOf && Array.isArray(schema.anyOf) && schema.anyOf.length > 0) { | ||
| // schema.anyOf[0] is safe: we guard with .length > 0 above | ||
| let anyOfUnion = getTypeScriptType(schema.anyOf[0]!, options) | ||
| for (let i = 1; i < schema.anyOf.length; i++) { | ||
| anyOfUnion += ' | ' + getTypeScriptType(schema.anyOf[i]!, options) | ||
| } | ||
| return anyOfUnion | ||
| } | ||
| // Handle allOf (intersection types) | ||
| if (schema.allOf && Array.isArray(schema.allOf) && schema.allOf.length > 0) { | ||
| // schema.allOf[0] is safe: we guard with .length > 0 above | ||
| let intersectionTypes = getTypeScriptType(schema.allOf[0]!, options) | ||
| for (let i = 1; i < schema.allOf.length; i++) { | ||
| intersectionTypes += ' & ' + getTypeScriptType(schema.allOf[i]!, options) | ||
| } | ||
| return intersectionTypes | ||
| } | ||
| // Handle object-like conditional schemas that use if/then without declaring type | ||
| const conditionalResult = getConditionalObjectSchema(schema) | ||
| if (conditionalResult) { | ||
| const baseType = getTypeScriptType(conditionalResult.schema, options) | ||
| if (conditionalResult.thenRef) { | ||
| return `(${baseType}) & ${refToName(conditionalResult.thenRef, options.typeSuffix)}` | ||
| } | ||
| return baseType | ||
| } | ||
| // No type so we return unknown | ||
| if (!schema.type) { | ||
| if (schema.additionalProperties !== undefined) { | ||
| if (typeof schema.additionalProperties === 'boolean') { | ||
| return recordType('string', getBooleanSubSchemaType(schema.additionalProperties), options) | ||
| } | ||
| return recordType('string', getTypeScriptType(schema.additionalProperties, options), options) | ||
| } | ||
| if (schema.patternProperties && typeof schema.patternProperties === 'object') { | ||
| const record = patternPropertiesRecordType(schema.patternProperties, options) | ||
| if (record !== undefined) return record | ||
| } | ||
| if (schema.default !== undefined) { | ||
| if (typeof schema.default === 'string') { | ||
| return 'string' | ||
| } | ||
| if (typeof schema.default === 'number') { | ||
| return 'number' | ||
| } | ||
| if (typeof schema.default === 'boolean') { | ||
| return 'boolean' | ||
| } | ||
| } | ||
| return 'unknown' | ||
| } | ||
| // Handle type as an array (union of types) | ||
| if (Array.isArray(schema.type)) { | ||
| const mapType = (t: string): string => { | ||
| switch (t) { | ||
| case 'string': | ||
| return 'string' | ||
| case 'number': | ||
| case 'integer': | ||
| return 'number' | ||
| case 'boolean': | ||
| return 'boolean' | ||
| case 'null': | ||
| return 'null' | ||
| case 'array': | ||
| return 'unknown[]' | ||
| case 'object': | ||
| return 'Record<string, unknown>' | ||
| default: | ||
| return 'unknown' | ||
| } | ||
| } | ||
| let typeUnion = mapType(schema.type[0]) | ||
| for (let i = 1; i < schema.type.length; i++) { | ||
| typeUnion += ' | ' + mapType(schema.type[i]) | ||
| } | ||
| return typeUnion | ||
| } | ||
| switch (schema.type) { | ||
| // String | ||
| case 'string': | ||
| return 'string' | ||
| // Number | ||
| case 'number': | ||
| case 'integer': | ||
| return 'number' | ||
| // Boolean | ||
| case 'boolean': | ||
| return 'boolean' | ||
| // Array | ||
| case 'array': | ||
| if (schema.items) { | ||
| const itemType = getTypeScriptType(schema.items, options) | ||
| // Wrap union types in parentheses so `(A | B)[]` is not misread as `A | B[]` | ||
| const wrappedItemType = itemType.includes(' | ') ? `(${itemType})` : itemType | ||
| return options.readonly ? `readonly ${wrappedItemType}[]` : `${wrappedItemType}[]` | ||
| } | ||
| return options.readonly ? 'readonly unknown[]' : 'unknown[]' | ||
| // Object | ||
| case 'object': | ||
| if (schema.properties) { | ||
| const readonlyPrefix = options.readonly ? 'readonly ' : '' | ||
| // Build the required-key set once instead of an O(required) `includes` | ||
| // per property (O(properties × required) for a wide object). | ||
| const requiredSet = new Set<string>(Array.isArray(schema.required) ? schema.required : []) | ||
| const hasDescriptions = Object.values(schema.properties).some( | ||
| (p) => isSchemaObject(p) && (typeof p.description === 'string' || typeof p.$comment === 'string'), | ||
| ) | ||
| if (hasDescriptions) { | ||
| let properties = '' | ||
| let first = true | ||
| for (const key in schema.properties) { | ||
| // schema.properties[key] is safe: key comes from iterating schema.properties | ||
| const propSchema = schema.properties[key]! | ||
| const isRequired = requiredSet.has(key) | ||
| const optional = isRequired ? '' : '?' | ||
| const propType = getTypeScriptType(propSchema, options) | ||
| const inlineDescription = | ||
| isSchemaObject(propSchema) && typeof propSchema.description === 'string' | ||
| ? propSchema.description | ||
| : isSchemaObject(propSchema) && typeof propSchema.$comment === 'string' | ||
| ? propSchema.$comment | ||
| : undefined | ||
| if (!first) properties += '\n' | ||
| first = false | ||
| if (inlineDescription) { | ||
| properties += | ||
| buildInlinePropertyComment(inlineDescription) + | ||
| ' ' + | ||
| readonlyPrefix + | ||
| safeKey(key) + | ||
| optional + | ||
| ': ' + | ||
| propType + | ||
| ';' | ||
| } else { | ||
| properties += ' ' + readonlyPrefix + safeKey(key) + optional + ': ' + propType + ';' | ||
| } | ||
| } | ||
| return '{\n' + properties + '\n}' | ||
| } | ||
| let properties = '' | ||
| let first = true | ||
| for (const key in schema.properties) { | ||
| // schema.properties[key] is safe: key comes from iterating schema.properties | ||
| const propSchema = schema.properties[key]! | ||
| const isRequired = requiredSet.has(key) | ||
| const optional = isRequired ? '' : '?' | ||
| const propType = getTypeScriptType(propSchema, options) | ||
| if (!first) properties += '; ' | ||
| properties += readonlyPrefix + safeKey(key) + optional + ': ' + propType | ||
| first = false | ||
| } | ||
| return '{ ' + properties + ' }' | ||
| } | ||
| // Handle additionalProperties with $ref or $dynamicRef | ||
| if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { | ||
| const additionalPropType = getTypeScriptType(schema.additionalProperties, options) | ||
| return recordType('string', additionalPropType, options) | ||
| } | ||
| // Handle patternProperties as a Record type | ||
| if (schema.patternProperties && typeof schema.patternProperties === 'object') { | ||
| const record = patternPropertiesRecordType(schema.patternProperties, options) | ||
| if (record !== undefined) return record | ||
| } | ||
| return 'object' | ||
| // Default to unknown | ||
| default: | ||
| return 'unknown' | ||
| } | ||
| } | ||
| /** | ||
| * Generates a TypeScript type definition from a JSON Schema. | ||
| * Handles required vs optional properties based on the schema's required array. | ||
| * Uses $comment as inline JSDoc description when present. | ||
| */ | ||
| export const generateTypeDefinition = (schema: JSONSchema, typeName: string, options: TypeOptions = {}): string => { | ||
| const readonlyPrefix = options.readonly ? 'readonly ' : '' | ||
| // Handle non-object schemas first | ||
| if (!isObjectLikeSchema(schema)) { | ||
| const tsType = getTypeScriptType(schema, options) | ||
| let result = '' | ||
| const topLevelComment = | ||
| (isSchemaObject(schema) && typeof schema.description === 'string' && schema.description) || | ||
| (isSchemaObject(schema) && typeof schema.$comment === 'string' && schema.$comment) || | ||
| undefined | ||
| if (topLevelComment) { | ||
| result += buildJsDocBlock(typeName, topLevelComment) | ||
| } | ||
| result += `export type ${typeName} = ${tsType};` | ||
| return result | ||
| } | ||
| if (isObjectLikeSchema(schema)) { | ||
| const conditionalResult = getConditionalObjectSchema(schema) | ||
| const normalizedSchema = conditionalResult?.schema ?? schema | ||
| const conditionalThenRef = conditionalResult?.thenRef ?? null | ||
| let jsDocTitle: string | undefined | ||
| let jsDocDescription: string | undefined | ||
| const topLevelComment = | ||
| (isSchemaObject(schema) && typeof schema.description === 'string' && schema.description) || | ||
| (isSchemaObject(schema) && typeof schema.$comment === 'string' && schema.$comment) || | ||
| undefined | ||
| if (topLevelComment) { | ||
| jsDocTitle = typeName | ||
| jsDocDescription = topLevelComment | ||
| } | ||
| const hasProperties = normalizedSchema.properties && Object.keys(normalizedSchema.properties).length > 0 | ||
| const hasAdditionalProperties = | ||
| normalizedSchema.additionalProperties && typeof normalizedSchema.additionalProperties === 'object' | ||
| const hasPatternProperties = | ||
| normalizedSchema.patternProperties && | ||
| typeof normalizedSchema.patternProperties === 'object' && | ||
| Object.keys(normalizedSchema.patternProperties).length > 0 | ||
| // Handle objects with only patternProperties (no fixed properties) | ||
| if (!hasProperties && hasPatternProperties && normalizedSchema.patternProperties) { | ||
| const record = | ||
| patternPropertiesRecordType(normalizedSchema.patternProperties, options) ?? 'Record<string, unknown>' | ||
| let result = '' | ||
| if (jsDocTitle && jsDocDescription) { | ||
| result += buildJsDocBlock(jsDocTitle, jsDocDescription) | ||
| } | ||
| result += `export type ${typeName} = ${record};` | ||
| return result | ||
| } | ||
| // Handle objects with only additionalProperties (no fixed properties) | ||
| if (!hasProperties && hasAdditionalProperties && normalizedSchema.additionalProperties) { | ||
| const additionalPropType = getTypeScriptType(normalizedSchema.additionalProperties, options) | ||
| let result = '' | ||
| if (jsDocTitle && jsDocDescription) { | ||
| result += buildJsDocBlock(jsDocTitle, jsDocDescription) | ||
| } | ||
| result += `export type ${typeName} = {\n ${readonlyPrefix}[key: string]: ${additionalPropType};\n};` | ||
| return result | ||
| } | ||
| const schemaProps = normalizedSchema.properties ?? {} | ||
| const requiredSet = new Set<string>(Array.isArray(normalizedSchema.required) ? normalizedSchema.required : []) | ||
| let properties = '' | ||
| let isFirstProp = true | ||
| for (const key in schemaProps) { | ||
| // schemaProps[key] is safe: key comes from iterating schemaProps | ||
| const propSchema = schemaProps[key]! | ||
| const isRequired = requiredSet.has(key) | ||
| const optional = isRequired ? '' : '?' | ||
| const propType = getTypeScriptType(propSchema, options) | ||
| const quotedKey = readonlyPrefix + safeKey(key) | ||
| if (!isFirstProp) properties += '\n' | ||
| isFirstProp = false | ||
| // Add JSDoc comment from $comment or description if available | ||
| const inlineDescription = | ||
| isSchemaObject(propSchema) && typeof propSchema.description === 'string' | ||
| ? propSchema.description | ||
| : isSchemaObject(propSchema) && typeof propSchema.$comment === 'string' | ||
| ? propSchema.$comment | ||
| : undefined | ||
| if (inlineDescription) { | ||
| properties += | ||
| buildInlinePropertyComment(inlineDescription) + ' ' + quotedKey + optional + ': ' + propType + ';' | ||
| } else { | ||
| properties += ' ' + quotedKey + optional + ': ' + propType + ';' | ||
| } | ||
| } | ||
| // Collect allOf $ref intersections | ||
| const allOfIntersections: string[] = [] | ||
| if (isSchemaObject(schema) && Array.isArray(schema.allOf)) { | ||
| for (const entry of schema.allOf) { | ||
| if (isSchemaObject(entry) && entry.$ref) { | ||
| allOfIntersections.push(refToName(entry.$ref, options.typeSuffix)) | ||
| } | ||
| } | ||
| } | ||
| // JSON Schema 2019-09+ allows $ref as a sibling to other keywords. | ||
| // Treat it as an additional intersection type (e.g. for specification-extensions). | ||
| if (isSchemaObject(schema) && typeof schema.$ref === 'string' && schema.$ref.startsWith('#')) { | ||
| allOfIntersections.push(refToName(schema.$ref, options.typeSuffix)) | ||
| } | ||
| let result = '' | ||
| if (jsDocTitle && jsDocDescription) { | ||
| result += buildJsDocBlock(jsDocTitle, jsDocDescription) | ||
| } | ||
| let typeBody = '{\n' + properties + '\n}' | ||
| if (conditionalThenRef) { | ||
| typeBody += ' & ' + refToName(conditionalThenRef, options.typeSuffix) | ||
| } | ||
| for (const intersectionType of allOfIntersections) { | ||
| typeBody += ' & ' + intersectionType | ||
| } | ||
| result += 'export type ' + typeName + ' = ' + typeBody + ';' | ||
| return result | ||
| } | ||
| return 'export type ' + typeName + ' = unknown;' | ||
| } |
| /** Type guard to check if a value is a non-null, non-array object with a string $ref property */ | ||
| export const hasRef = (value: unknown): value is { $ref: string } & Record<string, unknown> => { | ||
| return ( | ||
| typeof value === 'object' && | ||
| value !== null && | ||
| !Array.isArray(value) && | ||
| '$ref' in value && | ||
| typeof (value as { $ref: unknown }).$ref === 'string' | ||
| ) | ||
| } |
| /** | ||
| * Returns true if the provided value is a plain object. | ||
| * Optimized for JSON data validation: checks that value is truthy, typeof object, | ||
| * and not an array. This correctly handles all JSON value types. | ||
| * | ||
| * Examples: | ||
| * isObject({}) // true | ||
| * isObject({ a: 1 }) // true | ||
| * isObject([]) // false (Array) | ||
| * isObject(null) // false | ||
| * isObject(123) // false | ||
| * isObject('string') // false | ||
| */ | ||
| export const isObject = (value: unknown): value is Record<string, unknown> => | ||
| !!value && typeof value === 'object' && !Array.isArray(value) |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| import { isSchemaObject } from './schema-guards' | ||
| /** | ||
| * Vendor extension keyword carrying mjst-specific runtime hints that plain JSON | ||
| * Schema cannot express on its own. Adapters (TypeBox, Zod, ...) emit it when a | ||
| * source construct has no native JSON Schema equivalent, and the generators read | ||
| * it to produce the right TypeScript type and runtime checks. | ||
| */ | ||
| export const MJST_EXTENSION_KEY = 'x-mjst' | ||
| /** | ||
| * The shape of the `x-mjst` extension object. | ||
| * | ||
| * - `instanceOf` names a JavaScript class the value must be an instance of at | ||
| * runtime (e.g. `'Date'`). It round-trips constructs like TypeBox's | ||
| * `Type.Date()` that JSON Schema's core vocabulary has no keyword for. | ||
| * - `primitive` names a non-JSON primitive type (e.g. `'bigint'`) that has no | ||
| * JSON Schema representation. Unlike `instanceOf`, the value is checked with | ||
| * `typeof`, not `instanceof`. | ||
| * - `brand` carries a nominal-typing brand name. It is purely type-level: the | ||
| * runtime value still validates as its underlying JSON Schema type, but the | ||
| * generated TypeScript type is intersected with a unique brand so values are | ||
| * not interchangeable with the unbranded base type. | ||
| */ | ||
| export type MjstExtension = { | ||
| readonly instanceOf?: string | ||
| readonly primitive?: string | ||
| readonly brand?: string | ||
| } | ||
| // Only identifier-safe class names are honoured, so a malicious or malformed | ||
| // schema cannot inject arbitrary code into the generated output. | ||
| const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ | ||
| // The non-JSON primitives we know how to generate type/runtime handling for. | ||
| // Anything outside this set is ignored so unknown hints degrade gracefully. | ||
| const SUPPORTED_PRIMITIVES = new Set(['bigint']) | ||
| // Brand names are embedded inside a single-quoted string literal in generated | ||
| // output, so we only allow characters that cannot break out of the literal. | ||
| const SAFE_BRAND = /^[\w$ -]+$/ | ||
| const readExtensionString = (schema: JSONSchema, field: keyof MjstExtension): string | undefined => { | ||
| if (!isSchemaObject(schema)) return undefined | ||
| const extension = (schema as Record<string, unknown>)[MJST_EXTENSION_KEY] | ||
| if (typeof extension !== 'object' || extension === null) return undefined | ||
| const value = (extension as Record<string, unknown>)[field] | ||
| return typeof value === 'string' ? value : undefined | ||
| } | ||
| /** | ||
| * Reads the `instanceOf` class name from a schema's `x-mjst` extension, when it | ||
| * is present and a safe identifier. Returns undefined otherwise so callers fall | ||
| * back to ordinary type handling. | ||
| */ | ||
| export const getMjstInstanceOf = (schema: JSONSchema): string | undefined => { | ||
| const instanceOf = readExtensionString(schema, 'instanceOf') | ||
| return instanceOf !== undefined && IDENTIFIER.test(instanceOf) ? instanceOf : undefined | ||
| } | ||
| /** | ||
| * Reads the `primitive` type name from a schema's `x-mjst` extension, when it is | ||
| * one we support (e.g. `'bigint'`). Returns undefined otherwise. | ||
| */ | ||
| export const getMjstPrimitive = (schema: JSONSchema): string | undefined => { | ||
| const primitive = readExtensionString(schema, 'primitive') | ||
| return primitive !== undefined && SUPPORTED_PRIMITIVES.has(primitive) ? primitive : undefined | ||
| } | ||
| /** | ||
| * Reads the nominal `brand` name from a schema's `x-mjst` extension, when it is | ||
| * present and safe to embed in generated output. Returns undefined otherwise. | ||
| */ | ||
| export const getMjstBrand = (schema: JSONSchema): string | undefined => { | ||
| const brand = readExtensionString(schema, 'brand') | ||
| return brand !== undefined && SAFE_BRAND.test(brand) ? brand : undefined | ||
| } |
| /** | ||
| * Emits code for a JSON Schema `multipleOf` check that agrees with the runtime | ||
| * interpreter (`packages/runtime-validators/src/interpreter/interpret.ts`). | ||
| * | ||
| * The naive `x % m === 0` is float-wrong: IEEE-754 makes `0.3 % 0.1` evaluate to | ||
| * `0.0999…`, so `0.3` would spuriously fail `multipleOf: 0.1`. The interpreter | ||
| * instead divides and compares the quotient to its nearest integer within a | ||
| * tolerance that *scales with the quotient's magnitude* — a fixed epsilon falsely | ||
| * rejects large values (e.g. `1234567.89` against `multipleOf: 0.01`) whose | ||
| * representation error in the quotient already exceeds it. Keeping the emitted | ||
| * check identical to the interpreter's keeps generated validators/parsers and the | ||
| * interpreter from disagreeing on the same document. | ||
| * | ||
| * `valueExpr` is inlined three times, so it must be a side-effect-free expression | ||
| * (a property read or a cached variable — which is all the generators ever pass). | ||
| */ | ||
| const quotientTolerance = (valueExpr: string, divisor: number): { q: string; tol: string } => { | ||
| const q = `${valueExpr} / ${divisor}` | ||
| return { q, tol: `1e-8 * Math.max(1, Math.abs(${q}))` } | ||
| } | ||
| /** Boolean expression that is TRUE when `valueExpr` is a valid multiple of `divisor`. */ | ||
| export const multipleOfPassExpr = (valueExpr: string, divisor: number): string => { | ||
| const { q, tol } = quotientTolerance(valueExpr, divisor) | ||
| return `Math.abs(${q} - Math.round(${q})) <= ${tol}` | ||
| } | ||
| /** Boolean expression that is TRUE when `valueExpr` is NOT a multiple of `divisor` (the error condition). */ | ||
| export const multipleOfFailExpr = (valueExpr: string, divisor: number): string => { | ||
| const { q, tol } = quotientTolerance(valueExpr, divisor) | ||
| return `Math.abs(${q} - Math.round(${q})) > ${tol}` | ||
| } |
| type PropertyDocumentation = { | ||
| description: string | ||
| isRequired: boolean | ||
| } | ||
| export type ObjectDocumentation = { | ||
| title: string | ||
| description: string | ||
| properties: Record<string, PropertyDocumentation> | ||
| } | ||
| /** | ||
| * Fetches and parses OpenAPI specification documentation from the markdown source. | ||
| * | ||
| * When a section has no Fixed Fields table (e.g. Header Object delegates to Parameter Object), | ||
| * pass `fallbackCommentUrl` to inherit property documentation from another section. | ||
| */ | ||
| export const parseDocumentation = ( | ||
| markdownDocumentation: string, | ||
| commentUrl: string, | ||
| fallbackCommentUrl?: string, | ||
| ): ObjectDocumentation | null => { | ||
| try { | ||
| const markdown = markdownDocumentation | ||
| // Extract the fragment ID from the URL (e.g., #info-object) | ||
| const fragmentId = commentUrl.split('#')[1] | ||
| if (!fragmentId) { | ||
| return null | ||
| } | ||
| // Extract the base URL (everything before the #) | ||
| const baseUrl = commentUrl.split('#')[0] | ||
| // Convert fragment ID to title case (e.g., "info-object" -> "Info Object") | ||
| const titleWords = fragmentId.split('-') | ||
| let sectionTitle = '' | ||
| for (let i = 0; i < titleWords.length; i++) { | ||
| if (i > 0) sectionTitle += ' ' | ||
| const word = titleWords[i] ?? '' | ||
| sectionTitle += word.charAt(0).toUpperCase() + word.slice(1) | ||
| } | ||
| // Detect the heading level used for object sections in this markdown file. | ||
| // OAS 3.1 uses #### while OAS 3.2 uses ###, so we probe for both. | ||
| const headingLevelMatch = markdown.match(/^(#{2,5})\s+\S.*Object\s*$/m) | ||
| const headingHashes = headingLevelMatch?.[1] ?? '####' | ||
| const subHeadingHashes = headingHashes + '#' | ||
| // Find the section in the markdown | ||
| // Match the section heading and capture everything until the next same-level heading or end of file. | ||
| // `sectionTitle` comes from the URL fragment, so escape any regex metacharacters | ||
| // before interpolating — otherwise a fragment containing `+`, `(`, etc. throws a | ||
| // SyntaxError that the outer catch then mislabels as a fetch failure. | ||
| const escapedHashes = headingHashes.replace(/#/g, '\\#') | ||
| const escapedTitle = sectionTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | ||
| const sectionRegex = new RegExp( | ||
| `${escapedHashes}\\s+${escapedTitle}\\s*\\n([\\s\\S]*?)(?=\\n${escapedHashes}\\s|$)`, | ||
| 'i', | ||
| ) | ||
| const sectionMatch = markdown.match(sectionRegex) | ||
| if (!sectionMatch) { | ||
| return null | ||
| } | ||
| const sectionContent = sectionMatch?.[1] | ||
| if (!sectionContent) { | ||
| return null | ||
| } | ||
| // Extract the description (paragraphs before the "Fixed Fields" sub-heading) | ||
| // \n? handles the case where sectionContent starts directly with the sub-heading (no leading newline), | ||
| // which happens when the section heading is followed by a blank line consumed by \s* in sectionRegex. | ||
| const escapedSubHashes = subHeadingHashes.replace(/#/g, '\\#') | ||
| const descriptionMatch = sectionContent.match(new RegExp(`^([\\s\\S]*?)(?=\\n?${escapedSubHashes}|$)`)) | ||
| const description = descriptionMatch?.[1]?.trim().replace(/\n/g, ' ') || '' | ||
| // Extract all Fixed Fields tables within the section (some objects like Encoding Object | ||
| // split their fields across multiple sub-tables under different sub-sub-headings). | ||
| // Capture everything from the first "Fixed Fields" sub-heading to the end of the section — | ||
| // the section content is already bounded by the outer section regex so we don't need | ||
| // a stop condition here. | ||
| const fixedFieldsRegex = new RegExp(`${escapedSubHashes} Fixed Fields\\s*\\n([\\s\\S]*)`) | ||
| const fixedFieldsMatch = sectionContent.match(fixedFieldsRegex) | ||
| const properties: Record<string, PropertyDocumentation> = {} | ||
| if (fixedFieldsMatch?.[1]) { | ||
| const fixedFieldsContent = fixedFieldsMatch[1] | ||
| // Parse all markdown table rows across all sub-tables within the Fixed Fields section | ||
| const lines = fixedFieldsContent.split('\n') | ||
| let inTable = false | ||
| for (const line of lines) { | ||
| // Skip the header row and separator row | ||
| if (line.includes('Field Name') || line.includes('---|')) { | ||
| inTable = true | ||
| continue | ||
| } | ||
| // A ###### sub-heading resets inTable so we re-enter on the next header row | ||
| if (line.startsWith('#')) { | ||
| inTable = false | ||
| continue | ||
| } | ||
| if (!inTable || !line.trim() || !line.includes('|')) { | ||
| continue | ||
| } | ||
| // Parse table row: | fieldName | type | description | | ||
| // First, replace escaped pipes (\|) with a placeholder to avoid splitting on them | ||
| const lineWithPlaceholder = line.replace(/\\\|/g, '___PIPE___') | ||
| const cells = lineWithPlaceholder | ||
| .split('|') | ||
| .map((cell) => cell.replace(/___PIPE___/g, '|').trim()) | ||
| .filter((cell) => cell) | ||
| if (cells.length >= 3) { | ||
| const fieldName = cells[0]?.replace(/<[^>]*>/g, '').trim() // Remove HTML tags | ||
| // Some OpenAPI tables include an "Applies To" column before Description. | ||
| // We always want the right-most column as the property description. | ||
| const descriptionCellIndex = cells.length >= 4 ? 3 : 2 | ||
| let fieldDescription = cells[descriptionCellIndex]?.trim() || '' | ||
| const isRequired = fieldDescription.includes('REQUIRED') | ||
| // Replace relative anchor links with full URLs using the base URL | ||
| fieldDescription = fieldDescription.replace(/\(#([^)]+)\)/g, `(${baseUrl}#$1)`) | ||
| // Only accept field names that look like valid identifiers (camelCase or plain words). | ||
| // This filters out rows from non-field tables (e.g. type/contentType default tables) | ||
| // where the "field name" column contains type annotations like `string` or [_absent_]. | ||
| const isValidFieldName = | ||
| fieldName && | ||
| !fieldName.includes('Field Name') && | ||
| !fieldName.startsWith('`') && | ||
| !fieldName.startsWith('[') && | ||
| /^[a-zA-Z]/.test(fieldName) | ||
| if (isValidFieldName) { | ||
| properties[fieldName] = { | ||
| description: fieldDescription, | ||
| isRequired, | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| // Format title as "Info object" instead of "Info Object" | ||
| const title = sectionTitle.replace(/\sObject$/, ' object') | ||
| // If no properties were found and a fallback URL is provided, inherit properties from it | ||
| if (Object.keys(properties).length === 0 && fallbackCommentUrl) { | ||
| const fallback = parseDocumentation(markdownDocumentation, fallbackCommentUrl) | ||
| if (fallback && Object.keys(fallback.properties).length > 0) { | ||
| return { | ||
| title, | ||
| description, | ||
| properties: fallback.properties, | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| title, | ||
| description, | ||
| properties, | ||
| } | ||
| } catch (error) { | ||
| console.error('Failed to fetch OpenAPI documentation:', error) | ||
| return null | ||
| } | ||
| } |
| /** | ||
| * Converts a PascalCase or camelCase string to kebab-case. | ||
| * Handles consecutive uppercase sequences (e.g. "APIKey" → "api-key") and | ||
| * known mixed-case acronyms like "OAuth" that would otherwise split incorrectly. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * toKebabCase('ServerVariable') // 'server-variable' | ||
| * toKebabCase('APIKeySecurityScheme') // 'api-key-security-scheme' | ||
| * toKebabCase('OAuthFlows') // 'oauth-flows' | ||
| * toKebabCase('already-kebab') // 'already-kebab' | ||
| * ``` | ||
| */ | ||
| export const toKebabCase = (value: string): string => | ||
| value | ||
| // Collapse known mixed-case acronyms before splitting so they stay together | ||
| .replace(/OAuth/g, 'Oauth') | ||
| .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') | ||
| .replace(/([a-z\d])([A-Z])/g, '$1-$2') | ||
| .toLowerCase() | ||
| /** | ||
| * Derives a unique kebab-case filename from a URI ref. | ||
| * | ||
| * For a plain URI (no fragment), uses the path segments after the host, | ||
| * stripping version numbers, `.json` extension, and joining with `-`. | ||
| * For a URI with a fragment, appends the fragment's last path segment. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * uriRefToFilename('http://asyncapi.com/definitions/3.1.0/channel.json') | ||
| * // 'channel' | ||
| * uriRefToFilename('http://asyncapi.com/bindings/kafka/0.5.0/channel.json') | ||
| * // 'kafka-channel-binding' | ||
| * uriRefToFilename('http://asyncapi.com/bindings/sns/0.1.0/channel.json#/definitions/queue') | ||
| * // 'sns-channel-queue' | ||
| * ``` | ||
| */ | ||
| const uriRefToFilename = (uri: string): string => { | ||
| const hashIndex = uri.indexOf('#') | ||
| const baseUri = hashIndex === -1 ? uri : uri.slice(0, hashIndex) | ||
| const fragment = hashIndex === -1 ? '' : uri.slice(hashIndex + 1) | ||
| // Strip protocol + host, remove .json extension | ||
| const withoutProtocol = baseUri.replace(/^https?:\/\/[^/]+\//, '') | ||
| const withoutExt = withoutProtocol.replace(/\.json$/, '') | ||
| // Drop structural/noise segments: | ||
| // - "definitions" and "$defs" container keys | ||
| // - Version numbers that immediately follow "definitions" (e.g. "3.1.0" in "definitions/3.1.0/channel") | ||
| // but NOT version numbers in other positions (e.g. "0.5.0" in "bindings/kafka/0.5.0/channel") | ||
| // since those are needed to disambiguate multiple versions of the same binding | ||
| const rawSegments = withoutExt.split('/') | ||
| const SKIP_KEYS = new Set(['definitions', '$defs']) | ||
| const segments: string[] = [] | ||
| for (let i = 0; i < rawSegments.length; i++) { | ||
| const s = rawSegments[i] as string | ||
| if (SKIP_KEYS.has(s)) continue | ||
| // Skip a version segment only if the previous (non-skipped) segment was "definitions" | ||
| const prevRaw = rawSegments[i - 1] | ||
| if (/^\d+\.\d+/.test(s) && prevRaw !== undefined && SKIP_KEYS.has(prevRaw)) continue | ||
| segments.push(s) | ||
| } | ||
| // Join remaining segments, converting to kebab-case and replacing dots with dashes | ||
| const baseName = segments.map((s) => toKebabCase(s).replace(/\./g, '-')).join('-') | ||
| if (!fragment) return baseName | ||
| // Append the last meaningful segment of the fragment, skipping structural keys | ||
| const fragSegments = fragment.split('/').filter((s) => s && !SKIP_KEYS.has(s) && s !== 'properties') | ||
| const fragLast = fragSegments[fragSegments.length - 1] | ||
| if (!fragLast) return baseName | ||
| return `${baseName}-${toKebabCase(fragLast)}` | ||
| } | ||
| /** | ||
| * Converts a JSON Schema $ref to a filename. | ||
| * | ||
| * Handles three ref forms: | ||
| * - Internal `#/$defs/contact` → `contact` | ||
| * - Internal `#/definitions/ServerVariable` → `server-variable` | ||
| * - URI `http://example.com/definitions/3.1.0/channel.json` → `channel` | ||
| * - URI with fragment `http://example.com/channel.json#/definitions/queue` → `channel-queue` | ||
| * | ||
| * @param ref - The $ref string | ||
| * @returns The filename without extension | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * refToFilename('#/$defs/contact') // 'contact' | ||
| * refToFilename('#/$defs/server-variable') // 'server-variable' | ||
| * refToFilename('#/definitions/ServerVariable') // 'server-variable' | ||
| * refToFilename('#/definitions/APIKeySecurityScheme') // 'api-key-security-scheme' | ||
| * refToFilename('http://asyncapi.com/definitions/3.1.0/channel.json') // 'channel' | ||
| * ``` | ||
| */ | ||
| export const refToFilename = (ref: string): string => { | ||
| // URI ref — derive name from URI path | ||
| if (ref.startsWith('http://') || ref.startsWith('https://')) { | ||
| return uriRefToFilename(ref) | ||
| } | ||
| // Internal ref — extract the last segment after the last / | ||
| const segments = ref.split('/') | ||
| // Non-null assertion is safe here: split always returns at least one element | ||
| let filename = segments[segments.length - 1] as string | ||
| // Normalise PascalCase/camelCase keys (e.g. from draft-07 "definitions") to kebab-case | ||
| if (/[A-Z]/.test(filename)) { | ||
| filename = toKebabCase(filename) | ||
| } | ||
| return filename | ||
| } |
| import { refToFilename } from './ref-to-filename' | ||
| /** | ||
| * Converts a filename-like string to a PascalCase TypeScript identifier, | ||
| * appending an optional suffix. | ||
| * | ||
| * Word boundaries are *any* run of characters that cannot appear in a JS | ||
| * identifier — not just `-` — so a dotted key like `io.k8s.api.core.v1.Pod` | ||
| * becomes `IoK8sApiCoreV1Pod` instead of the invalid `Io.k8s.api.core.v1.pod`. | ||
| * A leading digit is prefixed with `_`, and an otherwise-empty result falls back | ||
| * to `_`, so the output is always a usable identifier. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * kebabToPascal('server-variable') // 'ServerVariable' | ||
| * kebabToPascal('channel') // 'Channel' | ||
| * kebabToPascal('channel', 'Object') // 'ChannelObject' | ||
| * kebabToPascal('io.k8s.api.core.v1.pod') // 'IoK8sApiCoreV1Pod' | ||
| * kebabToPascal('123abc') // '_123abc' | ||
| * ``` | ||
| */ | ||
| const kebabToPascal = (kebab: string, suffix: string): string => { | ||
| const words = kebab.split(/[^A-Za-z0-9]+/) | ||
| let pascalCase = '' | ||
| for (const word of words) { | ||
| if (word === '') continue | ||
| pascalCase += word.charAt(0).toUpperCase() + word.slice(1) | ||
| } | ||
| if (pascalCase === '') pascalCase = '_' | ||
| else if (/^[0-9]/.test(pascalCase)) pascalCase = `_${pascalCase}` | ||
| return pascalCase + suffix | ||
| } | ||
| /** | ||
| * Converts a JSON Schema $ref to a type name. | ||
| * Derives the filename via `refToFilename` then converts to PascalCase, | ||
| * appending an optional suffix. | ||
| * | ||
| * Handles all ref forms: internal `#/$defs/...`, `#/definitions/...`, and URI refs. | ||
| * | ||
| * @param ref - The $ref string | ||
| * @param suffix - Optional suffix appended to the PascalCase name. Defaults to | ||
| * `''` (no suffix). Pass e.g. `'Object'` to get `ContactObject`. | ||
| * @returns The type name in PascalCase with the suffix applied | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * refToName('#/$defs/contact') // 'Contact' | ||
| * refToName('#/$defs/server-variable') // 'ServerVariable' | ||
| * refToName('#/$defs/contact', 'Object') // 'ContactObject' | ||
| * refToName('http://asyncapi.com/definitions/3.1.0/channel.json') // 'Channel' | ||
| * ``` | ||
| */ | ||
| export const refToName = (ref: string, suffix = ''): string => kebabToPascal(refToFilename(ref), suffix) |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| /** | ||
| * Replaces $dynamicRef with $ref in a schema using the provided anchor-to-path map. | ||
| * | ||
| * This creates a deep clone of the schema to avoid mutating the original, then walks | ||
| * the clone and converts any { $dynamicRef: "#meta" } to { $ref: "#/$defs/schema" } | ||
| * (or whatever the dynamicRefMap dictates). | ||
| * | ||
| * Walks both object properties and array elements, so a `$dynamicRef` nested | ||
| * inside a keyword whose value is an array of subschemas (`allOf`, `anyOf`, | ||
| * `oneOf`, `prefixItems`, …) is rewritten too. The build system generates a | ||
| * separate file per `$def`, so each schema walked here is relatively shallow. | ||
| */ | ||
| export const resolveDynamicRefs = (schema: JSONSchema, dynamicRefMap: Record<string, string>): JSONSchema => { | ||
| if (typeof schema !== 'object' || schema === null) { | ||
| return schema | ||
| } | ||
| // Skip if there are no dynamic refs to resolve | ||
| if (Object.keys(dynamicRefMap).length === 0) { | ||
| return schema | ||
| } | ||
| // Cheap read-only pre-scan: most subschemas carry no `$dynamicRef`, so returning | ||
| // the original untouched avoids a full deep clone (and its allocation) on every | ||
| // node of every generator's walk when the document as a whole *does* use them. | ||
| if (!containsDynamicRef(schema)) { | ||
| return schema | ||
| } | ||
| const clone = JSON.parse(JSON.stringify(schema)) as Record<string, unknown> | ||
| const walk = (obj: unknown): void => { | ||
| if (typeof obj !== 'object' || obj === null) { | ||
| return | ||
| } | ||
| if (Array.isArray(obj)) { | ||
| for (const item of obj) walk(item) | ||
| return | ||
| } | ||
| const record = obj as Record<string, unknown> | ||
| if ('$dynamicRef' in record && typeof record['$dynamicRef'] === 'string') { | ||
| const resolved = dynamicRefMap[record['$dynamicRef'] as string] | ||
| if (resolved) { | ||
| record['$ref'] = resolved | ||
| delete record['$dynamicRef'] | ||
| } | ||
| } | ||
| for (const key in record) { | ||
| walk(record[key]) | ||
| } | ||
| } | ||
| walk(clone) | ||
| return clone as JSONSchema | ||
| } | ||
| /** True if `value` contains a `$dynamicRef` string anywhere in its subtree. */ | ||
| const containsDynamicRef = (value: unknown): boolean => { | ||
| if (typeof value !== 'object' || value === null) return false | ||
| if (Array.isArray(value)) { | ||
| for (const item of value) if (containsDynamicRef(item)) return true | ||
| return false | ||
| } | ||
| const record = value as Record<string, unknown> | ||
| if (typeof record['$dynamicRef'] === 'string') return true | ||
| for (const key in record) if (containsDynamicRef(record[key])) return true | ||
| return false | ||
| } |
| /** | ||
| * Navigates a JSON Pointer fragment (e.g. `/$defs/foo` or `/definitions/bar`) | ||
| * through a schema object, returning the target or undefined if not found. | ||
| */ | ||
| const navigatePointer = (pointer: string, schema: Record<string, unknown>): Record<string, unknown> | undefined => { | ||
| const parts = pointer.split('/').filter(Boolean) | ||
| let current = schema | ||
| for (const part of parts) { | ||
| const decodedPart = part.replace(/~1/g, '/').replace(/~0/g, '~') | ||
| if (current && typeof current === 'object' && decodedPart in current) { | ||
| const next = current[decodedPart as keyof typeof current] | ||
| if (typeof next === 'object' && next !== null) { | ||
| current = next as Record<string, unknown> | ||
| } else { | ||
| return undefined | ||
| } | ||
| } else { | ||
| return undefined | ||
| } | ||
| } | ||
| return current | ||
| } | ||
| /** | ||
| * Resolves a JSON Schema $ref pointer to the actual schema definition. | ||
| * | ||
| * Supports three ref forms: | ||
| * - Internal: `#/$defs/contact` — navigates the root schema by JSON Pointer | ||
| * - URI key: `http://example.com/foo.json` — looks up the key directly in `$defs` | ||
| * - URI with fragment: `http://example.com/foo.json#/definitions/bar` — looks up | ||
| * the base URI in `$defs`, then navigates the fragment within that definition | ||
| * | ||
| * @param ref - The $ref string | ||
| * @param rootSchema - The root schema containing the definitions | ||
| * @returns The resolved schema or undefined if not found | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const rootSchema = { | ||
| * $defs: { | ||
| * contact: { type: 'object', properties: { email: { type: 'string' } } }, | ||
| * 'http://example.com/server.json': { type: 'object' }, | ||
| * } | ||
| * } | ||
| * resolveRef('#/$defs/contact', rootSchema) | ||
| * resolveRef('http://example.com/server.json', rootSchema) | ||
| * ``` | ||
| */ | ||
| export const resolveRef = (ref: string, rootSchema: Record<string, unknown>): Record<string, unknown> | undefined => { | ||
| // Internal reference: navigate from root by JSON Pointer | ||
| if (ref.startsWith('#')) { | ||
| return navigatePointer(ref.slice(1), rootSchema) | ||
| } | ||
| // URI ref: may have a fragment (e.g. "http://foo.com/bar.json#/definitions/baz") | ||
| // A trailing bare "#" (e.g. "http://foo.com/schema#") means "the whole document" — treat as no fragment | ||
| const hashIndex = ref.indexOf('#') | ||
| const baseUri = hashIndex === -1 ? ref : ref.slice(0, hashIndex) | ||
| const rawFragment = hashIndex === -1 ? '' : ref.slice(hashIndex + 1) | ||
| const fragment = rawFragment === '' || rawFragment === '/' ? '' : rawFragment | ||
| // Look up the base URI as a key in $defs | ||
| const defs = rootSchema['$defs'] | ||
| if (typeof defs !== 'object' || defs === null) return undefined | ||
| const defsRecord = defs as Record<string, unknown> | ||
| const base = defsRecord[baseUri] | ||
| if (typeof base !== 'object' || base === null) return undefined | ||
| // No fragment — return the definition directly | ||
| if (!fragment) return base as Record<string, unknown> | ||
| // Normalize the fragment: draft-07 schemas use `/definitions/` but after | ||
| // upgradeDraft07Schema the key is renamed to `/$defs/` | ||
| const normalizedFragment = fragment.replace(/^\/definitions\//, '/$defs/') | ||
| // Navigate the fragment within the resolved definition | ||
| return navigatePointer(normalizedFragment, base as Record<string, unknown>) | ||
| } |
| /** | ||
| * Checks whether a property name is a valid JavaScript identifier that can be | ||
| * accessed with dot notation. Property names containing hyphens, dots, or | ||
| * other special characters (e.g., "x-linkedin") must use bracket notation. | ||
| */ | ||
| const JS_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/ | ||
| /** | ||
| * Generates a safe property accessor for a given key on an object variable. | ||
| * Uses dot notation for simple identifiers and bracket notation for keys | ||
| * that contain special characters like hyphens. | ||
| * | ||
| * @param variable - The variable name (e.g., "input", "input?") | ||
| * @param key - The property name to access | ||
| * @returns A valid JS property access expression | ||
| * | ||
| * @example | ||
| * safeAccessor("input", "name") // "input.name" | ||
| * safeAccessor("input?", "x-linkedin") // 'input?.["x-linkedin"]' | ||
| * safeAccessor("input", "x-linkedin") // 'input["x-linkedin"]' | ||
| */ | ||
| export const safeAccessor = (variable: string, key: string): string => { | ||
| if (JS_IDENTIFIER.test(key)) { | ||
| return `${variable}.${key}` | ||
| } | ||
| // Bracket keys are schema-controlled, so escape via JSON.stringify — a key like | ||
| // `it's` or `a']; evil(); //` would otherwise break or hijack the generated code. | ||
| const literal = JSON.stringify(key) | ||
| // Handle optional chaining: "input?" -> "input?.['key']" | ||
| if (variable.endsWith('?')) { | ||
| return `${variable}.[${literal}]` | ||
| } | ||
| return `${variable}[${literal}]` | ||
| } | ||
| /** | ||
| * Generates a safe property key for use in object literals. | ||
| * Wraps keys that are not valid identifiers in quotes. | ||
| * | ||
| * @example | ||
| * safeKey("name") // "name" | ||
| * safeKey("x-linkedin") // '"x-linkedin"' | ||
| */ | ||
| export const safeKey = (key: string): string => { | ||
| if (JS_IDENTIFIER.test(key)) { | ||
| return key | ||
| } | ||
| // Schema-controlled keys must be escaped; a bare-quoted `it's` produces broken TS. | ||
| return JSON.stringify(key) | ||
| } |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| type SchemaObject = Exclude<JSONSchema, false | boolean> | ||
| /** Type guard to check if schema is not false */ | ||
| export const isSchemaObject = (schema: JSONSchema): schema is SchemaObject => { | ||
| return typeof schema === 'object' && schema !== null && typeof schema !== 'boolean' | ||
| } | ||
| /** Type guard to check if schema has a type property */ | ||
| export const hasType = (schema: JSONSchema): schema is SchemaObject & { type: string } => { | ||
| return isSchemaObject(schema) && 'type' in schema && typeof schema.type === 'string' | ||
| } | ||
| /** Type guard to check if schema is an object schema */ | ||
| export const isObjectSchema = (schema: JSONSchema): schema is JSONSchema.Object => { | ||
| return isSchemaObject(schema) && (('type' in schema && schema.type === 'object') || 'properties' in schema) | ||
| } | ||
| /** Type guard to check if schema has properties */ | ||
| export const hasProperties = ( | ||
| schema: JSONSchema, | ||
| ): schema is SchemaObject & { properties: Record<string, JSONSchema> } => { | ||
| return ( | ||
| isSchemaObject(schema) && | ||
| 'properties' in schema && | ||
| typeof schema.properties === 'object' && | ||
| schema.properties !== null | ||
| ) | ||
| } | ||
| /** Type guard to check if schema has enum */ | ||
| export const hasEnum = (schema: JSONSchema): schema is SchemaObject & { enum: readonly unknown[] } => { | ||
| return isSchemaObject(schema) && 'enum' in schema && Array.isArray(schema.enum) | ||
| } | ||
| /** Type guard to check if schema has const */ | ||
| export const hasConst = (schema: JSONSchema): schema is SchemaObject & { const: unknown } => { | ||
| return isSchemaObject(schema) && 'const' in schema | ||
| } | ||
| /** Type guard to check if schema has pattern */ | ||
| export const hasPattern = (schema: JSONSchema): schema is SchemaObject & { pattern: string } => { | ||
| return isSchemaObject(schema) && 'pattern' in schema && typeof schema.pattern === 'string' | ||
| } | ||
| /** Type guard to check if schema has format */ | ||
| export const hasFormat = (schema: JSONSchema): schema is SchemaObject & { format: string } => { | ||
| return isSchemaObject(schema) && 'format' in schema && typeof schema.format === 'string' | ||
| } | ||
| /** Type guard to check if schema has default */ | ||
| export const hasDefault = (schema: JSONSchema): schema is SchemaObject & { default: unknown } => { | ||
| return isSchemaObject(schema) && 'default' in schema | ||
| } | ||
| /** Type guard to check if schema has examples */ | ||
| export const hasExamples = (schema: JSONSchema): schema is SchemaObject & { examples: readonly unknown[] } => { | ||
| return isSchemaObject(schema) && 'examples' in schema && Array.isArray(schema.examples) | ||
| } | ||
| /** Type guard to check if schema has oneOf */ | ||
| export const hasOneOf = (schema: JSONSchema): schema is SchemaObject & { oneOf: readonly JSONSchema[] } => { | ||
| return isSchemaObject(schema) && 'oneOf' in schema && Array.isArray(schema.oneOf) | ||
| } | ||
| /** Type guard to check if schema has anyOf */ | ||
| export const hasAnyOf = (schema: JSONSchema): schema is SchemaObject & { anyOf: readonly JSONSchema[] } => { | ||
| return isSchemaObject(schema) && 'anyOf' in schema && Array.isArray(schema.anyOf) | ||
| } | ||
| /** Type guard to check if schema has allOf */ | ||
| export const hasAllOf = (schema: JSONSchema): schema is SchemaObject & { allOf: readonly JSONSchema[] } => { | ||
| return isSchemaObject(schema) && 'allOf' in schema && Array.isArray(schema.allOf) | ||
| } | ||
| /** Type guard to check if schema has required */ | ||
| export const hasRequired = (schema: JSONSchema): schema is SchemaObject & { required: readonly string[] } => { | ||
| return isSchemaObject(schema) && 'required' in schema && Array.isArray(schema.required) | ||
| } | ||
| /** Type guard to check if schema has items (and it's not just boolean) */ | ||
| export const hasItems = (schema: JSONSchema): schema is SchemaObject & { items: SchemaObject } => { | ||
| return ( | ||
| isSchemaObject(schema) && | ||
| 'items' in schema && | ||
| typeof schema.items === 'object' && | ||
| schema.items !== null && | ||
| typeof schema.items !== 'boolean' | ||
| ) | ||
| } | ||
| /** Type guard to check if schema has additionalProperties */ | ||
| export const hasAdditionalProperties = ( | ||
| schema: JSONSchema, | ||
| ): schema is SchemaObject & { additionalProperties: JSONSchema | boolean } => { | ||
| return isSchemaObject(schema) && 'additionalProperties' in schema | ||
| } | ||
| /** Type guard to check if schema has minLength */ | ||
| export const hasMinLength = (schema: JSONSchema): schema is SchemaObject & { minLength: number } => { | ||
| return isSchemaObject(schema) && 'minLength' in schema && typeof schema.minLength === 'number' | ||
| } | ||
| /** Type guard to check if schema has maxLength */ | ||
| export const hasMaxLength = (schema: JSONSchema): schema is SchemaObject & { maxLength: number } => { | ||
| return isSchemaObject(schema) && 'maxLength' in schema && typeof schema.maxLength === 'number' | ||
| } | ||
| /** Type guard to check if schema has minimum */ | ||
| export const hasMinimum = (schema: JSONSchema): schema is SchemaObject & { minimum: number } => { | ||
| return isSchemaObject(schema) && 'minimum' in schema && typeof schema.minimum === 'number' | ||
| } | ||
| /** Type guard to check if schema has maximum */ | ||
| export const hasMaximum = (schema: JSONSchema): schema is SchemaObject & { maximum: number } => { | ||
| return isSchemaObject(schema) && 'maximum' in schema && typeof schema.maximum === 'number' | ||
| } | ||
| /** Type guard to check if schema has exclusiveMinimum */ | ||
| export const hasExclusiveMinimum = (schema: JSONSchema): schema is SchemaObject & { exclusiveMinimum: number } => { | ||
| return isSchemaObject(schema) && 'exclusiveMinimum' in schema && typeof schema.exclusiveMinimum === 'number' | ||
| } | ||
| /** Type guard to check if schema has exclusiveMaximum */ | ||
| export const hasExclusiveMaximum = (schema: JSONSchema): schema is SchemaObject & { exclusiveMaximum: number } => { | ||
| return isSchemaObject(schema) && 'exclusiveMaximum' in schema && typeof schema.exclusiveMaximum === 'number' | ||
| } | ||
| /** | ||
| * Draft-04 expressed a strict lower bound as a boolean `exclusiveMinimum: true` | ||
| * paired with `minimum`, where draft-06+ uses a standalone numeric keyword. True | ||
| * only for that legacy boolean form, so callers can tighten the `minimum` compare. | ||
| */ | ||
| export const hasStrictExclusiveMinimum = (schema: JSONSchema): boolean => { | ||
| if (!isSchemaObject(schema)) return false | ||
| const flag: unknown = schema.exclusiveMinimum | ||
| return flag === true | ||
| } | ||
| /** Draft-04 boolean `exclusiveMaximum: true` paired with `maximum`. See {@link hasStrictExclusiveMinimum}. */ | ||
| export const hasStrictExclusiveMaximum = (schema: JSONSchema): boolean => { | ||
| if (!isSchemaObject(schema)) return false | ||
| const flag: unknown = schema.exclusiveMaximum | ||
| return flag === true | ||
| } | ||
| /** Type guard to check if schema has multipleOf */ | ||
| export const hasMultipleOf = (schema: JSONSchema): schema is SchemaObject & { multipleOf: number } => { | ||
| return isSchemaObject(schema) && 'multipleOf' in schema && typeof schema.multipleOf === 'number' | ||
| } | ||
| /** Type guard to check if schema has minItems */ | ||
| export const hasMinItems = (schema: JSONSchema): schema is SchemaObject & { minItems: number } => { | ||
| return isSchemaObject(schema) && 'minItems' in schema && typeof schema.minItems === 'number' | ||
| } | ||
| /** Type guard to check if schema has maxItems */ | ||
| export const hasMaxItems = (schema: JSONSchema): schema is SchemaObject & { maxItems: number } => { | ||
| return isSchemaObject(schema) && 'maxItems' in schema && typeof schema.maxItems === 'number' | ||
| } | ||
| /** Type guard to check if schema has uniqueItems */ | ||
| export const hasUniqueItems = (schema: JSONSchema): schema is SchemaObject & { uniqueItems: boolean } => { | ||
| return isSchemaObject(schema) && 'uniqueItems' in schema && typeof schema.uniqueItems === 'boolean' | ||
| } | ||
| /** Type guard to check if schema has minProperties */ | ||
| export const hasMinProperties = (schema: JSONSchema): schema is SchemaObject & { minProperties: number } => { | ||
| return isSchemaObject(schema) && 'minProperties' in schema && typeof schema.minProperties === 'number' | ||
| } | ||
| /** Type guard to check if schema has maxProperties */ | ||
| export const hasMaxProperties = (schema: JSONSchema): schema is SchemaObject & { maxProperties: number } => { | ||
| return isSchemaObject(schema) && 'maxProperties' in schema && typeof schema.maxProperties === 'number' | ||
| } | ||
| /** Type guard to check if schema has dependentRequired (2020-12). */ | ||
| export const hasDependentRequired = ( | ||
| schema: JSONSchema, | ||
| ): schema is SchemaObject & { dependentRequired: Record<string, readonly string[]> } => { | ||
| return ( | ||
| isSchemaObject(schema) && | ||
| 'dependentRequired' in schema && | ||
| typeof schema.dependentRequired === 'object' && | ||
| schema.dependentRequired !== null | ||
| ) | ||
| } | ||
| /** Type guard to check if schema has a propertyNames subschema. */ | ||
| export const hasPropertyNames = (schema: JSONSchema): schema is SchemaObject & { propertyNames: JSONSchema } => { | ||
| return isSchemaObject(schema) && 'propertyNames' in schema | ||
| } | ||
| export { hasRef } from './has-ref' |
| /** | ||
| * Default key-count cutoff below which an unknown-key sweep inlines `!==` | ||
| * comparisons instead of a hoisted `Set`. Chosen so typical schema objects stay | ||
| * on the faster inline path while pathologically wide objects fall back to the | ||
| * `Set`'s O(1) lookup. | ||
| */ | ||
| export const INLINE_KEY_LIMIT = 16 | ||
| /** The pieces a caller needs to emit an `additionalProperties: false` sweep. */ | ||
| export type UnknownKeyCheck = { | ||
| /** | ||
| * Declarations to emit once before the sweep — a single hoisted `Set` when the | ||
| * key count exceeds the inline limit, otherwise empty (the inline form is | ||
| * stateless). Each entry is a full statement with no trailing `;` or newline, | ||
| * so callers can punctuate to match their surrounding style. | ||
| */ | ||
| readonly declarations: readonly string[] | ||
| /** | ||
| * Builds the boolean expression that is true when `keyVar` is NOT one of the | ||
| * known keys — an inline chain of `!==` comparisons, a `Set.has` miss, or the | ||
| * constant `true` when there are no known keys (every key is undeclared). | ||
| */ | ||
| readonly isUnknown: (keyVar: string) => string | ||
| /** | ||
| * The complement of {@link isUnknown}: true when `keyVar` IS a known key — an | ||
| * inline chain of `===` comparisons, a `Set.has` hit, or the constant `false` | ||
| * when there are no known keys. | ||
| */ | ||
| readonly isKnown: (keyVar: string) => string | ||
| } | ||
| /** | ||
| * Builds the "is this an undeclared key" test used by `additionalProperties: | ||
| * false` sweeps in the generated parsers and validators. | ||
| * | ||
| * For a small number of known keys V8 evaluates a chain of `!==` string | ||
| * comparisons faster than `Set.has` (which has to hash the string), and the | ||
| * inline form skips the per-module `Set` allocation — the same shape Ajv and | ||
| * TypeBox compile to. Above `inlineLimit` the chain grows long enough that the | ||
| * `Set`'s O(1) lookup wins, so a `Set` named `setName` is hoisted instead. | ||
| * | ||
| * @example | ||
| * const check = unknownKeyCheck(['id', 'name'], '_knownKeys0') | ||
| * check.declarations // [] | ||
| * check.isUnknown('_k') // '_k !== "id" && _k !== "name"' | ||
| * check.isKnown('_k') // '_k === "id" || _k === "name"' | ||
| */ | ||
| export const unknownKeyCheck = ( | ||
| knownKeys: readonly string[], | ||
| setName: string, | ||
| inlineLimit: number = INLINE_KEY_LIMIT, | ||
| ): UnknownKeyCheck => { | ||
| if (knownKeys.length === 0) { | ||
| return { declarations: [], isUnknown: () => 'true', isKnown: () => 'false' } | ||
| } | ||
| if (knownKeys.length <= inlineLimit) { | ||
| return { | ||
| declarations: [], | ||
| isUnknown: (keyVar) => knownKeys.map((key) => `${keyVar} !== ${JSON.stringify(key)}`).join(' && '), | ||
| isKnown: (keyVar) => knownKeys.map((key) => `${keyVar} === ${JSON.stringify(key)}`).join(' || '), | ||
| } | ||
| } | ||
| return { | ||
| declarations: [`const ${setName} = new Set(${JSON.stringify(knownKeys)})`], | ||
| isUnknown: (keyVar) => `!${setName}.has(${keyVar})`, | ||
| isKnown: (keyVar) => `${setName}.has(${keyVar})`, | ||
| } | ||
| } |
| /** | ||
| * Upgrades a JSON Schema draft-07 document to be compatible with the | ||
| * draft 2020-12 conventions used by the build pipeline. | ||
| * | ||
| * Draft-07 schemas differ from 2020-12 in two ways that affect our pipeline: | ||
| * - They use `definitions` instead of `$defs` | ||
| * - Their `definitions` keys (and `$ref` values) may be full URIs | ||
| * (e.g. `http://asyncapi.com/definitions/3.1.0/channel.json`) rather than | ||
| * short names (e.g. `channel`) | ||
| * | ||
| * This function: | ||
| * 1. Renames `definitions` → `$defs` at the root level only | ||
| * 2. Hoists any nested `$defs` (originally `definitions` inside sub-schemas) | ||
| * up to the root `$defs` with a prefixed name, rewriting internal refs | ||
| * so they resolve correctly from the root | ||
| * 3. Rewrites bare `$ref: "#"` self-references within each definition to | ||
| * point back to that definition's root-level `$defs` entry | ||
| * | ||
| * Only applied when the schema declares `$schema: http://json-schema.org/draft-07/schema`. | ||
| */ | ||
| import { refToFilename, toKebabCase } from './ref-to-filename' | ||
| /** | ||
| * Returns true if the schema is a draft-07 document that needs upgrading. | ||
| */ | ||
| export const isDraft07Schema = (schema: Record<string, unknown>): boolean => | ||
| typeof schema['$schema'] === 'string' && schema['$schema'].includes('draft-07') | ||
| /** | ||
| * Rewrites `$ref` values in a schema tree using an explicit string→string map. | ||
| * Also rewrites bare `$ref: "#"` to the given `selfRef` path when provided. | ||
| */ | ||
| const rewriteRefs = (obj: unknown, refMap: ReadonlyMap<string, string>, selfRef?: string): unknown => { | ||
| if (typeof obj !== 'object' || obj === null) return obj | ||
| if (Array.isArray(obj)) return obj.map((item) => rewriteRefs(item, refMap, selfRef)) | ||
| const record = obj as Record<string, unknown> | ||
| const result: Record<string, unknown> = {} | ||
| for (const [key, value] of Object.entries(record)) { | ||
| if (key === '$ref' && typeof value === 'string') { | ||
| if (refMap.has(value)) { | ||
| result[key] = refMap.get(value) | ||
| } else if (value === '#' && selfRef) { | ||
| result[key] = selfRef | ||
| } else { | ||
| result[key] = value | ||
| } | ||
| } else { | ||
| result[key] = rewriteRefs(value, refMap, selfRef) | ||
| } | ||
| } | ||
| return result | ||
| } | ||
| /** | ||
| * Hoists nested `$defs` from each root-level definition up to the root `$defs`. | ||
| * | ||
| * When a definition contains its own `$defs` (originally `definitions` in draft-07, | ||
| * e.g. the json-schema meta-schema or avro schema), those nested defs are moved to | ||
| * the root with a `parentName-childName` prefix. All internal `#/$defs/X` refs | ||
| * within the parent and its nested defs are rewritten to `#/$defs/parentName-X`. | ||
| * Bare `$ref: "#"` within nested defs is rewritten to `#/$defs/parentName`. | ||
| */ | ||
| const hoistNestedDefs = (defs: Record<string, unknown>): Record<string, unknown> => { | ||
| const hoisted: Record<string, unknown> = {} | ||
| for (const [parentName, parentSchema] of Object.entries(defs)) { | ||
| if (typeof parentSchema !== 'object' || parentSchema === null) { | ||
| hoisted[parentName] = parentSchema | ||
| continue | ||
| } | ||
| const parentObj = parentSchema as Record<string, unknown> | ||
| const nestedDefs = parentObj['$defs'] as Record<string, unknown> | undefined | ||
| if (!nestedDefs || typeof nestedDefs !== 'object') { | ||
| hoisted[parentName] = parentSchema | ||
| continue | ||
| } | ||
| // Derive a short kebab-case prefix from the parent name (which may be a URI) | ||
| const parentPrefix = | ||
| parentName.startsWith('http://') || parentName.startsWith('https://') ? refToFilename(parentName) : parentName | ||
| // Build a map from local ref → hoisted ref for every nested def | ||
| const localToHoisted = new Map<string, string>() | ||
| for (const localName of Object.keys(nestedDefs)) { | ||
| const hoistedName = `${parentPrefix}-${toKebabCase(localName)}` | ||
| localToHoisted.set(`#/$defs/${localName}`, `#/$defs/${hoistedName}`) | ||
| } | ||
| const selfRef = `#/$defs/${parentPrefix}` | ||
| // Rewrite refs in the parent. Keep the nested $defs in place so that | ||
| // URI-with-fragment refs (e.g. "http://foo.json#/$defs/queue") can still | ||
| // navigate into the parent's nested defs after resolution. | ||
| const rewrittenParent = rewriteRefs(parentObj, localToHoisted, selfRef) as Record<string, unknown> | ||
| hoisted[parentName] = rewrittenParent | ||
| // Hoist each nested def, rewriting its internal refs too | ||
| for (const [localName, localSchema] of Object.entries(nestedDefs)) { | ||
| const hoistedName = `${parentPrefix}-${toKebabCase(localName)}` | ||
| hoisted[hoistedName] = rewriteRefs(localSchema, localToHoisted, selfRef) | ||
| } | ||
| } | ||
| return hoisted | ||
| } | ||
| /** | ||
| * Upgrades a draft-07 schema so it is compatible with the build pipeline. | ||
| * If the schema is not draft-07, it is returned unchanged. | ||
| * | ||
| * @param schema - The raw JSON Schema (any draft) | ||
| * @returns The schema with `definitions` renamed to `$defs` at the root, | ||
| * nested defs hoisted to the root, and internal refs rewritten | ||
| */ | ||
| export const upgradeDraft07Schema = (schema: Record<string, unknown>): Record<string, unknown> => { | ||
| if (!isDraft07Schema(schema)) return schema | ||
| // Rename root-level `definitions` to `$defs` (keep all other keys as-is) | ||
| const { definitions, $schema: _, ...rest } = schema | ||
| const rawDefs = (definitions ?? {}) as Record<string, unknown> | ||
| // Recursively rename `definitions` → `$defs` inside each definition's body | ||
| // so nested defs are accessible before hoisting | ||
| const renamedDefs: Record<string, unknown> = {} | ||
| for (const [key, value] of Object.entries(rawDefs)) { | ||
| renamedDefs[key] = renameNestedDefs(value) | ||
| } | ||
| // Hoist nested $defs up to root so the pipeline can resolve all refs flatly | ||
| const hoistedDefs = hoistNestedDefs(renamedDefs) | ||
| // Add short-name aliases for URI-keyed definitions so that internal refs | ||
| // like `#/$defs/draft-07-schema` (produced by self-ref rewriting in hoistNestedDefs) | ||
| // resolve correctly alongside the original URI key lookups. | ||
| for (const key of Object.keys(hoistedDefs)) { | ||
| if (key.startsWith('http://') || key.startsWith('https://')) { | ||
| const shortName = refToFilename(key) | ||
| if (shortName && !(shortName in hoistedDefs)) { | ||
| hoistedDefs[shortName] = hoistedDefs[key] | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| ...rest, | ||
| $defs: hoistedDefs, | ||
| } | ||
| } | ||
| /** | ||
| * Recursively renames `definitions` → `$defs` within a schema value and | ||
| * rewrites `$ref: "#/definitions/X"` to `$ref: "#/$defs/X"` so that | ||
| * `hoistNestedDefs` can map them to their hoisted root-level equivalents. | ||
| * Does NOT hoist — hoisting is done separately at the root level. | ||
| */ | ||
| const renameNestedDefs = (obj: unknown): unknown => { | ||
| if (typeof obj !== 'object' || obj === null) return obj | ||
| if (Array.isArray(obj)) return obj.map(renameNestedDefs) | ||
| const record = obj as Record<string, unknown> | ||
| const result: Record<string, unknown> = {} | ||
| for (const [key, value] of Object.entries(record)) { | ||
| if (key === '$ref' && typeof value === 'string' && value.startsWith('#/definitions/')) { | ||
| result[key] = value.replace('#/definitions/', '#/$defs/') | ||
| } else { | ||
| const outKey = key === 'definitions' ? '$defs' : key | ||
| result[outKey] = renameNestedDefs(value) | ||
| } | ||
| } | ||
| return result | ||
| } |
| /** Parses the items of an array with a parser function */ | ||
| export const validateArray = (input: unknown, parser: (input: unknown) => unknown) => { | ||
| if (!Array.isArray(input)) { | ||
| return [] | ||
| } | ||
| // Pre-allocate the result array for better performance than push() | ||
| const len = input.length | ||
| const result = new Array(len) | ||
| for (let i = 0; i < len; i++) { | ||
| result[i] = parser(input[i]) | ||
| } | ||
| return result | ||
| } |
| import { isObject } from './is-object' | ||
| /** | ||
| * Parses the values of a record with a parser function. | ||
| * Uses for...in instead of Object.entries() to avoid allocating an intermediate array. | ||
| */ | ||
| export const validateRecord = (input: unknown, parser: (input: unknown) => unknown) => { | ||
| if (!isObject(input)) { | ||
| return {} | ||
| } | ||
| const record = input as Record<string, unknown> | ||
| const result: Record<string, unknown> = {} | ||
| for (const key in record) { | ||
| const value = parser(record[key]) | ||
| // A plain assignment of `__proto__` invokes the prototype setter and | ||
| // corrupts `result`'s prototype (a prototype-pollution vector for untrusted | ||
| // input). Define it as an own data property instead so it round-trips as a | ||
| // normal key, matching every other property. | ||
| if (key === '__proto__') { | ||
| Object.defineProperty(result, key, { value, writable: true, enumerable: true, configurable: true }) | ||
| } else { | ||
| result[key] = value | ||
| } | ||
| } | ||
| return result | ||
| } |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12' | ||
| import { buildDynamicRefMap } from './build-dynamic-ref-map' | ||
| import { extractDynamicAnchorDefs } from './extract-dynamic-anchor-defs' | ||
| import { extractRefs } from './extract-refs' | ||
| import { refToFilename } from './ref-to-filename' | ||
| import { refToName } from './ref-to-name' | ||
| import { resolveDynamicRefs } from './resolve-dynamic-refs' | ||
| import { resolveRef } from './resolve-ref' | ||
| import { upgradeDraft07Schema } from './upgrade-draft07-schema' | ||
| /** | ||
| * One node of the `$ref` graph, handed to the `visit` callback. Everything a | ||
| * generator needs to emit a single output file is pre-computed here so the | ||
| * traversal, naming, and `$dynamicRef` rewriting live in one place instead of | ||
| * being copy-pasted into every generator. | ||
| */ | ||
| export type RefNode = { | ||
| /** The `$ref` string this node was reached through, or `undefined` for the root schema. */ | ||
| ref: string | undefined | ||
| /** PascalCase type name — the root type name verbatim, or `refToName(ref, typeSuffix)`. */ | ||
| typeName: string | ||
| /** kebab-case filename without extension — the lowercased root type name, or `refToFilename(ref)`. */ | ||
| filename: string | ||
| /** The subschema to generate, with any `$dynamicRef` already rewritten to `$ref`. */ | ||
| schema: JSONSchema | ||
| /** The upgraded root document, for callers that resolve imports against it. */ | ||
| rootSchema: Record<string, unknown> | ||
| /** True for the root schema node, which is always visited first. */ | ||
| isRoot: boolean | ||
| } | ||
| /** Options controlling how `$ref`-derived names are produced. */ | ||
| export type WalkRefGraphOptions = { | ||
| /** | ||
| * Suffix appended to every `$ref`-derived type name (e.g. `'Object'` → | ||
| * `ContactObject`). The root type name is used verbatim and is not affected. | ||
| * Defaults to `''`. | ||
| */ | ||
| readonly typeSuffix?: string | ||
| } | ||
| /** | ||
| * The reusable, schema-scoped work the walker memoizes. Keyed by the *original* | ||
| * root schema object so repeated walks of the same document — the parsers, | ||
| * validators, and examples generators all running over one loaded schema — | ||
| * pay for the draft-07 upgrade, the dynamic-ref map, and each `resolveRef` / | ||
| * `extractRefs` exactly once. JSON Schema inputs are treated as immutable here; | ||
| * the `WeakMap` drops the entry once the caller releases the schema. | ||
| */ | ||
| type RootCache = { | ||
| upgraded: Record<string, unknown> | ||
| dynamicRefMap: Record<string, string> | ||
| resolveRefCache: Map<string, Record<string, unknown> | undefined> | ||
| extractRefsCache: WeakMap<object, Set<string>> | ||
| } | ||
| const rootCaches = new WeakMap<object, RootCache>() | ||
| const getRootCache = (rootSchema: JSONSchema): RootCache => { | ||
| // Only object roots can key a WeakMap. A boolean root has no refs to walk and | ||
| // the draft-07 upgrade is a no-op for it, so a throwaway cache is fine. | ||
| if (typeof rootSchema !== 'object' || rootSchema === null) { | ||
| const upgraded = rootSchema as unknown as Record<string, unknown> | ||
| return { upgraded, dynamicRefMap: {}, resolveRefCache: new Map(), extractRefsCache: new WeakMap() } | ||
| } | ||
| const existing = rootCaches.get(rootSchema) | ||
| if (existing) return existing | ||
| const upgraded = upgradeDraft07Schema(rootSchema as Record<string, unknown>) | ||
| const cache: RootCache = { | ||
| upgraded, | ||
| dynamicRefMap: buildDynamicRefMap(upgraded as JSONSchema), | ||
| resolveRefCache: new Map(), | ||
| extractRefsCache: new WeakMap(), | ||
| } | ||
| rootCaches.set(rootSchema, cache) | ||
| return cache | ||
| } | ||
| /** Memoized `resolveRef` keyed by ref string within a single root document. */ | ||
| const cachedResolveRef = (cache: RootCache, ref: string): Record<string, unknown> | undefined => { | ||
| if (cache.resolveRefCache.has(ref)) return cache.resolveRefCache.get(ref) | ||
| const resolved = resolveRef(ref, cache.upgraded) | ||
| cache.resolveRefCache.set(ref, resolved) | ||
| return resolved | ||
| } | ||
| /** Memoized `extractRefs` keyed by the (stable) resolved subschema identity. */ | ||
| const cachedExtractRefs = (cache: RootCache, schema: JSONSchema): Set<string> => { | ||
| if (typeof schema !== 'object' || schema === null) return extractRefs(schema) | ||
| const existing = cache.extractRefsCache.get(schema) | ||
| if (existing) return existing | ||
| const refs = extractRefs(schema) | ||
| cache.extractRefsCache.set(schema, refs) | ||
| return refs | ||
| } | ||
| /** | ||
| * Walks a JSON Schema and its entire `$ref` / `$dynamicRef` graph, invoking | ||
| * `visit` once per distinct output file: first the root, then every reachable | ||
| * definition (breadth-first). This is the single, shared traversal the parser, | ||
| * validator, and example generators were each re-implementing. | ||
| * | ||
| * For every node the walker has already upgraded draft-07 inputs, resolved the | ||
| * ref, rewritten `$dynamicRef` to `$ref`, and derived the type/file names — so | ||
| * callers only have to turn `node.schema` into file content. Definitions | ||
| * reachable only via `$dynamicAnchor` are seeded too, so nothing the generated | ||
| * code imports goes ungenerated. A ref that fails to resolve is reported via | ||
| * `console.warn` and skipped, matching the generators' prior behavior. | ||
| * | ||
| * Resolution work is memoized per root document (see {@link RootCache}), so | ||
| * running several generators over the same loaded schema does the expensive | ||
| * walking once. | ||
| * | ||
| * @param rootSchema - The root JSON Schema to walk. | ||
| * @param rootTypeName - The name for the root type (e.g. `'Document'`). | ||
| * @param options - Naming options ({@link WalkRefGraphOptions}). | ||
| * @param visit - Called once per output file with a fully prepared {@link RefNode}. | ||
| */ | ||
| export const walkRefGraph = ( | ||
| rootSchema: JSONSchema, | ||
| rootTypeName: string, | ||
| options: WalkRefGraphOptions, | ||
| visit: (node: RefNode) => void, | ||
| ): void => { | ||
| const typeSuffix = options.typeSuffix ?? '' | ||
| const cache = getRootCache(rootSchema) | ||
| const { upgraded, dynamicRefMap } = cache | ||
| const processedRefs = new Set<string>() | ||
| const processedFilenames = new Set<string>() | ||
| // Root node first — its filename reserves a slot so a later ref that maps to | ||
| // the same name does not emit a duplicate file. | ||
| const rootFilename = rootTypeName.toLowerCase() | ||
| processedFilenames.add(rootFilename) | ||
| visit({ | ||
| ref: undefined, | ||
| typeName: rootTypeName, | ||
| filename: rootFilename, | ||
| schema: resolveDynamicRefs(upgraded as JSONSchema, dynamicRefMap), | ||
| rootSchema: upgraded, | ||
| isRoot: true, | ||
| }) | ||
| const queue: string[] = [ | ||
| ...cachedExtractRefs(cache, upgraded as JSONSchema), | ||
| ...extractDynamicAnchorDefs(upgraded as JSONSchema), | ||
| ] | ||
| // Advance a read cursor instead of `queue.shift()`, whose O(n) element move | ||
| // makes draining a large ref graph quadratic. | ||
| for (let head = 0; head < queue.length; head++) { | ||
| const ref = queue[head] | ||
| if (!ref || processedRefs.has(ref)) continue | ||
| processedRefs.add(ref) | ||
| const resolved = cachedResolveRef(cache, ref) | ||
| if (!resolved) { | ||
| console.warn(`Warning: Could not resolve ref: ${ref}`) | ||
| continue | ||
| } | ||
| const filename = refToFilename(ref) | ||
| if (!processedFilenames.has(filename)) { | ||
| processedFilenames.add(filename) | ||
| visit({ | ||
| ref, | ||
| typeName: refToName(ref, typeSuffix), | ||
| filename, | ||
| schema: resolveDynamicRefs(resolved as JSONSchema, dynamicRefMap), | ||
| rootSchema: upgraded, | ||
| isRoot: false, | ||
| }) | ||
| } | ||
| // Always queue nested refs from the resolved schema, even when its file was | ||
| // a duplicate: two ref strings can share a filename yet reach different | ||
| // sub-definitions (e.g. a URI key and its short-name alias). | ||
| for (const nested of cachedExtractRefs(cache, resolved as JSONSchema)) { | ||
| if (!processedRefs.has(nested)) queue.push(nested) | ||
| } | ||
| } | ||
| } |
| /** | ||
| * Escapes a JSON Schema `pattern` so it can be embedded between the slashes of | ||
| * a generated regex literal (`/…/`). | ||
| * a generated regex literal (`/…/`), and validates that the pattern is a legal | ||
| * regex at generation time. | ||
| * | ||
@@ -8,10 +9,19 @@ * A `pattern` is an ECMA-262 regex *body*, and the generated text goes into a | ||
| * must be left exactly as-is (doubling `\d` to `\\d` would change it from "a | ||
| * digit" to "a literal backslash followed by d"). The only character that would | ||
| * corrupt the surrounding literal is an *unescaped* `/`, which would close it | ||
| * early. So we escape bare slashes to `\/` while leaving every existing escape | ||
| * sequence (including an already-escaped `\/`) untouched. | ||
| * digit" to "a literal backslash followed by d"). Two things would otherwise | ||
| * corrupt the surrounding literal: | ||
| * - an *unescaped* `/`, which would close the literal early; and | ||
| * - a raw line terminator (LF, CR, U+2028, U+2029), which is not allowed | ||
| * inside a regex literal and would split the emitted `/…/` across lines (a | ||
| * syntax error). Each is rewritten to its backslash escape, which matches | ||
| * the identical character, so the regex's meaning is unchanged. | ||
| * | ||
| * Additionally, an *invalid* pattern (e.g. `([`) would be emitted verbatim as | ||
| * `/([/…` and produce output that does not parse. We compile the pattern with | ||
| * `new RegExp` here and throw a clear generator-time error instead, so a bad | ||
| * schema fails loudly during generation rather than emitting broken code. | ||
| * | ||
| * @example | ||
| * escapeRegexPattern('\\d{4}/\\d{2}') // → '\\d{4}\\/\\d{2}' (i.e. \d{4}\/\d{2}) | ||
| * @throws if `pattern` is not a valid regular expression. | ||
| */ | ||
| export declare const escapeRegexPattern: (pattern: string) => string; |
| /** | ||
| * Escapes a JSON Schema `pattern` so it can be embedded between the slashes of | ||
| * a generated regex literal (`/…/`). | ||
| * a generated regex literal (`/…/`), and validates that the pattern is a legal | ||
| * regex at generation time. | ||
| * | ||
@@ -8,14 +9,51 @@ * A `pattern` is an ECMA-262 regex *body*, and the generated text goes into a | ||
| * must be left exactly as-is (doubling `\d` to `\\d` would change it from "a | ||
| * digit" to "a literal backslash followed by d"). The only character that would | ||
| * corrupt the surrounding literal is an *unescaped* `/`, which would close it | ||
| * early. So we escape bare slashes to `\/` while leaving every existing escape | ||
| * sequence (including an already-escaped `\/`) untouched. | ||
| * digit" to "a literal backslash followed by d"). Two things would otherwise | ||
| * corrupt the surrounding literal: | ||
| * - an *unescaped* `/`, which would close the literal early; and | ||
| * - a raw line terminator (LF, CR, U+2028, U+2029), which is not allowed | ||
| * inside a regex literal and would split the emitted `/…/` across lines (a | ||
| * syntax error). Each is rewritten to its backslash escape, which matches | ||
| * the identical character, so the regex's meaning is unchanged. | ||
| * | ||
| * Additionally, an *invalid* pattern (e.g. `([`) would be emitted verbatim as | ||
| * `/([/…` and produce output that does not parse. We compile the pattern with | ||
| * `new RegExp` here and throw a clear generator-time error instead, so a bad | ||
| * schema fails loudly during generation rather than emitting broken code. | ||
| * | ||
| * @example | ||
| * escapeRegexPattern('\\d{4}/\\d{2}') // → '\\d{4}\\/\\d{2}' (i.e. \d{4}\/\d{2}) | ||
| * @throws if `pattern` is not a valid regular expression. | ||
| */ | ||
| export const escapeRegexPattern = (pattern) => | ||
| // Match either an escape sequence (`\` + any char, kept verbatim) or a bare | ||
| // `/` (escaped). Consuming escape pairs first means the slash in `\/` is never | ||
| // seen as bare, so it is not double-escaped. | ||
| pattern.replace(/\\[\s\S]|\//g, (match) => (match === '/' ? '\\/' : match)); | ||
| export const escapeRegexPattern = (pattern) => { | ||
| // Validate at generation time — an invalid pattern must fail here, not emit | ||
| // a `/([/` literal that breaks the generated file. | ||
| try { | ||
| new RegExp(pattern); | ||
| } | ||
| catch (error) { | ||
| throw new Error(`Invalid regex pattern ${JSON.stringify(pattern)}: ${error instanceof Error ? error.message : String(error)}`); | ||
| } | ||
| // Line terminators, keyed by code point, and the backslash escape each maps to. | ||
| // These are the four characters disallowed *raw* inside a regex literal. | ||
| const lineTerminatorEscapes = { | ||
| 0x0a: '\\n', // LF | ||
| 0x0d: '\\r', // CR | ||
| 0x2028: '\\u2028', // LINE SEPARATOR | ||
| 0x2029: '\\u2029', // PARAGRAPH SEPARATOR | ||
| }; | ||
| // Match either an escape sequence (`\` + any char, kept verbatim) or a single | ||
| // character that would corrupt the literal (a bare `/` or a line terminator). | ||
| // The line terminators are written as `\u….` escapes so this source file is | ||
| // itself free of the raw characters it guards against. Consuming escape pairs | ||
| // first means the slash in `\/` is never seen as bare, so it is not | ||
| // double-escaped. | ||
| return pattern.replace(/\\[\s\S]|[/\n\r\u2028\u2029]/g, (match) => { | ||
| if (match === '/') | ||
| return '\\/'; | ||
| const terminatorEscape = lineTerminatorEscapes[match.charCodeAt(0)]; | ||
| if (terminatorEscape !== undefined && match.length === 1) | ||
| return terminatorEscape; | ||
| // An escape pair like `\/` or `\d`: keep exactly as authored. | ||
| return match; | ||
| }); | ||
| }; |
@@ -34,8 +34,9 @@ // Generated files declare their public surface with these two forms, so we can | ||
| continue; | ||
| // `.js` extension so the barrel resolves under Node ESM, not only Bun. | ||
| if (typesOnly) { | ||
| indexContent += `export type { ${typeNames.join(', ')} } from './${moduleName}';\n`; | ||
| indexContent += `export type { ${typeNames.join(', ')} } from './${moduleName}.js';\n`; | ||
| } | ||
| else { | ||
| const typeExports = typeNames.map((name) => `type ${name}`); | ||
| indexContent += `export { ${[...typeExports, ...constNames].join(', ')} } from './${moduleName}';\n`; | ||
| indexContent += `export { ${[...typeExports, ...constNames].join(', ')} } from './${moduleName}.js';\n`; | ||
| } | ||
@@ -42,0 +43,0 @@ } |
@@ -120,2 +120,31 @@ import { getMjstBrand, getMjstInstanceOf, getMjstPrimitive } from './mjst-extension.js'; | ||
| /** | ||
| * Builds the `Record<…>` type for a schema's `patternProperties`. Every pattern's | ||
| * value type is unioned (deduplicated) rather than only the first being used, so | ||
| * `{ '^a': string-schema, '^b': number-schema }` becomes `Record<string, string | | ||
| * number>` instead of silently dropping the second pattern. The template-literal | ||
| * `` `x-${string}` `` key is kept only when the sole pattern is the `^x-` vendor | ||
| * convention. Returns `undefined` when there are no usable patterns. | ||
| */ | ||
| const patternPropertiesRecordType = (patternProperties, options) => { | ||
| const entries = Object.entries(patternProperties); | ||
| if (entries.length === 0) | ||
| return undefined; | ||
| const seen = new Set(); | ||
| const valueTypes = []; | ||
| for (const [, value] of entries) { | ||
| const valueType = typeof value === 'boolean' ? getBooleanSubSchemaType(value) : getTypeScriptType(value, options); | ||
| if (!seen.has(valueType)) { | ||
| seen.add(valueType); | ||
| valueTypes.push(valueType); | ||
| } | ||
| } | ||
| if (valueTypes.length === 0) | ||
| return undefined; | ||
| const valueType = valueTypes.join(' | '); | ||
| // The `^x-` vendor-extension convention maps to a template-literal key, but only | ||
| // when it is the single pattern (a union of key patterns can't be expressed). | ||
| const keyType = entries.length === 1 && entries[0]?.[0] === '^x-' ? '`x-${string}`' : 'string'; | ||
| return recordType(keyType, valueType, options); | ||
| }; | ||
| /** | ||
| * Converts a JSON Schema type to its TypeScript equivalent, ignoring any brand. | ||
@@ -176,10 +205,2 @@ * Recursively handles nested objects and arrays. | ||
| } | ||
| // Handle multi-value enum - union of literal types | ||
| if (schema.enum && schema.enum.length > 1) { | ||
| let multiEnumUnion = JSON.stringify(schema.enum[0]); | ||
| for (let i = 1; i < schema.enum.length; i++) { | ||
| multiEnumUnion += ' | ' + JSON.stringify(schema.enum[i]); | ||
| } | ||
| return multiEnumUnion; | ||
| } | ||
| // Handle union types (oneOf, anyOf) - check this before returning unknown | ||
@@ -229,15 +250,5 @@ if (schema.oneOf && Array.isArray(schema.oneOf) && schema.oneOf.length > 0) { | ||
| if (schema.patternProperties && typeof schema.patternProperties === 'object') { | ||
| const firstEntry = Object.entries(schema.patternProperties)[0]; | ||
| if (firstEntry) { | ||
| const [pattern, value] = firstEntry; | ||
| if (value !== undefined) { | ||
| const valueType = typeof value === 'boolean' ? getBooleanSubSchemaType(value) : getTypeScriptType(value, options); | ||
| // The ^x- pattern is a common JSON Schema convention for vendor extensions | ||
| // that maps naturally to the TypeScript template literal `x-${string}` | ||
| if (pattern === '^x-') { | ||
| return recordType('`x-${string}`', valueType, options); | ||
| } | ||
| return recordType('string', valueType, options); | ||
| } | ||
| } | ||
| const record = patternPropertiesRecordType(schema.patternProperties, options); | ||
| if (record !== undefined) | ||
| return record; | ||
| } | ||
@@ -308,2 +319,5 @@ if (schema.default !== undefined) { | ||
| const readonlyPrefix = options.readonly ? 'readonly ' : ''; | ||
| // Build the required-key set once instead of an O(required) `includes` | ||
| // per property (O(properties × required) for a wide object). | ||
| const requiredSet = new Set(Array.isArray(schema.required) ? schema.required : []); | ||
| const hasDescriptions = Object.values(schema.properties).some((p) => isSchemaObject(p) && (typeof p.description === 'string' || typeof p.$comment === 'string')); | ||
@@ -316,3 +330,3 @@ if (hasDescriptions) { | ||
| const propSchema = schema.properties[key]; | ||
| const isRequired = schema.required?.includes(key) ?? false; | ||
| const isRequired = requiredSet.has(key); | ||
| const optional = isRequired ? '' : '?'; | ||
@@ -350,3 +364,3 @@ const propType = getTypeScriptType(propSchema, options); | ||
| const propSchema = schema.properties[key]; | ||
| const isRequired = schema.required?.includes(key) ?? false; | ||
| const isRequired = requiredSet.has(key); | ||
| const optional = isRequired ? '' : '?'; | ||
@@ -368,13 +382,5 @@ const propType = getTypeScriptType(propSchema, options); | ||
| if (schema.patternProperties && typeof schema.patternProperties === 'object') { | ||
| const firstEntry = Object.entries(schema.patternProperties)[0]; | ||
| if (firstEntry) { | ||
| const [pattern, patternVal] = firstEntry; | ||
| if (patternVal) { | ||
| const valueType = getTypeScriptType(patternVal, options); | ||
| if (pattern === '^x-') { | ||
| return recordType('`x-${string}`', valueType, options); | ||
| } | ||
| return recordType('string', valueType, options); | ||
| } | ||
| } | ||
| const record = patternPropertiesRecordType(schema.patternProperties, options); | ||
| if (record !== undefined) | ||
| return record; | ||
| } | ||
@@ -427,14 +433,3 @@ return 'object'; | ||
| if (!hasProperties && hasPatternProperties && normalizedSchema.patternProperties) { | ||
| const firstEntry = Object.entries(normalizedSchema.patternProperties)[0]; | ||
| const firstPattern = firstEntry?.[0]; | ||
| const firstPatternProperty = firstEntry?.[1]; | ||
| if (firstPatternProperty === undefined) { | ||
| return `export type ${typeName} = Record<string, unknown>;`; | ||
| } | ||
| const patternPropType = typeof firstPatternProperty === 'boolean' | ||
| ? getBooleanSubSchemaType(firstPatternProperty) | ||
| : getTypeScriptType(firstPatternProperty, options); | ||
| // The ^x- pattern is a common JSON Schema convention for vendor extensions | ||
| // that maps naturally to the TypeScript template literal `x-${string}` | ||
| const keyType = firstPattern === '^x-' ? '`x-${string}`' : 'string'; | ||
| const record = patternPropertiesRecordType(normalizedSchema.patternProperties, options) ?? 'Record<string, unknown>'; | ||
| let result = ''; | ||
@@ -444,3 +439,3 @@ if (jsDocTitle && jsDocDescription) { | ||
| } | ||
| result += `export type ${typeName} = ${recordType(keyType, patternPropType, options)};`; | ||
| result += `export type ${typeName} = ${record};`; | ||
| return result; | ||
@@ -459,2 +454,3 @@ } | ||
| const schemaProps = normalizedSchema.properties ?? {}; | ||
| const requiredSet = new Set(Array.isArray(normalizedSchema.required) ? normalizedSchema.required : []); | ||
| let properties = ''; | ||
@@ -465,3 +461,3 @@ let isFirstProp = true; | ||
| const propSchema = schemaProps[key]; | ||
| const isRequired = normalizedSchema.required?.includes(key) ?? false; | ||
| const isRequired = requiredSet.has(key); | ||
| const optional = isRequired ? '' : '?'; | ||
@@ -468,0 +464,0 @@ const propType = getTypeScriptType(propSchema, options); |
@@ -32,5 +32,9 @@ /** | ||
| // Find the section in the markdown | ||
| // Match the section heading and capture everything until the next same-level heading or end of file | ||
| // Match the section heading and capture everything until the next same-level heading or end of file. | ||
| // `sectionTitle` comes from the URL fragment, so escape any regex metacharacters | ||
| // before interpolating — otherwise a fragment containing `+`, `(`, etc. throws a | ||
| // SyntaxError that the outer catch then mislabels as a fetch failure. | ||
| const escapedHashes = headingHashes.replace(/#/g, '\\#'); | ||
| const sectionRegex = new RegExp(`${escapedHashes}\\s+${sectionTitle}\\s*\\n([\\s\\S]*?)(?=\\n${escapedHashes}\\s|$)`, 'i'); | ||
| const escapedTitle = sectionTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| const sectionRegex = new RegExp(`${escapedHashes}\\s+${escapedTitle}\\s*\\n([\\s\\S]*?)(?=\\n${escapedHashes}\\s|$)`, 'i'); | ||
| const sectionMatch = markdown.match(sectionRegex); | ||
@@ -37,0 +41,0 @@ if (!sectionMatch) { |
+17
-2
| import { refToFilename } from './ref-to-filename.js'; | ||
| /** | ||
| * Converts a kebab-case filename to PascalCase, appending an optional suffix. | ||
| * Converts a filename-like string to a PascalCase TypeScript identifier, | ||
| * appending an optional suffix. | ||
| * | ||
| * Word boundaries are *any* run of characters that cannot appear in a JS | ||
| * identifier — not just `-` — so a dotted key like `io.k8s.api.core.v1.Pod` | ||
| * becomes `IoK8sApiCoreV1Pod` instead of the invalid `Io.k8s.api.core.v1.pod`. | ||
| * A leading digit is prefixed with `_`, and an otherwise-empty result falls back | ||
| * to `_`, so the output is always a usable identifier. | ||
| * | ||
| * @example | ||
@@ -10,10 +17,18 @@ * ```ts | ||
| * kebabToPascal('channel', 'Object') // 'ChannelObject' | ||
| * kebabToPascal('io.k8s.api.core.v1.pod') // 'IoK8sApiCoreV1Pod' | ||
| * kebabToPascal('123abc') // '_123abc' | ||
| * ``` | ||
| */ | ||
| const kebabToPascal = (kebab, suffix) => { | ||
| const words = kebab.split('-'); | ||
| const words = kebab.split(/[^A-Za-z0-9]+/); | ||
| let pascalCase = ''; | ||
| for (const word of words) { | ||
| if (word === '') | ||
| continue; | ||
| pascalCase += word.charAt(0).toUpperCase() + word.slice(1); | ||
| } | ||
| if (pascalCase === '') | ||
| pascalCase = '_'; | ||
| else if (/^[0-9]/.test(pascalCase)) | ||
| pascalCase = `_${pascalCase}`; | ||
| return pascalCase + suffix; | ||
@@ -20,0 +35,0 @@ }; |
@@ -21,2 +21,8 @@ /** | ||
| } | ||
| // Cheap read-only pre-scan: most subschemas carry no `$dynamicRef`, so returning | ||
| // the original untouched avoids a full deep clone (and its allocation) on every | ||
| // node of every generator's walk when the document as a whole *does* use them. | ||
| if (!containsDynamicRef(schema)) { | ||
| return schema; | ||
| } | ||
| const clone = JSON.parse(JSON.stringify(schema)); | ||
@@ -47,1 +53,19 @@ const walk = (obj) => { | ||
| }; | ||
| /** True if `value` contains a `$dynamicRef` string anywhere in its subtree. */ | ||
| const containsDynamicRef = (value) => { | ||
| if (typeof value !== 'object' || value === null) | ||
| return false; | ||
| if (Array.isArray(value)) { | ||
| for (const item of value) | ||
| if (containsDynamicRef(item)) | ||
| return true; | ||
| return false; | ||
| } | ||
| const record = value; | ||
| if (typeof record['$dynamicRef'] === 'string') | ||
| return true; | ||
| for (const key in record) | ||
| if (containsDynamicRef(record[key])) | ||
| return true; | ||
| return false; | ||
| }; |
@@ -12,4 +12,4 @@ /** | ||
| * safeAccessor("input", "name") // "input.name" | ||
| * safeAccessor("input?", "x-linkedin") // "input?.['x-linkedin']" | ||
| * safeAccessor("input", "x-linkedin") // "input['x-linkedin']" | ||
| * safeAccessor("input?", "x-linkedin") // 'input?.["x-linkedin"]' | ||
| * safeAccessor("input", "x-linkedin") // 'input["x-linkedin"]' | ||
| */ | ||
@@ -23,4 +23,4 @@ export declare const safeAccessor: (variable: string, key: string) => string; | ||
| * safeKey("name") // "name" | ||
| * safeKey("x-linkedin") // "'x-linkedin'" | ||
| * safeKey("x-linkedin") // '"x-linkedin"' | ||
| */ | ||
| export declare const safeKey: (key: string) => string; |
@@ -18,4 +18,4 @@ /** | ||
| * safeAccessor("input", "name") // "input.name" | ||
| * safeAccessor("input?", "x-linkedin") // "input?.['x-linkedin']" | ||
| * safeAccessor("input", "x-linkedin") // "input['x-linkedin']" | ||
| * safeAccessor("input?", "x-linkedin") // 'input?.["x-linkedin"]' | ||
| * safeAccessor("input", "x-linkedin") // 'input["x-linkedin"]' | ||
| */ | ||
@@ -26,7 +26,10 @@ export const safeAccessor = (variable, key) => { | ||
| } | ||
| // Bracket keys are schema-controlled, so escape via JSON.stringify — a key like | ||
| // `it's` or `a']; evil(); //` would otherwise break or hijack the generated code. | ||
| const literal = JSON.stringify(key); | ||
| // Handle optional chaining: "input?" -> "input?.['key']" | ||
| if (variable.endsWith('?')) { | ||
| return `${variable}.['${key}']`; | ||
| return `${variable}.[${literal}]`; | ||
| } | ||
| return `${variable}['${key}']`; | ||
| return `${variable}[${literal}]`; | ||
| }; | ||
@@ -39,3 +42,3 @@ /** | ||
| * safeKey("name") // "name" | ||
| * safeKey("x-linkedin") // "'x-linkedin'" | ||
| * safeKey("x-linkedin") // '"x-linkedin"' | ||
| */ | ||
@@ -46,3 +49,4 @@ export const safeKey = (key) => { | ||
| } | ||
| return `'${key}'`; | ||
| // Schema-controlled keys must be escaped; a bare-quoted `it's` produces broken TS. | ||
| return JSON.stringify(key); | ||
| }; |
@@ -13,5 +13,15 @@ import { isObject } from './is-object.js'; | ||
| for (const key in record) { | ||
| result[key] = parser(record[key]); | ||
| const value = parser(record[key]); | ||
| // A plain assignment of `__proto__` invokes the prototype setter and | ||
| // corrupts `result`'s prototype (a prototype-pollution vector for untrusted | ||
| // input). Define it as an own data property instead so it round-trips as a | ||
| // normal key, matching every other property. | ||
| if (key === '__proto__') { | ||
| Object.defineProperty(result, key, { value, writable: true, enumerable: true, configurable: true }); | ||
| } | ||
| else { | ||
| result[key] = value; | ||
| } | ||
| } | ||
| return result; | ||
| }; |
@@ -93,4 +93,6 @@ import { buildDynamicRefMap } from './build-dynamic-ref-map.js'; | ||
| ]; | ||
| while (queue.length > 0) { | ||
| const ref = queue.shift(); | ||
| // Advance a read cursor instead of `queue.shift()`, whose O(n) element move | ||
| // makes draining a large ref graph quadratic. | ||
| for (let head = 0; head < queue.length; head++) { | ||
| const ref = queue[head]; | ||
| if (!ref || processedRefs.has(ref)) | ||
@@ -97,0 +99,0 @@ continue; |
+4
-2
| { | ||
| "name": "@amritk/helpers", | ||
| "version": "0.10.1", | ||
| "version": "0.10.2", | ||
| "description": "Shared utilities for the mjst code generation ecosystem.", | ||
@@ -23,3 +23,5 @@ "type": "module", | ||
| "files": [ | ||
| "dist" | ||
| "dist", | ||
| "src/**/*.ts", | ||
| "!src/**/*.test.ts" | ||
| ], | ||
@@ -26,0 +28,0 @@ "publishConfig": { |
206373
87.24%71
54.35%4502
90.6%