From 6c9af87f5f02bfbd75555e2958a430c829942895 Mon Sep 17 00:00:00 2001 From: Charles Catta Date: Wed, 26 Jun 2024 16:33:16 -0400 Subject: [PATCH] feat(zui): Typescript transforms updates, remove all dependencies (#340) --- zui/package.json | 5 +- zui/pnpm-lock.yaml | 43 -- zui/src/index.ts | 5 - .../zui-to-typescript-next/index.ts | 8 +- .../zui-to-typescript-next/utils.ts | 7 +- .../transforms/zui-to-typescript/generator.ts | 378 ------------- zui/src/transforms/zui-to-typescript/index.ts | 158 ------ .../transforms/zui-to-typescript/linker.ts | 37 -- .../zui-to-typescript/normalizer.ts | 238 -------- .../transforms/zui-to-typescript/optimizer.ts | 74 --- .../zui-to-typescript/optionValidator.ts | 7 - .../transforms/zui-to-typescript/parser.ts | 525 ------------------ .../transforms/zui-to-typescript/resolver.ts | 28 - .../transforms/zui-to-typescript/types/AST.ts | 171 ------ .../zui-to-typescript/types/JSONSchema.ts | 145 ----- .../zui-to-typescript/typesOfSchema.ts | 149 ----- zui/src/transforms/zui-to-typescript/utils.ts | 381 ------------- .../transforms/zui-to-typescript/validator.ts | 56 -- .../zui-to-typescript/zui-to-ts.test.ts | 41 -- zui/src/ui/utils.ts | 214 +++++++ zui/src/z/types/basetype/index.ts | 10 +- 21 files changed, 223 insertions(+), 2457 deletions(-) delete mode 100644 zui/src/transforms/zui-to-typescript/generator.ts delete mode 100644 zui/src/transforms/zui-to-typescript/index.ts delete mode 100644 zui/src/transforms/zui-to-typescript/linker.ts delete mode 100644 zui/src/transforms/zui-to-typescript/normalizer.ts delete mode 100644 zui/src/transforms/zui-to-typescript/optimizer.ts delete mode 100644 zui/src/transforms/zui-to-typescript/optionValidator.ts delete mode 100644 zui/src/transforms/zui-to-typescript/parser.ts delete mode 100644 zui/src/transforms/zui-to-typescript/resolver.ts delete mode 100644 zui/src/transforms/zui-to-typescript/types/AST.ts delete mode 100644 zui/src/transforms/zui-to-typescript/types/JSONSchema.ts delete mode 100644 zui/src/transforms/zui-to-typescript/typesOfSchema.ts delete mode 100644 zui/src/transforms/zui-to-typescript/utils.ts delete mode 100644 zui/src/transforms/zui-to-typescript/validator.ts delete mode 100644 zui/src/transforms/zui-to-typescript/zui-to-ts.test.ts diff --git a/zui/package.json b/zui/package.json index f4b72f1a..fb5eb9d2 100644 --- a/zui/package.json +++ b/zui/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/zui", - "version": "0.8.12", + "version": "0.9.0", "description": "An extension of Zod for working nicely with UIs and JSON Schemas", "type": "module", "source": "./src/index.ts", @@ -31,7 +31,6 @@ "@types/benchmark": "^2.1.5", "@types/jest": "^29.5.12", "@types/json-schema": "^7.0.15", - "@types/lodash": "^4.17.0", "@types/node": "^20.12.6", "@types/react": "^18.2.48", "@vitejs/plugin-react-swc": "^3.6.0", @@ -52,8 +51,6 @@ "vitest": "1.5.2" }, "dependencies": { - "@apidevtools/json-schema-ref-parser": "^11.5.5", - "lodash": "^4.17.21" }, "peerDependencies": { "react": "^18.2.0" diff --git a/zui/pnpm-lock.yaml b/zui/pnpm-lock.yaml index 5072eb38..450278c9 100644 --- a/zui/pnpm-lock.yaml +++ b/zui/pnpm-lock.yaml @@ -7,13 +7,6 @@ settings: importers: .: - dependencies: - '@apidevtools/json-schema-ref-parser': - specifier: ^11.5.5 - version: 11.5.5 - lodash: - specifier: ^4.17.21 - version: 4.17.21 devDependencies: '@testing-library/jest-dom': specifier: ^6.4.2 @@ -33,9 +26,6 @@ importers: '@types/json-schema': specifier: ^7.0.15 version: 7.0.15 - '@types/lodash': - specifier: ^4.17.0 - version: 4.17.0 '@types/node': specifier: ^20.12.6 version: 20.12.6 @@ -100,10 +90,6 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@apidevtools/json-schema-ref-parser@11.5.5': - resolution: {integrity: sha512-hv/aXDILyroHioVW27etFMV+IX6FyNn41YwbeGIAt5h/7fUTQvHI5w3ols8qYAT8aQt3kzexq5ZwxFDxNHIhdQ==} - engines: {node: '>= 16'} - '@aw-web-design/x-default-browser@1.4.126': resolution: {integrity: sha512-Xk1sIhyNC/esHGGVjL/niHLowM0csl/kFO5uawBy4IrWwy0o1G8LGt3jP6nmWGz+USxeeqbihAmp/oVZju6wug==} hasBin: true @@ -1081,9 +1067,6 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jsdevtools/ono@7.1.3': - resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} - '@ndelangen/get-tarball@3.0.9': resolution: {integrity: sha512-9JKTEik4vq+yGosHYhZ1tiH/3WpUS0Nh0kej4Agndhox8pAdWhEx5knFVRcb/ya9knCRCs1rPxNrSXTDdfVqpA==} @@ -1494,9 +1477,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/lodash@4.17.0': - resolution: {integrity: sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==} - '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -1649,9 +1629,6 @@ packages: app-root-dir@1.0.2: resolution: {integrity: sha512-jlpIfsOoNoafl92Sz//64uQHGSyMrD2vYG5d8o2a4qGvyNCvXur7bzIsWtAC/6flI2RYAp3kv8rsfBtaLm7w0g==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-query@5.1.3: resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} @@ -2622,10 +2599,6 @@ packages: js-tokens@8.0.3: resolution: {integrity: sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - jscodeshift@0.15.2: resolution: {integrity: sha512-FquR7Okgmc4Sd0aEDwqho3rEiKR3BdvuG9jfdHjLJ6JQoWSMpavug3AoIfnfWhxFlf+5pzQh8qjqz0DWFrNQzA==} hasBin: true @@ -3935,12 +3908,6 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - '@apidevtools/json-schema-ref-parser@11.5.5': - dependencies: - '@jsdevtools/ono': 7.1.3 - '@types/json-schema': 7.0.15 - js-yaml: 4.1.0 - '@aw-web-design/x-default-browser@1.4.126': dependencies: default-browser-id: 3.0.0 @@ -4928,8 +4895,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 - '@jsdevtools/ono@7.1.3': {} - '@ndelangen/get-tarball@3.0.9': dependencies: gunzip-maybe: 1.4.2 @@ -5489,8 +5454,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/lodash@4.17.0': {} - '@types/mime@1.3.5': {} '@types/node@18.19.31': @@ -5650,8 +5613,6 @@ snapshots: app-root-dir@1.0.2: {} - argparse@2.0.1: {} - aria-query@5.1.3: dependencies: deep-equal: 2.2.3 @@ -6750,10 +6711,6 @@ snapshots: js-tokens@8.0.3: {} - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - jscodeshift@0.15.2(@babel/preset-env@7.24.4(@babel/core@7.24.4)): dependencies: '@babel/core': 7.24.4 diff --git a/zui/src/index.ts b/zui/src/index.ts index ab85b61e..51bd2af5 100644 --- a/zui/src/index.ts +++ b/zui/src/index.ts @@ -1,7 +1,6 @@ import { jsonSchemaToZui } from './transforms/json-schema-to-zui' import { zuiToJsonSchema } from './transforms/zui-to-json-schema' import { objectToZui } from './transforms/object-to-zui' -import { toTypescriptTypings } from './transforms/zui-to-typescript' import { toTypescript, UntitledDeclarationError, @@ -29,10 +28,6 @@ export const transforms = { zuiToJsonSchema, objectToZui, toTypescript, - /** - * @deprecated use toTypescript instead - */ - zuiToTypescriptTypings: toTypescriptTypings, } export { UntitledDeclarationError, type TypescriptGenerationOptions } diff --git a/zui/src/transforms/zui-to-typescript-next/index.ts b/zui/src/transforms/zui-to-typescript-next/index.ts index 1c7146e3..04cf81cb 100644 --- a/zui/src/transforms/zui-to-typescript-next/index.ts +++ b/zui/src/transforms/zui-to-typescript-next/index.ts @@ -56,7 +56,7 @@ class Declaration { export type TypescriptGenerationOptions = { declaration?: boolean - formatters?: ((typing: string) => string)[] + formatter?: (typing: string) => string } type SchemaTypes = z.Schema | KeyValue | FnParameters | Declaration | null @@ -84,10 +84,8 @@ export function toTypescript(schema: z.Schema, options?: TypescriptGenerationOpt let dts = sUnwrapZod(wrappedSchema, { ...options }) - if (options.formatters?.length) { - for (const formatter of options.formatters) { - dts = formatter(dts) - } + if (options.formatter) { + dts = options.formatter(dts) } return dts diff --git a/zui/src/transforms/zui-to-typescript-next/utils.ts b/zui/src/transforms/zui-to-typescript-next/utils.ts index fa614a03..bd469b4f 100644 --- a/zui/src/transforms/zui-to-typescript-next/utils.ts +++ b/zui/src/transforms/zui-to-typescript-next/utils.ts @@ -1,4 +1,5 @@ -import _ from 'lodash' +import { camelCase, deburr } from '../../ui/utils' + export function escapeString(str: string) { if (typeof str !== 'string') { return '' @@ -35,12 +36,12 @@ export const getMultilineComment = (description?: string) => { } export const toValidFunctionName = (str: string) => { - let name = _.deburr(str) + let name = deburr(str) name = name.replace(/[^a-zA-Z0-9_$]/g, '') if (!/^[a-zA-Z_$]/.test(name)) { name = `_${name}` } - return _.camelCase(name) + return camelCase(name) } diff --git a/zui/src/transforms/zui-to-typescript/generator.ts b/zui/src/transforms/zui-to-typescript/generator.ts deleted file mode 100644 index 78a4d0cd..00000000 --- a/zui/src/transforms/zui-to-typescript/generator.ts +++ /dev/null @@ -1,378 +0,0 @@ -import memoize from 'lodash/memoize.js' -import omit from 'lodash/omit.js' -import { DEFAULT_OPTIONS, type Options } from './index' -import { - type AST, - type ASTWithStandaloneName, - type TArray, - type TEnum, - type TInterface, - type TIntersection, - type TNamedInterface, - type TUnion, - hasComment, - hasStandaloneName, - T_ANY, - T_UNKNOWN, -} from './types/AST' -import { log, toSafeString } from './utils' - -export function generate(ast: AST, options = DEFAULT_OPTIONS): string { - return ( - [ - options.bannerComment, - declareNamedTypes(ast, options, ast.standaloneName!), - declareNamedInterfaces(ast, options, ast.standaloneName!), - declareEnums(ast, options), - ] - .filter(Boolean) - .join('\n\n') + '\n' - ) // trailing newline -} - -function declareEnums(ast: AST, options: Options, processed = new Set()): string { - if (processed.has(ast)) { - return '' - } - - processed.add(ast) - let type = '' - - switch (ast.type) { - case 'ENUM': - return generateStandaloneEnum(ast, options) + '\n' - case 'ARRAY': - return declareEnums(ast.params, options, processed) - case 'UNION': - case 'INTERSECTION': - return ast.params.reduce((prev, ast) => prev + declareEnums(ast, options, processed), '') - case 'TUPLE': - type = ast.params.reduce((prev, ast) => prev + declareEnums(ast, options, processed), '') - if (ast.spreadParam) { - type += declareEnums(ast.spreadParam, options, processed) - } - return type - case 'INTERFACE': - return getSuperTypesAndParams(ast).reduce((prev, ast) => prev + declareEnums(ast, options, processed), '') - default: - return '' - } -} - -function declareNamedInterfaces(ast: AST, options: Options, rootASTName: string, processed = new Set()): string { - if (processed.has(ast)) { - return '' - } - - processed.add(ast) - let type = '' - - switch (ast.type) { - case 'ARRAY': - type = declareNamedInterfaces((ast as TArray).params, options, rootASTName, processed) - break - case 'INTERFACE': - type = [ - hasStandaloneName(ast) && - (ast.standaloneName === rootASTName || options.declareExternallyReferenced) && - generateStandaloneInterface(ast, options), - getSuperTypesAndParams(ast) - .map((ast) => declareNamedInterfaces(ast, options, rootASTName, processed)) - .filter(Boolean) - .join('\n'), - ] - .filter(Boolean) - .join('\n') - break - case 'INTERSECTION': - case 'TUPLE': - case 'UNION': - type = ast.params - .map((_) => declareNamedInterfaces(_, options, rootASTName, processed)) - .filter(Boolean) - .join('\n') - if (ast.type === 'TUPLE' && ast.spreadParam) { - type += declareNamedInterfaces(ast.spreadParam, options, rootASTName, processed) - } - break - default: - type = '' - } - - return type -} - -function declareNamedTypes(ast: AST, options: Options, rootASTName: string, processed = new Set()): string { - if (processed.has(ast)) { - return '' - } - - processed.add(ast) - - switch (ast.type) { - case 'ARRAY': - return [ - declareNamedTypes(ast.params, options, rootASTName, processed), - hasStandaloneName(ast) ? generateStandaloneType(ast, options) : undefined, - ] - .filter(Boolean) - .join('\n') - case 'ENUM': - return '' - case 'INTERFACE': - return getSuperTypesAndParams(ast) - .map( - (ast) => - (ast.standaloneName === rootASTName || options.declareExternallyReferenced) && - declareNamedTypes(ast, options, rootASTName, processed), - ) - .filter(Boolean) - .join('\n') - case 'INTERSECTION': - case 'TUPLE': - case 'UNION': - return [ - hasStandaloneName(ast) ? generateStandaloneType(ast, options) : undefined, - ast.params - .map((ast) => declareNamedTypes(ast, options, rootASTName, processed)) - .filter(Boolean) - .join('\n'), - 'spreadParam' in ast && ast.spreadParam - ? declareNamedTypes(ast.spreadParam, options, rootASTName, processed) - : undefined, - ] - .filter(Boolean) - .join('\n') - default: - if (hasStandaloneName(ast)) { - return generateStandaloneType(ast, options) - } - return '' - } -} - -function generateTypeUnmemoized(ast: AST, options: Options): string { - const type = generateRawType(ast, options) - - if (options.strictIndexSignatures && ast.keyName === '[k: string]') { - return `${type} | undefined` - } - - return type -} -export const generateType = memoize(generateTypeUnmemoized) - -function generateRawType(ast: AST, options: Options): string { - log('magenta', 'generator', ast) - - if (hasStandaloneName(ast)) { - return toSafeString(ast.standaloneName) - } - - switch (ast.type) { - case 'ANY': - return 'any' - case 'ARRAY': - return (() => { - const type = generateType(ast.params, options) - return type.endsWith('"') ? '(' + type + ')[]' : type + '[]' - })() - case 'BOOLEAN': - return 'boolean' - case 'INTERFACE': - return generateInterface(ast, options) - case 'INTERSECTION': - return generateSetOperation(ast, options) - case 'LITERAL': - return JSON.stringify(ast.params) - case 'NEVER': - return 'never' - case 'NUMBER': - return 'number' - case 'NULL': - return 'null' - case 'OBJECT': - return 'object' - case 'REFERENCE': - return ast.params - case 'STRING': - return 'string' - case 'TUPLE': - return (() => { - const minItems = ast.minItems - const maxItems = ast.maxItems || -1 - - let spreadParam = ast.spreadParam - const astParams = [...ast.params] - if (minItems > 0 && minItems > astParams.length && ast.spreadParam === undefined) { - // this is a valid state, and JSONSchema doesn't care about the item type - if (maxItems < 0) { - // no max items and no spread param, so just spread any - spreadParam = options.unknownAny ? T_UNKNOWN : T_ANY - } - } - if (maxItems > astParams.length && ast.spreadParam === undefined) { - // this is a valid state, and JSONSchema doesn't care about the item type - // fill the tuple with any elements - for (let i = astParams.length; i < maxItems; i += 1) { - astParams.push(options.unknownAny ? T_UNKNOWN : T_ANY) - } - } - - function addSpreadParam(params: string[]): string[] { - if (spreadParam) { - const spread = '...(' + generateType(spreadParam, options) + ')[]' - params.push(spread) - } - return params - } - - function paramsToString(params: string[]): string { - return '[' + params.join(', ') + ']' - } - - const paramsList = astParams.map((param) => generateType(param, options)) - - if (paramsList.length > minItems) { - /* - if there are more items than the min, we return a union of tuples instead of - using the optional element operator. This is done because it is more typesafe. - - // optional element operator - type A = [string, string?, string?] - const a: A = ['a', undefined, 'c'] // no error - - // union of tuples - type B = [string] | [string, string] | [string, string, string] - const b: B = ['a', undefined, 'c'] // TS error - */ - - const cumulativeParamsList: string[] = paramsList.slice(0, minItems) - const typesToUnion: string[] = [] - - if (cumulativeParamsList.length > 0) { - // actually has minItems, so add the initial state - typesToUnion.push(paramsToString(cumulativeParamsList)) - } else { - // no minItems means it's acceptable to have an empty tuple type - typesToUnion.push(paramsToString([])) - } - - for (let i = minItems; i < paramsList.length; i += 1) { - cumulativeParamsList.push(paramsList[i]!) - - if (i === paramsList.length - 1) { - // only the last item in the union should have the spread parameter - addSpreadParam(cumulativeParamsList) - } - - typesToUnion.push(paramsToString(cumulativeParamsList)) - } - - return typesToUnion.join('|') - } - - // no max items so only need to return one type - return paramsToString(addSpreadParam(paramsList)) - })() - case 'UNION': - return generateSetOperation(ast, options) - case 'UNKNOWN': - return 'unknown' - case 'CUSTOM_TYPE': - return ast.params - } -} - -/** - * Generate a Union or Intersection - */ -function generateSetOperation(ast: TIntersection | TUnion, options: Options): string { - const members = (ast as TUnion).params.map((_) => generateType(_, options)) - const separator = ast.type === 'UNION' ? '|' : '&' - return members.length === 1 ? members[0]! : '(' + members.join(' ' + separator + ' ') + ')' -} - -function generateInterface(ast: TInterface, options: Options): string { - return ( - '{' + - '\n' + - ast.params - .filter((_) => !_.isPatternProperty && !_.isUnreachableDefinition) - .map( - ({ isRequired, keyName, ast }) => - [isRequired, keyName, ast, generateType(ast, options)] as [boolean, string, AST, string], - ) - .map( - ([isRequired, keyName, ast, type]) => - (hasComment(ast) && !ast.standaloneName ? generateComment(ast.comment, ast.deprecated) + '\n' : '') + - escapeKeyName(keyName) + - (isRequired ? '' : '?') + - ': ' + - type, - ) - .join('\n') + - '\n' + - '}' - ) -} - -function generateComment(comment?: string, deprecated?: boolean): string { - const commentLines = ['/**'] - if (deprecated) { - commentLines.push(' * @deprecated') - } - if (typeof comment !== 'undefined') { - commentLines.push(...comment.split('\n').map((_) => ' * ' + _)) - } - commentLines.push(' */') - return commentLines.join('\n') -} - -function generateStandaloneEnum(ast: TEnum, options: Options): string { - return ( - (hasComment(ast) ? generateComment(ast.comment, ast.deprecated) + '\n' : '') + - 'export ' + - (options.enableConstEnums ? 'const ' : '') + - `enum ${toSafeString(ast.standaloneName)} {` + - '\n' + - ast.params.map(({ ast, keyName }) => keyName + ' = ' + generateType(ast, options)).join(',\n') + - '\n' + - '}' - ) -} - -function generateStandaloneInterface(ast: TNamedInterface, options: Options): string { - return ( - (hasComment(ast) ? generateComment(ast.comment, ast.deprecated) + '\n' : '') + - `export interface ${toSafeString(ast.standaloneName)} ` + - (ast.superTypes.length > 0 - ? `extends ${ast.superTypes.map((superType) => toSafeString(superType.standaloneName)).join(', ')} ` - : '') + - generateInterface(ast, options) - ) -} - -function generateStandaloneType(ast: ASTWithStandaloneName, options: Options): string { - return ( - (hasComment(ast) ? generateComment(ast.comment) + '\n' : '') + - `export type ${toSafeString(ast.standaloneName)} = ${generateType( - omit(ast, 'standaloneName') as AST /* TODO */, - options, - )}` - ) -} - -function escapeKeyName(keyName: string): string { - if (keyName.length && /[A-Za-z_$]/.test(keyName.charAt(0)) && /^[\w$]+$/.test(keyName)) { - return keyName - } - if (keyName === '[k: string]') { - return keyName - } - return JSON.stringify(keyName) -} - -function getSuperTypesAndParams(ast: TInterface): AST[] { - return ast.params.map((param) => param.ast).concat(ast.superTypes) -} diff --git a/zui/src/transforms/zui-to-typescript/index.ts b/zui/src/transforms/zui-to-typescript/index.ts deleted file mode 100644 index cad52749..00000000 --- a/zui/src/transforms/zui-to-typescript/index.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { type JSONSchema4 } from 'json-schema' -import cloneDeep from 'lodash/cloneDeep.js' -import endsWith from 'lodash/endsWith.js' -import isEqual from 'lodash/isEqual.js' -import merge from 'lodash/merge.js' -import { generate } from './generator' -import { link } from './linker' -import { normalize } from './normalizer' -import { optimize } from './optimizer' -import { validateOptions } from './optionValidator' -import { parse } from './parser' -import { dereference } from './resolver' -import { error, log } from './utils' -import { validate } from './validator' - -export type { EnumJSONSchema, JSONSchema, NamedEnumJSONSchema, CustomTypeJSONSchema } from './types/JSONSchema' - -export interface Options { - /** - * [$RefParser](https://github.com/BigstickCarpet/json-schema-ref-parser) Options, used when resolving `$ref`s - */ - $refOptions: any - /** - * Default value for additionalProperties, when it is not explicitly set. - */ - additionalProperties: boolean - /** - * Disclaimer comment prepended to the top of each generated file. - */ - bannerComment: string - /** - * Root directory for resolving [`$ref`](https://tools.ietf.org/id/draft-pbryan-zyp-json-ref-03.html)s. - */ - cwd: string - /** - * Declare external schemas referenced via `$ref`? - */ - declareExternallyReferenced: boolean - /** - * Prepend enums with [`const`](https://www.typescriptlang.org/docs/handbook/enums.html#computed-and-constant-members)? - */ - enableConstEnums: boolean - /** - * Format code? Set this to `false` to improve performance. - */ - format: boolean - /** - * Ignore maxItems and minItems for `array` types, preventing tuples being generated. - */ - ignoreMinAndMaxItems: boolean - /** - * Maximum number of unioned tuples to emit when representing bounded-size array types, - * before falling back to emitting unbounded arrays. Increase this to improve precision - * of emitted types, decrease it to improve performance, or set it to `-1` to ignore - * `minItems` and `maxItems`. - */ - maxItems: number - /** - * Append all index signatures with `| undefined` so that they are strictly typed. - * - * This is required to be compatible with `strictNullChecks`. - */ - strictIndexSignatures: boolean - /** - * Generate code for `definitions` that aren't referenced by the schema? - */ - unreachableDefinitions: boolean - /** - * Generate unknown type instead of any - */ - unknownAny: boolean -} - -export const DEFAULT_OPTIONS: Options = { - $refOptions: {}, - additionalProperties: true, // TODO: default to empty schema (as per spec) instead - bannerComment: '', - cwd: '', - declareExternallyReferenced: true, - enableConstEnums: true, - format: true, - ignoreMinAndMaxItems: false, - maxItems: 20, - strictIndexSignatures: false, - unreachableDefinitions: false, - unknownAny: true, -} - -export async function compile(schema: JSONSchema4, name = 'Root', options: Partial = {}): Promise { - validateOptions(options) - const _options = merge({}, DEFAULT_OPTIONS, options) - - const start = Date.now() - function time() { - return `(${Date.now() - start}ms)` - } - - // normalize options - if (!endsWith(_options.cwd, '/')) { - _options.cwd += '/' - } - - // Initial clone to avoid mutating the input - const _schema = cloneDeep(schema) - - const { dereferencedPaths, dereferencedSchema } = await dereference(_schema, _options) - if (process.env.VERBOSE) { - if (isEqual(_schema, dereferencedSchema)) { - log('green', 'dereferencer', time(), '✅ No change') - } else { - log('green', 'dereferencer', time(), '✅ Result:', dereferencedSchema) - } - } - - const linked = link(dereferencedSchema) - if (process.env.VERBOSE) { - log('green', 'linker', time(), '✅ No change') - } - - const errors = validate(linked, name) - if (errors.length) { - errors.forEach((_) => error(_)) - throw new ValidationError() - } - if (process.env.VERBOSE) { - log('green', 'validator', time(), '✅ No change') - } - - const normalized = normalize(linked, dereferencedPaths, name, _options) - log('yellow', 'normalizer', time(), '✅ Result:', normalized) - - const parsed = parse(normalized, _options) - log('blue', 'parser', time(), '✅ Result:', parsed) - - const optimized = optimize(parsed, _options) - log('cyan', 'optimizer', time(), '✅ Result:', optimized) - - const generated = generate(optimized, _options) - log('magenta', 'generator', time(), '✅ Result:', generated) - return generated -} - -export class ValidationError extends Error {} - -type ToTypescriptTyingsOptions = { schemaName: string } & Partial - -export type { ToTypescriptTyingsOptions } - -export const toTypescriptTypings = async (jsonSchema: any, options?: ToTypescriptTyingsOptions) => { - const generatedType = await compile(jsonSchema, options?.schemaName ?? 'Schema', { - bannerComment: '', - ...options, - }) - - return !options?.schemaName - ? generatedType.replace('export interface Schema ', '').replace('export type Schema = ', '') - : generatedType -} diff --git a/zui/src/transforms/zui-to-typescript/linker.ts b/zui/src/transforms/zui-to-typescript/linker.ts deleted file mode 100644 index f68791ee..00000000 --- a/zui/src/transforms/zui-to-typescript/linker.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { type JSONSchema4Type } from 'json-schema' -import isPlainObject from 'lodash/isPlainObject.js' -import { type JSONSchema, Parent, type LinkedJSONSchema } from './types/JSONSchema' - -/** - * Traverses over the schema, giving each node a reference to its - * parent node. We need this for downstream operations. - */ -export function link(schema: JSONSchema4Type | JSONSchema, parent: JSONSchema4Type | null = null): LinkedJSONSchema { - if (!Array.isArray(schema) && !isPlainObject(schema)) { - return schema as LinkedJSONSchema - } - - // Handle cycles - if ((schema as JSONSchema).hasOwnProperty(Parent)) { - return schema as LinkedJSONSchema - } - - // Add a reference to this schema's parent - Object.defineProperty(schema, Parent, { - enumerable: false, - value: parent, - writable: false, - }) - - // Arrays - if (Array.isArray(schema)) { - schema.forEach((child) => link(child, schema)) - } - - // Objects - for (const key in schema as JSONSchema) { - link((schema as JSONSchema)[key], schema) - } - - return schema as LinkedJSONSchema -} diff --git a/zui/src/transforms/zui-to-typescript/normalizer.ts b/zui/src/transforms/zui-to-typescript/normalizer.ts deleted file mode 100644 index 97e524e9..00000000 --- a/zui/src/transforms/zui-to-typescript/normalizer.ts +++ /dev/null @@ -1,238 +0,0 @@ -import isEqual from 'lodash/isEqual.js' -import isEmpty from 'lodash/isEmpty.js' -import { type Options } from './' -import { type DereferencedPaths } from './resolver' -import { type JSONSchemaTypeName, type LinkedJSONSchema, type NormalizedJSONSchema, Parent } from './types/JSONSchema' -import { appendToDescription, escapeBlockComment, isSchemaLike, justName, toSafeString, traverse } from './utils' - -type Rule = ( - schema: LinkedJSONSchema, - fileName: string, - options: Options, - key: string | null, - dereferencedPaths: DereferencedPaths, -) => void -const rules = new Map() - -function hasType(schema: LinkedJSONSchema, type: JSONSchemaTypeName) { - return schema.type === type || (Array.isArray(schema.type) && schema.type.includes(type)) -} -function isObjectType(schema: LinkedJSONSchema) { - return schema.properties !== undefined || hasType(schema, 'object') || hasType(schema, 'any') -} -function isArrayType(schema: LinkedJSONSchema) { - return schema.items !== undefined || hasType(schema, 'array') || hasType(schema, 'any') -} - -rules.set('Remove `type=["null"]` if `enum=[null]`', (schema) => { - if ( - Array.isArray(schema.enum) && - schema.enum.some((e) => e === null) && - Array.isArray(schema.type) && - schema.type.includes('null') - ) { - schema.type = schema.type.filter((type) => type !== 'null') - } -}) - -rules.set('Destructure unary types', (schema) => { - if (schema.type && Array.isArray(schema.type) && schema.type.length === 1) { - schema.type = schema.type[0] - } -}) - -rules.set('Add empty `required` property if none is defined', (schema) => { - if (isObjectType(schema) && !('required' in schema)) { - schema.required = [] - } -}) - -rules.set('Transform `required`=false to `required`=[]', (schema) => { - if (schema.required === false) { - schema.required = [] - } -}) - -rules.set('Default additionalProperties', (schema, _, options) => { - if (isObjectType(schema) && !('additionalProperties' in schema) && schema.patternProperties === undefined) { - schema.additionalProperties = options.additionalProperties - } -}) - -rules.set('Transform id to $id', (schema, fileName) => { - if (!isSchemaLike(schema)) { - return - } - if (schema.id && schema.$id && schema.id !== schema.$id) { - throw ReferenceError( - `Schema must define either id or $id, not both. Given id=${schema.id}, $id=${schema.$id} in ${fileName}`, - ) - } - if (schema.id) { - schema.$id = schema.id - delete schema.id - } -}) - -rules.set('Add an $id to anything that needs it', (schema, fileName, _options, _key, dereferencedPaths) => { - if (!isSchemaLike(schema)) { - return - } - - // Top-level schema - if (!schema.$id && !schema[Parent]) { - let id = justName(fileName) - id = isEmpty(id) ? 'Root' : id - schema.$id = toSafeString(id) - return - } - - // Sub-schemas with references - if (!isArrayType(schema) && !isObjectType(schema)) { - return - } - - // We'll infer from $id and title downstream - // TODO: Normalize upstream - const dereferencedName = dereferencedPaths.get(schema) - if (!schema.$id && !schema.title && dereferencedName) { - schema.$id = toSafeString(justName(dereferencedName)) - } - - if (dereferencedName) { - dereferencedPaths.delete(schema) - } -}) - -rules.set('Escape closing JSDoc comment', (schema) => { - escapeBlockComment(schema) -}) - -rules.set('Add JSDoc comments for minItems and maxItems', (schema) => { - if (!isArrayType(schema)) { - return - } - const commentsToAppend = [ - 'minItems' in schema ? `@minItems ${schema.minItems}` : '', - 'maxItems' in schema ? `@maxItems ${schema.maxItems}` : '', - ].filter(Boolean) - if (commentsToAppend.length) { - schema.description = appendToDescription(schema.description, ...commentsToAppend) - } -}) - -rules.set('Optionally remove maxItems and minItems', (schema, _fileName, options) => { - if (!isArrayType(schema)) { - return - } - if ('minItems' in schema && options.ignoreMinAndMaxItems) { - delete schema.minItems - } - if ('maxItems' in schema && (options.ignoreMinAndMaxItems || options.maxItems === -1)) { - delete schema.maxItems - } -}) - -rules.set('Normalize schema.minItems', (schema, _fileName, options) => { - if (options.ignoreMinAndMaxItems) { - return - } - // make sure we only add the props onto array types - if (!isArrayType(schema)) { - return - } - const { minItems } = schema - schema.minItems = typeof minItems === 'number' ? minItems : 0 - // cannot normalize maxItems because maxItems = 0 has an actual meaning -}) - -rules.set('Remove maxItems if it is big enough to likely cause OOMs', (schema, _fileName, options) => { - if (options.ignoreMinAndMaxItems || options.maxItems === -1) { - return - } - if (!isArrayType(schema)) { - return - } - const { maxItems, minItems } = schema - // minItems is guaranteed to be a number after the previous rule runs - if (maxItems !== undefined && maxItems - (minItems as number) > options.maxItems) { - delete schema.maxItems - } -}) - -rules.set('Normalize schema.items', (schema, _fileName, options) => { - if (options.ignoreMinAndMaxItems) { - return - } - const { maxItems, minItems } = schema - const hasMaxItems = typeof maxItems === 'number' && maxItems >= 0 - const hasMinItems = typeof minItems === 'number' && minItems > 0 - - if (schema.items && !Array.isArray(schema.items) && (hasMaxItems || hasMinItems)) { - const items = schema.items - // create a tuple of length N - const newItems = Array(maxItems || minItems || 0).fill(items) - if (!hasMaxItems) { - // if there is no maximum, then add a spread item to collect the rest - schema.additionalItems = items - } - schema.items = newItems - } - - if (Array.isArray(schema.items) && hasMaxItems && maxItems! < schema.items.length) { - // it's perfectly valid to provide 5 item defs but require maxItems 1 - // obviously we shouldn't emit a type for items that aren't expected - schema.items = schema.items.slice(0, maxItems) - } - - return schema -}) - -rules.set('Remove extends, if it is empty', (schema) => { - if (!schema.hasOwnProperty('extends')) { - return - } - if (schema.extends == null || (Array.isArray(schema.extends) && schema.extends.length === 0)) { - delete schema.extends - } -}) - -rules.set('Make extends always an array, if it is defined', (schema) => { - if (schema.extends == null) { - return - } - if (!Array.isArray(schema.extends)) { - schema.extends = [schema.extends] - } -}) - -rules.set('Transform definitions to $defs', (schema, fileName) => { - if (schema.definitions && schema.$defs && !isEqual(schema.definitions, schema.$defs)) { - throw ReferenceError( - `Schema must define either definitions or $defs, not both. Given id=${schema.id} in ${fileName}`, - ) - } - if (schema.definitions) { - schema.$defs = schema.definitions - delete schema.definitions - } -}) - -rules.set('Transform const to singleton enum', (schema) => { - if (schema.const !== undefined) { - schema.enum = [schema.const] - delete schema.const - } -}) - -export function normalize( - rootSchema: LinkedJSONSchema, - dereferencedPaths: DereferencedPaths, - filename: string, - options: Options, -): NormalizedJSONSchema { - rules.forEach((rule) => - traverse(rootSchema, (schema, key) => rule(schema, filename, options, key, dereferencedPaths)), - ) - return rootSchema as NormalizedJSONSchema -} diff --git a/zui/src/transforms/zui-to-typescript/optimizer.ts b/zui/src/transforms/zui-to-typescript/optimizer.ts deleted file mode 100644 index 22547d78..00000000 --- a/zui/src/transforms/zui-to-typescript/optimizer.ts +++ /dev/null @@ -1,74 +0,0 @@ -import uniqBy from 'lodash/uniqBy.js' -import { type Options } from '.' -import { generateType } from './generator' -import { type AST, T_ANY, T_UNKNOWN } from './types/AST' -import { log } from './utils' - -export function optimize(ast: AST, options: Options, processed = new Set()): AST { - if (processed.has(ast)) { - return ast - } - - processed.add(ast) - - switch (ast.type) { - case 'INTERFACE': - return Object.assign(ast, { - params: ast.params.map((_) => Object.assign(_, { ast: optimize(_.ast, options, processed) })), - }) - case 'INTERSECTION': - case 'UNION': - // Start with the leaves... - const optimizedAST = Object.assign(ast, { - params: ast.params.map((_) => optimize(_, options, processed)), - }) - - // [A, B, C, Any] -> Any - if (optimizedAST.params.some((_) => _.type === 'ANY')) { - log('cyan', 'optimizer', '[A, B, C, Any] -> Any', optimizedAST) - return T_ANY - } - - // [A, B, C, Unknown] -> Unknown - if (optimizedAST.params.some((_) => _.type === 'UNKNOWN')) { - log('cyan', 'optimizer', '[A, B, C, Unknown] -> Unknown', optimizedAST) - return T_UNKNOWN - } - - // [A (named), A] -> [A (named)] - if ( - optimizedAST.params.every((_) => { - const a = generateType(omitStandaloneName(_), options) - const b = generateType(omitStandaloneName(optimizedAST.params[0]!), options) - return a === b - }) && - optimizedAST.params.some((_) => _.standaloneName !== undefined) - ) { - log('cyan', 'optimizer', '[A (named), A] -> [A (named)]', optimizedAST) - optimizedAST.params = optimizedAST.params.filter((_) => _.standaloneName !== undefined) - } - - // [A, B, B] -> [A, B] - const params = uniqBy(optimizedAST.params, (_) => generateType(_, options)) - if (params.length !== optimizedAST.params.length) { - log('cyan', 'optimizer', '[A, B, B] -> [A, B]', optimizedAST) - optimizedAST.params = params - } - - return Object.assign(optimizedAST, { - params: optimizedAST.params.map((_) => optimize(_, options, processed)), - }) - default: - return ast - } -} - -// TODO: More clearly disambiguate standalone names vs. aliased names instead. -function omitStandaloneName(ast: A): A { - switch (ast.type) { - case 'ENUM': - return ast - default: - return { ...ast, standaloneName: undefined } - } -} diff --git a/zui/src/transforms/zui-to-typescript/optionValidator.ts b/zui/src/transforms/zui-to-typescript/optionValidator.ts deleted file mode 100644 index 39405e6e..00000000 --- a/zui/src/transforms/zui-to-typescript/optionValidator.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type Options } from '.' - -export function validateOptions({ maxItems }: Partial): void { - if (maxItems !== undefined && maxItems < -1) { - throw RangeError(`Expected options.maxItems to be >= -1, but was given ${maxItems}.`) - } -} diff --git a/zui/src/transforms/zui-to-typescript/parser.ts b/zui/src/transforms/zui-to-typescript/parser.ts deleted file mode 100644 index 6bfa67d7..00000000 --- a/zui/src/transforms/zui-to-typescript/parser.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { type JSONSchema4Type, type JSONSchema4TypeName } from 'json-schema' -import findKey from 'lodash/findKey.js' -import includes from 'lodash/includes.js' -import isPlainObject from 'lodash/isPlainObject.js' -import map from 'lodash/map.js' -import memoize from 'lodash/memoize.js' -import omit from 'lodash/omit.js' -import { type Options } from './' -import { - type AST, - T_ANY, - T_ANY_ADDITIONAL_PROPERTIES, - type TInterface, - type TInterfaceParam, - type TNamedInterface, - type TTuple, - T_UNKNOWN, - T_UNKNOWN_ADDITIONAL_PROPERTIES, - type TIntersection, -} from './types/AST' -import { - getRootSchema, - isBoolean, - isPrimitive, - type JSONSchema as LinkedJSONSchema, - type JSONSchemaWithDefinitions, - type SchemaSchema, - type SchemaType, -} from './types/JSONSchema' -import { typesOfSchema } from './typesOfSchema' -import { generateName, log, maybeStripDefault, maybeStripNameHints } from './utils' - -export type Processed = Map> - -export type UsedNames = Set - -export function parse( - schema: LinkedJSONSchema | JSONSchema4Type, - options: Options, - keyName?: string, - processed: Processed = new Map(), - usedNames = new Set(), -): AST { - if (isPrimitive(schema)) { - if (isBoolean(schema)) { - return parseBooleanSchema(schema, keyName, options) - } - - return parseLiteral(schema, keyName) - } - - const types = typesOfSchema(schema) - if (types.length === 1) { - const ast = parseAsTypeWithCache(schema, types[0], options, keyName, processed, usedNames) - log('blue', 'parser', 'Types:', types, 'Input:', schema, 'Output:', ast) - return ast - } - - // Be careful to first process the intersection before processing its params, - // so that it gets first pick for standalone name. - const ast = parseAsTypeWithCache( - { - $id: schema.$id, - allOf: [], - description: schema.description, - title: schema.title, - }, - 'ALL_OF', - options, - keyName, - processed, - usedNames, - ) as TIntersection - - ast.params = types.map((type) => - // We hoist description (for comment) and id/title (for standaloneName) - // to the parent intersection type, so we remove it from the children. - parseAsTypeWithCache(maybeStripNameHints(schema), type, options, keyName, processed, usedNames), - ) - - log('blue', 'parser', 'Types:', types, 'Input:', schema, 'Output:', ast) - return ast -} - -function parseAsTypeWithCache( - schema: LinkedJSONSchema, - type: SchemaType, - options: Options, - keyName?: string, - processed: Processed = new Map(), - usedNames = new Set(), -): AST { - // If we've seen this node before, return it. - let cachedTypeMap = processed.get(schema) - if (!cachedTypeMap) { - cachedTypeMap = new Map() - processed.set(schema, cachedTypeMap) - } - const cachedAST = cachedTypeMap.get(type) - if (cachedAST) { - return cachedAST - } - - // Cache processed ASTs before they are actually computed, then update - // them in place using set(). This is to avoid cycles. - // TODO: Investigate alternative approaches (lazy-computing nodes, etc.) - const ast = {} as AST - cachedTypeMap.set(type, ast) - - // Update the AST in place. This updates the `processed` cache, as well - // as any nodes that directly reference the node. - return Object.assign(ast, parseNonLiteral(schema, type, options, keyName, processed, usedNames)) -} - -function parseBooleanSchema(schema: boolean, keyName: string | undefined, options: Options): AST { - if (schema) { - return { - keyName, - type: options.unknownAny ? 'UNKNOWN' : 'ANY', - } - } - - return { - keyName, - type: 'NEVER', - } -} - -function parseLiteral(schema: JSONSchema4Type, keyName: string | undefined): AST { - return { - keyName, - params: schema, - type: 'LITERAL', - } -} - -function parseNonLiteral( - schema: LinkedJSONSchema, - type: SchemaType, - options: Options, - keyName: string | undefined, - processed: Processed, - usedNames: UsedNames, -): AST { - const definitions = getDefinitionsMemoized(getRootSchema(schema as any)) // TODO - const keyNameFromDefinition = findKey(definitions, (_) => _ === schema) - - switch (type) { - case 'ALL_OF': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - params: schema.allOf!.map((_) => parse(_, options, undefined, processed, usedNames)), - type: 'INTERSECTION', - } - case 'ANY': - return { - ...(options.unknownAny ? T_UNKNOWN : T_ANY), - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - } - case 'ANY_OF': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - params: schema.anyOf!.map((_) => parse(_, options, undefined, processed, usedNames)), - type: 'UNION', - } - case 'BOOLEAN': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - type: 'BOOLEAN', - } - case 'CUSTOM_TYPE': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - params: schema.tsType!, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - type: 'CUSTOM_TYPE', - } - case 'NAMED_ENUM': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition ?? keyName, usedNames)!, - params: schema.enum!.map((_, n) => ({ - ast: parseLiteral(_, undefined), - keyName: schema.tsEnumNames![n]!, - })), - type: 'ENUM', - } - case 'NAMED_SCHEMA': - return newInterface(schema as SchemaSchema, options, processed, usedNames, keyName) - case 'NEVER': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - type: 'NEVER', - } - case 'NULL': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - type: 'NULL', - } - case 'NUMBER': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - type: 'NUMBER', - } - case 'OBJECT': - return { - comment: schema.description, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - type: 'OBJECT', - deprecated: schema.deprecated, - } - case 'ONE_OF': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - params: schema.oneOf!.map((_) => parse(_, options, undefined, processed, usedNames)), - type: 'UNION', - } - case 'REFERENCE': - throw Error('Refs should have been resolved by the resolver!' + schema) - case 'STRING': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - type: 'STRING', - } - case 'TYPED_ARRAY': - if (Array.isArray(schema.items)) { - // normalised to not be undefined - const minItems = schema.minItems! - const maxItems = schema.maxItems! - const arrayType: TTuple = { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - maxItems, - minItems, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - params: schema.items.map((_) => parse(_, options, undefined, processed, usedNames)), - type: 'TUPLE', - } - if (schema.additionalItems === true) { - arrayType.spreadParam = options.unknownAny ? T_UNKNOWN : T_ANY - } else if (schema.additionalItems) { - arrayType.spreadParam = parse(schema.additionalItems, options, undefined, processed, usedNames) - } - return arrayType - } else { - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - params: parse(schema.items!, options, '{keyNameFromDefinition}Items', processed, usedNames), - type: 'ARRAY', - } - } - case 'UNION': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - params: (schema.type as JSONSchema4TypeName[]).map((type) => { - const member: LinkedJSONSchema = { ...omit(schema, '$id', 'description', 'title'), type } - return parse(maybeStripDefault(member as any), options, undefined, processed, usedNames) - }), - type: 'UNION', - } - case 'UNNAMED_ENUM': - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - params: schema.enum!.map((_) => parseLiteral(_, undefined)), - type: 'UNION', - } - case 'UNNAMED_SCHEMA': - return newInterface(schema as SchemaSchema, options, processed, usedNames, keyName, keyNameFromDefinition) - case 'UNTYPED_ARRAY': - // normalised to not be undefined - const minItems = schema.minItems! - const maxItems = typeof schema.maxItems === 'number' ? schema.maxItems : -1 - const params = options.unknownAny ? T_UNKNOWN : T_ANY - if (minItems > 0 || maxItems >= 0) { - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - maxItems: schema.maxItems, - minItems, - // create a tuple of length N - params: Array(Math.max(maxItems, minItems) || 0).fill(params), - // if there is no maximum, then add a spread item to collect the rest - spreadParam: maxItems >= 0 ? undefined : params, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - type: 'TUPLE', - } - } - - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - params, - standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames), - type: 'ARRAY', - } - } -} - -/** - * Compute a schema name using a series of fallbacks - */ -function standaloneName( - schema: LinkedJSONSchema, - keyNameFromDefinition: string | undefined, - usedNames: UsedNames, -): string | undefined { - const name = schema.title || schema.$id || keyNameFromDefinition - if (name) { - return generateName(name, usedNames) - } -} - -function newInterface( - schema: SchemaSchema, - options: Options, - processed: Processed, - usedNames: UsedNames, - keyName?: string, - keyNameFromDefinition?: string, -): TInterface { - const name = standaloneName(schema, keyNameFromDefinition, usedNames)! - return { - comment: schema.description, - deprecated: schema.deprecated, - keyName, - params: parseSchema(schema, options, processed, usedNames, name), - standaloneName: name, - superTypes: parseSuperTypes(schema, options, processed, usedNames), - type: 'INTERFACE', - } -} - -function parseSuperTypes( - schema: SchemaSchema, - options: Options, - processed: Processed, - usedNames: UsedNames, -): TNamedInterface[] { - // Type assertion needed because of dereferencing step - // TODO: Type it upstream - const superTypes = schema.extends as unknown as SchemaSchema[] | undefined - if (!superTypes) { - return [] - } - return superTypes.map((_) => parse(_, options, undefined, processed, usedNames) as TNamedInterface) -} - -/** - * Helper to parse schema properties into params on the parent schema's type - */ -function parseSchema( - schema: SchemaSchema, - options: Options, - processed: Processed, - usedNames: UsedNames, - parentSchemaName: string, -): TInterfaceParam[] { - let asts: TInterfaceParam[] = map(schema.properties, (value, key: string) => ({ - ast: parse(value, options, key, processed, usedNames), - isPatternProperty: false, - isRequired: includes(schema.required || [], key), - isUnreachableDefinition: false, - keyName: key, - })) - - let singlePatternProperty = false - if (schema.patternProperties) { - // partially support patternProperties. in the case that - // additionalProperties is not set, and there is only a single - // value definition, we can validate against that. - singlePatternProperty = !schema.additionalProperties && Object.keys(schema.patternProperties).length === 1 - - asts = asts.concat( - map(schema.patternProperties, (value, key: string) => { - const ast = parse(value, options, key, processed, usedNames) - const comment = `This interface was referenced by \`${parentSchemaName}\`'s JSON-Schema definition -via the \`patternProperty\` "${key.replace('*/', '*\\/')}".` - ast.comment = ast.comment ? `${ast.comment}\n\n${comment}` : comment - return { - ast, - isPatternProperty: !singlePatternProperty, - isRequired: singlePatternProperty || includes(schema.required || [], key), - isUnreachableDefinition: false, - keyName: singlePatternProperty ? '[k: string]' : key, - } - }), - ) - } - - if (options.unreachableDefinitions) { - asts = asts.concat( - map(schema.$defs, (value, key: string) => { - const ast = parse(value, options, key, processed, usedNames) - const comment = `This interface was referenced by \`${parentSchemaName}\`'s JSON-Schema -via the \`definition\` "${key}".` - ast.comment = ast.comment ? `${ast.comment}\n\n${comment}` : comment - return { - ast, - isPatternProperty: false, - isRequired: includes(schema.required || [], key), - isUnreachableDefinition: true, - keyName: key, - } - }), - ) - } - - // handle additionalProperties - switch (schema.additionalProperties) { - case undefined: - case true: - if (singlePatternProperty) { - return asts - } - return asts.concat({ - ast: options.unknownAny ? T_UNKNOWN_ADDITIONAL_PROPERTIES : T_ANY_ADDITIONAL_PROPERTIES, - isPatternProperty: false, - isRequired: true, - isUnreachableDefinition: false, - keyName: '[k: string]', - }) - - case false: - return asts - - // pass "true" as the last param because in TS, properties - // defined via index signatures are already optional - default: - return asts.concat({ - ast: parse(schema.additionalProperties, options, '[k: string]', processed, usedNames), - isPatternProperty: false, - isRequired: true, - isUnreachableDefinition: false, - keyName: '[k: string]', - }) - } -} - -type Definitions = { [k: string]: LinkedJSONSchema } - -function getDefinitions( - schema: LinkedJSONSchema, - isSchema = true, - processed = new Set(), -): Definitions { - if (processed.has(schema)) { - return {} - } - processed.add(schema) - if (Array.isArray(schema)) { - return schema.reduce( - (prev, cur) => ({ - ...prev, - ...getDefinitions(cur, false, processed), - }), - {}, - ) - } - if (isPlainObject(schema)) { - return { - ...(isSchema && hasDefinitions(schema) ? schema.$defs : {}), - ...Object.keys(schema).reduce( - (prev, cur) => ({ - ...prev, - ...getDefinitions(schema[cur], false, processed), - }), - {}, - ), - } - } - return {} -} - -const getDefinitionsMemoized = memoize(getDefinitions) - -/** - * TODO: Reduce rate of false positives - */ -function hasDefinitions(schema: LinkedJSONSchema): schema is JSONSchemaWithDefinitions { - return '$defs' in schema -} diff --git a/zui/src/transforms/zui-to-typescript/resolver.ts b/zui/src/transforms/zui-to-typescript/resolver.ts deleted file mode 100644 index a83e3a9a..00000000 --- a/zui/src/transforms/zui-to-typescript/resolver.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ParserOptions } from '@apidevtools/json-schema-ref-parser' -import { type JSONSchema } from './types/JSONSchema' -import { log } from './utils' - -export type DereferencedPaths = WeakMap - -export async function dereference( - schema: JSONSchema, - { cwd, $refOptions }: { cwd: string; $refOptions: ParserOptions }, -): Promise<{ dereferencedPaths: DereferencedPaths; dereferencedSchema: JSONSchema }> { - log('green', 'dereferencer', 'Dereferencing input schema:', cwd, schema) - if (typeof process === 'undefined') { - throw new Error('process is not defined') - } - const mod = await import('@apidevtools/json-schema-ref-parser') - const parser = new mod.$RefParser() - const dereferencedPaths: DereferencedPaths = new WeakMap() - const dereferencedSchema = (await parser.dereference(cwd, schema as any, { - ...$refOptions, - dereference: { - ...$refOptions.dereference, - onDereference($ref: string, schema: JSONSchema) { - dereferencedPaths.set(schema, $ref) - }, - }, - })) as JSONSchema - return { dereferencedPaths, dereferencedSchema } -} diff --git a/zui/src/transforms/zui-to-typescript/types/AST.ts b/zui/src/transforms/zui-to-typescript/types/AST.ts deleted file mode 100644 index 0b366bb5..00000000 --- a/zui/src/transforms/zui-to-typescript/types/AST.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { type JSONSchema4Type } from 'json-schema' - -export type AST_TYPE = AST['type'] - -export type AST = - | TAny - | TArray - | TBoolean - | TEnum - | TInterface - | TNamedInterface - | TIntersection - | TLiteral - | TNever - | TNumber - | TNull - | TObject - | TReference - | TString - | TTuple - | TUnion - | TUnknown - | TCustomType - -export interface AbstractAST { - comment?: string - keyName?: string - standaloneName?: string - type: AST_TYPE - deprecated?: boolean -} - -export type ASTWithComment = AST & { comment: string } -export type ASTWithName = AST & { keyName: string } -export type ASTWithStandaloneName = AST & { standaloneName: string } - -export function hasComment(ast: AST): ast is ASTWithComment { - return ( - ('comment' in ast && ast.comment != null && ast.comment !== '') || - // Compare to true because ast.deprecated might be undefined - ('deprecated' in ast && ast.deprecated === true) - ) -} - -export function hasStandaloneName(ast: AST): ast is ASTWithStandaloneName { - return 'standaloneName' in ast && ast.standaloneName != null && ast.standaloneName !== '' -} - -//////////////////////////////////////////// types - -export interface TAny extends AbstractAST { - type: 'ANY' -} - -export interface TArray extends AbstractAST { - type: 'ARRAY' - params: AST -} - -export interface TBoolean extends AbstractAST { - type: 'BOOLEAN' -} - -export interface TEnum extends AbstractAST { - standaloneName: string - type: 'ENUM' - params: TEnumParam[] -} - -export interface TEnumParam { - ast: AST - keyName: string -} - -export interface TInterface extends AbstractAST { - type: 'INTERFACE' - params: TInterfaceParam[] - superTypes: TNamedInterface[] -} - -export interface TNamedInterface extends AbstractAST { - standaloneName: string - type: 'INTERFACE' - params: TInterfaceParam[] - superTypes: TNamedInterface[] -} - -export interface TNever extends AbstractAST { - type: 'NEVER' -} - -export interface TInterfaceParam { - ast: AST - keyName: string - isRequired: boolean - isPatternProperty: boolean - isUnreachableDefinition: boolean -} - -export interface TIntersection extends AbstractAST { - type: 'INTERSECTION' - params: AST[] -} - -export interface TLiteral extends AbstractAST { - params: JSONSchema4Type - type: 'LITERAL' -} - -export interface TNumber extends AbstractAST { - type: 'NUMBER' -} - -export interface TNull extends AbstractAST { - type: 'NULL' -} - -export interface TObject extends AbstractAST { - type: 'OBJECT' -} - -export interface TReference extends AbstractAST { - type: 'REFERENCE' - params: string -} - -export interface TString extends AbstractAST { - type: 'STRING' -} - -export interface TTuple extends AbstractAST { - type: 'TUPLE' - params: AST[] - spreadParam?: AST - minItems: number - maxItems?: number -} - -export interface TUnion extends AbstractAST { - type: 'UNION' - params: AST[] -} - -export interface TUnknown extends AbstractAST { - type: 'UNKNOWN' -} - -export interface TCustomType extends AbstractAST { - type: 'CUSTOM_TYPE' - params: string -} - -//////////////////////////////////////////// literals - -export const T_ANY: TAny = { - type: 'ANY', -} - -export const T_ANY_ADDITIONAL_PROPERTIES: TAny & ASTWithName = { - keyName: '[k: string]', - type: 'ANY', -} - -export const T_UNKNOWN: TUnknown = { - type: 'UNKNOWN', -} - -export const T_UNKNOWN_ADDITIONAL_PROPERTIES: TUnknown & ASTWithName = { - keyName: '[k: string]', - type: 'UNKNOWN', -} diff --git a/zui/src/transforms/zui-to-typescript/types/JSONSchema.ts b/zui/src/transforms/zui-to-typescript/types/JSONSchema.ts deleted file mode 100644 index d87d3e48..00000000 --- a/zui/src/transforms/zui-to-typescript/types/JSONSchema.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { type JSONSchema4, type JSONSchema4Type, type JSONSchema4TypeName } from 'json-schema' -import memoize from 'lodash/memoize.js' -import isPlainObject from 'lodash/isPlainObject.js' - -export type SchemaType = - | 'ALL_OF' - | 'UNNAMED_SCHEMA' - | 'ANY' - | 'ANY_OF' - | 'BOOLEAN' - | 'NAMED_ENUM' - | 'NAMED_SCHEMA' - | 'NEVER' - | 'NULL' - | 'NUMBER' - | 'STRING' - | 'OBJECT' - | 'ONE_OF' - | 'TYPED_ARRAY' - | 'REFERENCE' - | 'UNION' - | 'UNNAMED_ENUM' - | 'UNTYPED_ARRAY' - | 'CUSTOM_TYPE' - -export type JSONSchemaTypeName = JSONSchema4TypeName -export type JSONSchemaType = JSONSchema4Type - -export interface JSONSchema extends JSONSchema4 { - /** - * schema extension to support numeric enums - */ - tsEnumNames?: string[] - /** - * schema extension to support custom types - */ - tsType?: string - /** - * property exists at least in https://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.9.3 - */ - deprecated?: boolean -} - -export const Parent = Symbol('Parent') - -export interface LinkedJSONSchema extends JSONSchema { - /** - * A reference to this schema's parent node, for convenience. - * `null` when this is the root schema. - */ - [Parent]: LinkedJSONSchema | null - - additionalItems?: boolean | LinkedJSONSchema - additionalProperties?: boolean | LinkedJSONSchema - items?: LinkedJSONSchema | LinkedJSONSchema[] - definitions?: { - [k: string]: LinkedJSONSchema - } - properties?: { - [k: string]: LinkedJSONSchema - } - patternProperties?: { - [k: string]: LinkedJSONSchema - } - dependencies?: { - [k: string]: LinkedJSONSchema | string[] - } - allOf?: LinkedJSONSchema[] - anyOf?: LinkedJSONSchema[] - oneOf?: LinkedJSONSchema[] - not?: LinkedJSONSchema -} - -export interface NormalizedJSONSchema extends LinkedJSONSchema { - additionalItems?: boolean | NormalizedJSONSchema - additionalProperties: boolean | NormalizedJSONSchema - extends?: string[] - items?: NormalizedJSONSchema | NormalizedJSONSchema[] - $defs?: { - [k: string]: NormalizedJSONSchema - } - properties?: { - [k: string]: NormalizedJSONSchema - } - patternProperties?: { - [k: string]: NormalizedJSONSchema - } - dependencies?: { - [k: string]: NormalizedJSONSchema | string[] - } - allOf?: NormalizedJSONSchema[] - anyOf?: NormalizedJSONSchema[] - oneOf?: NormalizedJSONSchema[] - not?: NormalizedJSONSchema - required: string[] - - // Removed by normalizer - definitions: never - id: never -} - -export interface EnumJSONSchema extends NormalizedJSONSchema { - enum: any[] -} - -export interface NamedEnumJSONSchema extends NormalizedJSONSchema { - tsEnumNames: string[] -} - -export interface SchemaSchema extends NormalizedJSONSchema { - properties: { - [k: string]: NormalizedJSONSchema - } - required: string[] -} - -export interface JSONSchemaWithDefinitions extends NormalizedJSONSchema { - $defs: { - [k: string]: NormalizedJSONSchema - } -} - -export interface CustomTypeJSONSchema extends NormalizedJSONSchema { - tsType: string -} - -export const getRootSchema = memoize((schema: LinkedJSONSchema): LinkedJSONSchema => { - const parent = schema[Parent] - if (!parent) { - return schema - } - return getRootSchema(parent) -}) - -export function isBoolean(schema: LinkedJSONSchema | JSONSchemaType): schema is boolean { - return schema === true || schema === false -} - -export function isPrimitive(schema: LinkedJSONSchema | JSONSchemaType): schema is JSONSchemaType { - return !isPlainObject(schema) -} - -export function isCompound(schema: JSONSchema): boolean { - return Array.isArray(schema.type) || 'anyOf' in schema || 'oneOf' in schema -} diff --git a/zui/src/transforms/zui-to-typescript/typesOfSchema.ts b/zui/src/transforms/zui-to-typescript/typesOfSchema.ts deleted file mode 100644 index 61c96a50..00000000 --- a/zui/src/transforms/zui-to-typescript/typesOfSchema.ts +++ /dev/null @@ -1,149 +0,0 @@ -import isPlainObject from 'lodash/isPlainObject.js' -import { isCompound, type JSONSchema, type SchemaType } from './types/JSONSchema' - -/** - * Duck types a JSONSchema schema or property to determine which kind of AST node to parse it into. - * - * Due to what some might say is an oversight in the JSON-Schema spec, a given schema may - * implicitly be an *intersection* of multiple JSON-Schema directives (ie. multiple TypeScript - * types). The spec leaves it up to implementations to decide what to do with this - * loosely-defined behavior. - */ -export function typesOfSchema(schema: JSONSchema): readonly [SchemaType, ...SchemaType[]] { - // tsType is an escape hatch that supercedes all other directives - if (schema.tsType) { - return ['CUSTOM_TYPE'] - } - - // Collect matched types - const matchedTypes: SchemaType[] = [] - for (const [schemaType, f] of Object.entries(matchers)) { - if (f(schema)) { - matchedTypes.push(schemaType as SchemaType) - } - } - - // Default to an unnamed schema - if (!matchedTypes.length) { - return ['UNNAMED_SCHEMA'] - } - - return matchedTypes as [SchemaType, ...SchemaType[]] -} - -const matchers: Record boolean> = { - ALL_OF(schema) { - return 'allOf' in schema - }, - ANY(schema) { - if (Object.keys(schema).length === 0) { - // The empty schema {} validates any value - // @see https://json-schema.org/draft-07/json-schema-core.html#rfc.section.4.3.1 - return true - } - return schema.type === 'any' - }, - ANY_OF(schema) { - return 'anyOf' in schema - }, - BOOLEAN(schema) { - if ('enum' in schema) { - return false - } - if (schema.type === 'boolean') { - return true - } - if (!isCompound(schema) && typeof schema.default === 'boolean') { - return true - } - return false - }, - CUSTOM_TYPE() { - return false // Explicitly handled before we try to match - }, - NAMED_ENUM(schema) { - return 'enum' in schema && 'tsEnumNames' in schema - }, - NAMED_SCHEMA(schema) { - // 8.2.1. The presence of "$id" in a subschema indicates that the subschema constitutes a distinct schema resource within a single schema document. - return '$id' in schema && ('patternProperties' in schema || 'properties' in schema) - }, - NEVER(schema: JSONSchema | boolean) { - return schema === false - }, - NULL(schema) { - return schema.type === 'null' - }, - NUMBER(schema) { - if ('enum' in schema) { - return false - } - if (schema.type === 'integer' || schema.type === 'number') { - return true - } - if (!isCompound(schema) && typeof schema.default === 'number') { - return true - } - return false - }, - OBJECT(schema) { - return ( - schema.type === 'object' && - !isPlainObject(schema.additionalProperties) && - !schema.allOf && - !schema.anyOf && - !schema.oneOf && - !schema.patternProperties && - !schema.properties && - !schema.required - ) - }, - ONE_OF(schema) { - return 'oneOf' in schema - }, - REFERENCE(schema) { - return '$ref' in schema - }, - STRING(schema) { - if ('enum' in schema) { - return false - } - if (schema.type === 'string') { - return true - } - if (!isCompound(schema) && typeof schema.default === 'string') { - return true - } - return false - }, - TYPED_ARRAY(schema) { - if (schema.type && schema.type !== 'array') { - return false - } - return 'items' in schema - }, - UNION(schema) { - return Array.isArray(schema.type) - }, - UNNAMED_ENUM(schema) { - if ('tsEnumNames' in schema) { - return false - } - if ( - schema.type && - schema.type !== 'boolean' && - schema.type !== 'integer' && - schema.type !== 'number' && - schema.type !== 'string' - ) { - return false - } - return 'enum' in schema - }, - UNNAMED_SCHEMA() { - return false // Explicitly handled as the default case - }, - UNTYPED_ARRAY(schema) { - return schema.type === 'array' && !('items' in schema) - }, -} diff --git a/zui/src/transforms/zui-to-typescript/utils.ts b/zui/src/transforms/zui-to-typescript/utils.ts deleted file mode 100644 index 1d181700..00000000 --- a/zui/src/transforms/zui-to-typescript/utils.ts +++ /dev/null @@ -1,381 +0,0 @@ -import deburr from 'lodash/deburr.js' -import isPlainObject from 'lodash/isPlainObject.js' -import trim from 'lodash/trim.js' -import upperFirst from 'lodash/upperFirst.js' - -import { type JSONSchema, type LinkedJSONSchema, Parent } from './types/JSONSchema' - -const basename = (path: string, suffix?: string) => { - const base = path.split('/').pop() || '' - return suffix ? base.replaceAll(suffix, '') : base -} -const extname = (path: string) => { - const base = basename(path) - const index = base.lastIndexOf('.') - return index === -1 ? '' : base.slice(index) -} -const dirname = (path: string) => path.split('/').slice(0, -1).join('/') -const normalize = (path: string) => path.replace(/\/+/g, '/') -const sep = '/' -const posix = { join: (...args: string[]) => args.join('/'), normalize } - -// TODO: pull out into a separate package -export function Try(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, - callback: (schema: LinkedJSONSchema, key: string | null) => void, - processed: Set, -) { - 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, -) { - arr.forEach((s, k) => traverse(s, callback, processed, k.toString())) -} - -export function traverse( - schema: LinkedJSONSchema, - callback: (schema: LinkedJSONSchema, key: string | null) => void, - processed = new Set(), - 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) - } - - // 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) { - 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(...messages) -} - -type LogStyle = 'blue' | 'cyan' | 'green' | 'magenta' | 'red' | 'white' | 'yellow' - -export function log(_: 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(title, ...messages) - if (lastMessage) { - console.dir(lastMessage, { depth: 6, maxArrayLength: 6 }) - } -} - -/** - * 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 -} - -/** - * Removes the schema's `$id`, `name`, and `description` properties - * if they exist. - * Useful when parsing intersections. - * - * Mutates `schema`. - */ -export function maybeStripNameHints(schema: JSONSchema): JSONSchema { - if ('$id' in schema) { - delete schema.$id - } - if ('description' in schema) { - delete schema.description - } - if ('name' in schema) { - delete schema.name - } - 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: LinkedJSONSchema) { - if (!isPlainObject(schema)) { - return false - } - 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 -} diff --git a/zui/src/transforms/zui-to-typescript/validator.ts b/zui/src/transforms/zui-to-typescript/validator.ts deleted file mode 100644 index fb5ecf59..00000000 --- a/zui/src/transforms/zui-to-typescript/validator.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type JSONSchema, type LinkedJSONSchema } from './types/JSONSchema' -import { traverse } from './utils' - -type Rule = (schema: JSONSchema) => boolean | void -const rules = new Map() - -rules.set('Enum members and tsEnumNames must be of the same length', (schema) => { - if (schema.enum && schema.tsEnumNames && schema.enum.length !== schema.tsEnumNames.length) { - return false - } -}) - -rules.set('tsEnumNames must be an array of strings', (schema) => { - if (schema.tsEnumNames && schema.tsEnumNames.some((_) => typeof _ !== 'string')) { - return false - } -}) - -rules.set('When both maxItems and minItems are present, maxItems >= minItems', (schema) => { - const { maxItems, minItems } = schema - if (typeof maxItems === 'number' && typeof minItems === 'number') { - return maxItems >= minItems - } -}) - -rules.set('When maxItems exists, maxItems >= 0', (schema) => { - const { maxItems } = schema - if (typeof maxItems === 'number') { - return maxItems >= 0 - } -}) - -rules.set('When minItems exists, minItems >= 0', (schema) => { - const { minItems } = schema - if (typeof minItems === 'number') { - return minItems >= 0 - } -}) - -rules.set('deprecated must be a boolean', (schema) => { - const typeOfDeprecated = typeof schema.deprecated - return typeOfDeprecated === 'boolean' || typeOfDeprecated === 'undefined' -}) - -export function validate(schema: LinkedJSONSchema, filename: string): string[] { - const errors: string[] = [] - rules.forEach((rule, ruleName) => { - traverse(schema, (schema, key) => { - if (rule(schema) === false) { - errors.push(`Error at key "${key}" in file "${filename}": ${ruleName}`) - } - return schema - }) - }) - return errors -} diff --git a/zui/src/transforms/zui-to-typescript/zui-to-ts.test.ts b/zui/src/transforms/zui-to-typescript/zui-to-ts.test.ts deleted file mode 100644 index cadf7711..00000000 --- a/zui/src/transforms/zui-to-typescript/zui-to-ts.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { z } from '../../z/index' - -describe('zui-to-ts', () => { - test('generate typings for example schema', async () => { - const schema = z.object({ - name: z.string().title('Name'), - age: z.number().max(100).min(0).title('Age').describe('Age in years').default(18), - job: z.enum(['developer', 'designer', 'manager']).title('Job').default('developer'), - group: z.union([z.literal('avg'), z.literal('key'), z.literal('max')]), - }) - - const def = await schema.toTypescriptTypings({ schemaName: 'User' }) - expect(def).toMatchInlineSnapshot(` -"export interface User { -name: string -/** - * Age in years - */ -age?: number -job?: (\"developer\" | \"designer\" | \"manager\") -group: (\"avg\" | \"key\" | \"max\") -} -" - `) - }) - - test('without schema, no export statement', async () => { - const schema = z.object({ - name: z.string().title('Name'), - }) - - const def = await schema.toTypescriptTypings() - expect(def).toMatchInlineSnapshot(` -"{ -name: string -} -" - `) - }) -}) diff --git a/zui/src/ui/utils.ts b/zui/src/ui/utils.ts index d4d576c7..d7d9065d 100644 --- a/zui/src/ui/utils.ts +++ b/zui/src/ui/utils.ts @@ -108,6 +108,220 @@ function handleSpecialWords(text: string, index: number, words: string[]): strin return text } +export const words = (string: string): string[] => { + const hasUnicodeWord = /[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/ + const unicodeWords = /[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\W)|[0-9]+/g + const asciiWords = /[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g + + return hasUnicodeWord.test(string) ? string.match(unicodeWords) || [] : string.match(asciiWords) || [] +} + +const deburredLetters: { [key: string]: string } = { + // Latin-1 Supplement block + '\xc0': 'A', + '\xc1': 'A', + '\xc2': 'A', + '\xc3': 'A', + '\xc4': 'A', + '\xc5': 'A', + '\xe0': 'a', + '\xe1': 'a', + '\xe2': 'a', + '\xe3': 'a', + '\xe4': 'a', + '\xe5': 'a', + '\xc7': 'C', + '\xe7': 'c', + '\xd0': 'D', + '\xf0': 'd', + '\xc8': 'E', + '\xc9': 'E', + '\xca': 'E', + '\xcb': 'E', + '\xe8': 'e', + '\xe9': 'e', + '\xea': 'e', + '\xeb': 'e', + '\xcc': 'I', + '\xcd': 'I', + '\xce': 'I', + '\xcf': 'I', + '\xec': 'i', + '\xed': 'i', + '\xee': 'i', + '\xef': 'i', + '\xd1': 'N', + '\xf1': 'n', + '\xd2': 'O', + '\xd3': 'O', + '\xd4': 'O', + '\xd5': 'O', + '\xd6': 'O', + '\xd8': 'O', + '\xf2': 'o', + '\xf3': 'o', + '\xf4': 'o', + '\xf5': 'o', + '\xf6': 'o', + '\xf8': 'o', + '\xd9': 'U', + '\xda': 'U', + '\xdb': 'U', + '\xdc': 'U', + '\xf9': 'u', + '\xfa': 'u', + '\xfb': 'u', + '\xfc': 'u', + '\xdd': 'Y', + '\xfd': 'y', + '\xff': 'y', + '\xc6': 'Ae', + '\xe6': 'ae', + '\xde': 'Th', + '\xfe': 'th', + '\xdf': 'ss', + // Latin Extended-A block + '\u0100': 'A', + '\u0102': 'A', + '\u0104': 'A', + '\u0101': 'a', + '\u0103': 'a', + '\u0105': 'a', + '\u0106': 'C', + '\u0108': 'C', + '\u010a': 'C', + '\u010c': 'C', + '\u0107': 'c', + '\u0109': 'c', + '\u010b': 'c', + '\u010d': 'c', + '\u010e': 'D', + '\u0110': 'D', + '\u010f': 'd', + '\u0111': 'd', + '\u0112': 'E', + '\u0114': 'E', + '\u0116': 'E', + '\u0118': 'E', + '\u011a': 'E', + '\u0113': 'e', + '\u0115': 'e', + '\u0117': 'e', + '\u0119': 'e', + '\u011b': 'e', + '\u011c': 'G', + '\u011e': 'G', + '\u0120': 'G', + '\u0122': 'G', + '\u011d': 'g', + '\u011f': 'g', + '\u0121': 'g', + '\u0123': 'g', + '\u0124': 'H', + '\u0126': 'H', + '\u0125': 'h', + '\u0127': 'h', + '\u0128': 'I', + '\u012a': 'I', + '\u012c': 'I', + '\u012e': 'I', + '\u0130': 'I', + '\u0129': 'i', + '\u012b': 'i', + '\u012d': 'i', + '\u012f': 'i', + '\u0131': 'i', + '\u0134': 'J', + '\u0135': 'j', + '\u0136': 'K', + '\u0137': 'k', + '\u0138': 'k', + '\u0139': 'L', + '\u013b': 'L', + '\u013d': 'L', + '\u013f': 'L', + '\u0141': 'L', + '\u013a': 'l', + '\u013c': 'l', + '\u013e': 'l', + '\u0140': 'l', + '\u0142': 'l', + '\u0143': 'N', + '\u0145': 'N', + '\u0147': 'N', + '\u014a': 'N', + '\u0144': 'n', + '\u0146': 'n', + '\u0148': 'n', + '\u014b': 'n', + '\u014c': 'O', + '\u014e': 'O', + '\u0150': 'O', + '\u014d': 'o', + '\u014f': 'o', + '\u0151': 'o', + '\u0154': 'R', + '\u0156': 'R', + '\u0158': 'R', + '\u0155': 'r', + '\u0157': 'r', + '\u0159': 'r', + '\u015a': 'S', + '\u015c': 'S', + '\u015e': 'S', + '\u0160': 'S', + '\u015b': 's', + '\u015d': 's', + '\u015f': 's', + '\u0161': 's', + '\u0162': 'T', + '\u0164': 'T', + '\u0166': 'T', + '\u0163': 't', + '\u0165': 't', + '\u0167': 't', + '\u0168': 'U', + '\u016a': 'U', + '\u016c': 'U', + '\u016e': 'U', + '\u0170': 'U', + '\u0172': 'U', + '\u0169': 'u', + '\u016b': 'u', + '\u016d': 'u', + '\u016f': 'u', + '\u0171': 'u', + '\u0173': 'u', + '\u0174': 'W', + '\u0175': 'w', + '\u0176': 'Y', + '\u0177': 'y', + '\u0178': 'Y', + '\u0179': 'Z', + '\u017b': 'Z', + '\u017d': 'Z', + '\u017a': 'z', + '\u017c': 'z', + '\u017e': 'z', + '\u0132': 'IJ', + '\u0133': 'ij', + '\u0152': 'Oe', + '\u0153': 'oe', + '\u0149': "'n", + '\u017f': 's', +} + +export const deburr = (string: string): string => { + return string.replace(/[^\u0000-\u007E]/g, (char) => deburredLetters[char] || char) +} + +export const camelCase = (string: string): string => { + return words(deburr(string.replace(/['\u2019]/g, ''))).reduce((result: string, word: string, index: number) => { + word = word.toLowerCase() + return result + (index ? capitalize(word) : word) + }, '') +} + const acronyms = [ '2D', '3D', diff --git a/zui/src/z/types/basetype/index.ts b/zui/src/z/types/basetype/index.ts index 47f47958..f13ed391 100644 --- a/zui/src/z/types/basetype/index.ts +++ b/zui/src/z/types/basetype/index.ts @@ -48,7 +48,6 @@ import { } from '../index' import type { ZuiSchemaOptions } from '../../../transforms/zui-to-json-schema/zui-extension' import type { ObjectToZuiOptions } from '../../../transforms/object-to-zui' -import { type ToTypescriptTyingsOptions, toTypescriptTypings } from '../../../transforms/zui-to-typescript' import { TypescriptGenerationOptions, toTypescript } from '../../../transforms/zui-to-typescript-next' export type RefinementCtx = { @@ -577,13 +576,6 @@ export abstract class ZodType { - return toTypescriptTypings(this.toJsonSchema(), opts) - } - toTypescript(opts?: TypescriptGenerationOptions): string { return toTypescript(this, opts) } @@ -593,7 +585,7 @@ export abstract class ZodType Promise | string)[] }, ): Promise { - let result = toTypescript(this, { ...opts, formatters: [] }) + let result = toTypescript(this, { ...opts, formatter: undefined }) for (const formatter of opts?.formatters || []) { result = await formatter(result) }