Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Failing after 34s
Build & Deploy / 🏗️ Build (push) Has started running
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
423 lines
11 KiB
Plaintext
423 lines
11 KiB
Plaintext
import {deburr, isPlainObject, trim, upperFirst} from 'lodash'
|
|
import {basename, dirname, extname, normalize, sep, posix} from 'path'
|
|
import {Intersection, JSONSchema, LinkedJSONSchema, NormalizedJSONSchema, Parent} from './types/JSONSchema'
|
|
import {JSONSchema4} from 'json-schema'
|
|
import yaml from 'js-yaml'
|
|
import type {Format} from 'cli-color'
|
|
|
|
// TODO: pull out into a separate package
|
|
export function Try<T>(fn: () => T, err: (e: Error) => any): T {
|
|
try {
|
|
return fn()
|
|
} catch (e) {
|
|
return err(e as Error)
|
|
}
|
|
}
|
|
|
|
// keys that shouldn't be traversed by the catchall step
|
|
const BLACKLISTED_KEYS = new Set([
|
|
'id',
|
|
'$defs',
|
|
'$id',
|
|
'$schema',
|
|
'title',
|
|
'description',
|
|
'default',
|
|
'multipleOf',
|
|
'maximum',
|
|
'exclusiveMaximum',
|
|
'minimum',
|
|
'exclusiveMinimum',
|
|
'maxLength',
|
|
'minLength',
|
|
'pattern',
|
|
'additionalItems',
|
|
'items',
|
|
'maxItems',
|
|
'minItems',
|
|
'uniqueItems',
|
|
'maxProperties',
|
|
'minProperties',
|
|
'required',
|
|
'additionalProperties',
|
|
'definitions',
|
|
'properties',
|
|
'patternProperties',
|
|
'dependencies',
|
|
'enum',
|
|
'type',
|
|
'allOf',
|
|
'anyOf',
|
|
'oneOf',
|
|
'not',
|
|
])
|
|
|
|
function traverseObjectKeys(
|
|
obj: Record<string, LinkedJSONSchema>,
|
|
callback: (schema: LinkedJSONSchema, key: string | null) => void,
|
|
processed: Set<LinkedJSONSchema>,
|
|
) {
|
|
Object.keys(obj).forEach(k => {
|
|
if (obj[k] && typeof obj[k] === 'object' && !Array.isArray(obj[k])) {
|
|
traverse(obj[k], callback, processed, k)
|
|
}
|
|
})
|
|
}
|
|
|
|
function traverseArray(
|
|
arr: LinkedJSONSchema[],
|
|
callback: (schema: LinkedJSONSchema, key: string | null) => void,
|
|
processed: Set<LinkedJSONSchema>,
|
|
) {
|
|
arr.forEach((s, k) => traverse(s, callback, processed, k.toString()))
|
|
}
|
|
|
|
function traverseIntersection(
|
|
schema: LinkedJSONSchema,
|
|
callback: (schema: LinkedJSONSchema, key: string | null) => void,
|
|
processed: Set<LinkedJSONSchema>,
|
|
) {
|
|
if (typeof schema !== 'object' || !schema) {
|
|
return
|
|
}
|
|
|
|
const r = schema as unknown as Record<string | symbol, unknown>
|
|
const intersection = r[Intersection] as NormalizedJSONSchema | undefined
|
|
if (!intersection) {
|
|
return
|
|
}
|
|
|
|
if (Array.isArray(intersection.allOf)) {
|
|
traverseArray(intersection.allOf, callback, processed)
|
|
}
|
|
}
|
|
|
|
export function traverse(
|
|
schema: LinkedJSONSchema,
|
|
callback: (schema: LinkedJSONSchema, key: string | null) => void,
|
|
processed = new Set<LinkedJSONSchema>(),
|
|
key?: string,
|
|
): void {
|
|
// Handle recursive schemas
|
|
if (processed.has(schema)) {
|
|
return
|
|
}
|
|
|
|
processed.add(schema)
|
|
callback(schema, key ?? null)
|
|
|
|
if (schema.anyOf) {
|
|
traverseArray(schema.anyOf, callback, processed)
|
|
}
|
|
if (schema.allOf) {
|
|
traverseArray(schema.allOf, callback, processed)
|
|
}
|
|
if (schema.oneOf) {
|
|
traverseArray(schema.oneOf, callback, processed)
|
|
}
|
|
if (schema.properties) {
|
|
traverseObjectKeys(schema.properties, callback, processed)
|
|
}
|
|
if (schema.patternProperties) {
|
|
traverseObjectKeys(schema.patternProperties, callback, processed)
|
|
}
|
|
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
traverse(schema.additionalProperties, callback, processed)
|
|
}
|
|
if (schema.items) {
|
|
const {items} = schema
|
|
if (Array.isArray(items)) {
|
|
traverseArray(items, callback, processed)
|
|
} else {
|
|
traverse(items, callback, processed)
|
|
}
|
|
}
|
|
if (schema.additionalItems && typeof schema.additionalItems === 'object') {
|
|
traverse(schema.additionalItems, callback, processed)
|
|
}
|
|
if (schema.dependencies) {
|
|
if (Array.isArray(schema.dependencies)) {
|
|
traverseArray(schema.dependencies, callback, processed)
|
|
} else {
|
|
traverseObjectKeys(schema.dependencies as LinkedJSONSchema, callback, processed)
|
|
}
|
|
}
|
|
if (schema.definitions) {
|
|
traverseObjectKeys(schema.definitions, callback, processed)
|
|
}
|
|
if (schema.$defs) {
|
|
traverseObjectKeys(schema.$defs, callback, processed)
|
|
}
|
|
if (schema.not) {
|
|
traverse(schema.not, callback, processed)
|
|
}
|
|
traverseIntersection(schema, callback, processed)
|
|
|
|
// technically you can put definitions on any key
|
|
Object.keys(schema)
|
|
.filter(key => !BLACKLISTED_KEYS.has(key))
|
|
.forEach(key => {
|
|
const child = schema[key]
|
|
if (child && typeof child === 'object') {
|
|
traverseObjectKeys(child, callback, processed)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Eg. `foo/bar/baz.json` => `baz`
|
|
*/
|
|
export function justName(filename = ''): string {
|
|
return stripExtension(basename(filename))
|
|
}
|
|
|
|
/**
|
|
* Avoid appending "js" to top-level unnamed schemas
|
|
*/
|
|
export function stripExtension(filename: string): string {
|
|
return filename.replace(extname(filename), '')
|
|
}
|
|
|
|
/**
|
|
* Convert a string that might contain spaces or special characters to one that
|
|
* can safely be used as a TypeScript interface or enum name.
|
|
*/
|
|
export function toSafeString(string: string) {
|
|
// identifiers in javaScript/ts:
|
|
// First character: a-zA-Z | _ | $
|
|
// Rest: a-zA-Z | _ | $ | 0-9
|
|
|
|
return upperFirst(
|
|
// remove accents, umlauts, ... by their basic latin letters
|
|
deburr(string)
|
|
// replace chars which are not valid for typescript identifiers with whitespace
|
|
.replace(/(^\s*[^a-zA-Z_$])|([^a-zA-Z_$\d])/g, ' ')
|
|
// uppercase leading underscores followed by lowercase
|
|
.replace(/^_[a-z]/g, match => match.toUpperCase())
|
|
// remove non-leading underscores followed by lowercase (convert snake_case)
|
|
.replace(/_[a-z]/g, match => match.substr(1, match.length).toUpperCase())
|
|
// uppercase letters after digits, dollars
|
|
.replace(/([\d$]+[a-zA-Z])/g, match => match.toUpperCase())
|
|
// uppercase first letter after whitespace
|
|
.replace(/\s+([a-zA-Z])/g, match => trim(match.toUpperCase()))
|
|
// remove remaining whitespace
|
|
.replace(/\s/g, ''),
|
|
)
|
|
}
|
|
|
|
export function generateName(from: string, usedNames: Set<string>) {
|
|
let name = toSafeString(from)
|
|
if (!name) {
|
|
name = 'NoName'
|
|
}
|
|
|
|
// increment counter until we find a free name
|
|
if (usedNames.has(name)) {
|
|
let counter = 1
|
|
let nameWithCounter = `${name}${counter}`
|
|
while (usedNames.has(nameWithCounter)) {
|
|
nameWithCounter = `${name}${counter}`
|
|
counter++
|
|
}
|
|
name = nameWithCounter
|
|
}
|
|
|
|
usedNames.add(name)
|
|
return name
|
|
}
|
|
|
|
export function error(...messages: any[]): void {
|
|
if (!process.env.VERBOSE) {
|
|
return console.error(messages)
|
|
}
|
|
console.error(getStyledTextForLogging('red')?.('error'), ...messages)
|
|
}
|
|
|
|
type LogStyle = 'blue' | 'cyan' | 'green' | 'magenta' | 'red' | 'white' | 'yellow'
|
|
|
|
export function log(style: LogStyle, title: string, ...messages: unknown[]): void {
|
|
if (!process.env.VERBOSE) {
|
|
return
|
|
}
|
|
let lastMessage = null
|
|
if (messages.length > 1 && typeof messages[messages.length - 1] !== 'string') {
|
|
lastMessage = messages.splice(messages.length - 1, 1)
|
|
}
|
|
console.info(color()?.whiteBright.bgCyan('debug'), getStyledTextForLogging(style)?.(title), ...messages)
|
|
if (lastMessage) {
|
|
console.dir(lastMessage, {depth: 6, maxArrayLength: 6})
|
|
}
|
|
}
|
|
|
|
function getStyledTextForLogging(style: LogStyle): ((text: string) => string) | undefined {
|
|
if (!process.env.VERBOSE) {
|
|
return
|
|
}
|
|
switch (style) {
|
|
case 'blue':
|
|
return color()?.whiteBright.bgBlue
|
|
case 'cyan':
|
|
return color()?.whiteBright.bgCyan
|
|
case 'green':
|
|
return color()?.whiteBright.bgGreen
|
|
case 'magenta':
|
|
return color()?.whiteBright.bgMagenta
|
|
case 'red':
|
|
return color()?.whiteBright.bgRedBright
|
|
case 'white':
|
|
return color()?.black.bgWhite
|
|
case 'yellow':
|
|
return color()?.whiteBright.bgYellow
|
|
}
|
|
}
|
|
|
|
/**
|
|
* escape block comments in schema descriptions so that they don't unexpectedly close JSDoc comments in generated typescript interfaces
|
|
*/
|
|
export function escapeBlockComment(schema: JSONSchema) {
|
|
const replacer = '* /'
|
|
if (schema === null || typeof schema !== 'object') {
|
|
return
|
|
}
|
|
for (const key of Object.keys(schema)) {
|
|
if (key === 'description' && typeof schema[key] === 'string') {
|
|
schema[key] = schema[key]!.replace(/\*\//g, replacer)
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
the following logic determines the out path by comparing the in path to the users specified out path.
|
|
For example, if input directory MultiSchema looks like:
|
|
MultiSchema/foo/a.json
|
|
MultiSchema/bar/fuzz/c.json
|
|
MultiSchema/bar/d.json
|
|
And the user wants the outputs to be in MultiSchema/Out, then this code will be able to map the inner directories foo, bar, and fuzz into the intended Out directory like so:
|
|
MultiSchema/Out/foo/a.json
|
|
MultiSchema/Out/bar/fuzz/c.json
|
|
MultiSchema/Out/bar/d.json
|
|
*/
|
|
export function pathTransform(outputPath: string, inputPath: string, filePath: string): string {
|
|
const inPathList = normalize(inputPath).split(sep)
|
|
const filePathList = dirname(normalize(filePath)).split(sep)
|
|
const filePathRel = filePathList.filter((f, i) => f !== inPathList[i])
|
|
|
|
return posix.join(posix.normalize(outputPath), ...filePathRel)
|
|
}
|
|
|
|
/**
|
|
* Removes the schema's `default` property if it doesn't match the schema's `type` property.
|
|
* Useful when parsing unions.
|
|
*
|
|
* Mutates `schema`.
|
|
*/
|
|
export function maybeStripDefault(schema: LinkedJSONSchema): LinkedJSONSchema {
|
|
if (!('default' in schema)) {
|
|
return schema
|
|
}
|
|
|
|
switch (schema.type) {
|
|
case 'array':
|
|
if (Array.isArray(schema.default)) {
|
|
return schema
|
|
}
|
|
break
|
|
case 'boolean':
|
|
if (typeof schema.default === 'boolean') {
|
|
return schema
|
|
}
|
|
break
|
|
case 'integer':
|
|
case 'number':
|
|
if (typeof schema.default === 'number') {
|
|
return schema
|
|
}
|
|
break
|
|
case 'string':
|
|
if (typeof schema.default === 'string') {
|
|
return schema
|
|
}
|
|
break
|
|
case 'null':
|
|
if (schema.default === null) {
|
|
return schema
|
|
}
|
|
break
|
|
case 'object':
|
|
if (isPlainObject(schema.default)) {
|
|
return schema
|
|
}
|
|
break
|
|
}
|
|
delete schema.default
|
|
return schema
|
|
}
|
|
|
|
export function appendToDescription(existingDescription: string | undefined, ...values: string[]): string {
|
|
if (existingDescription) {
|
|
return `${existingDescription}\n\n${values.join('\n')}`
|
|
}
|
|
return values.join('\n')
|
|
}
|
|
|
|
export function isSchemaLike(schema: any): schema is LinkedJSONSchema {
|
|
if (!isPlainObject(schema)) {
|
|
return false
|
|
}
|
|
|
|
// top-level schema
|
|
const parent = schema[Parent]
|
|
if (parent === null) {
|
|
return true
|
|
}
|
|
|
|
const JSON_SCHEMA_KEYWORDS = [
|
|
'$defs',
|
|
'allOf',
|
|
'anyOf',
|
|
'definitions',
|
|
'dependencies',
|
|
'enum',
|
|
'not',
|
|
'oneOf',
|
|
'patternProperties',
|
|
'properties',
|
|
'required',
|
|
]
|
|
if (JSON_SCHEMA_KEYWORDS.some(_ => parent[_] === schema)) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export function parseFileAsJSONSchema(filename: string | null, contents: string): JSONSchema4 {
|
|
if (filename != null && isYaml(filename)) {
|
|
return Try(
|
|
() => yaml.load(contents.toString()) as JSONSchema4,
|
|
() => {
|
|
throw new TypeError(`Error parsing YML in file "${filename}"`)
|
|
},
|
|
)
|
|
}
|
|
|
|
return Try(
|
|
() => JSON.parse(contents.toString()),
|
|
() => {
|
|
throw new TypeError(`Error parsing JSON in file "${filename}"`)
|
|
},
|
|
)
|
|
}
|
|
|
|
function isYaml(filename: string) {
|
|
return filename.endsWith('.yaml') || filename.endsWith('.yml')
|
|
}
|
|
|
|
function color(): Format {
|
|
let cliColor
|
|
try {
|
|
cliColor = require('cli-color')
|
|
} catch {}
|
|
return cliColor
|
|
}
|