From 28290a1f63933f444d72fc103fbdc01e0d527f27 Mon Sep 17 00:00:00 2001 From: Martin Blom Date: Mon, 2 Oct 2023 21:49:23 +0200 Subject: [PATCH 1/2] feat: generate type maps. --- .gitignore | 1 + src/SchemaGenerator.ts | 55 +++++++++++++++++++++++++++++++++---- ts-json-schema-generator.ts | 43 +++++++++++++++++++++++++++-- 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index dfb06bc6d..c47bccb06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /*.ts +!/ts-json-schema-generator.ts coverage/ dist/ node_modules/ diff --git a/src/SchemaGenerator.ts b/src/SchemaGenerator.ts index 00f4d5802..449f48522 100644 --- a/src/SchemaGenerator.ts +++ b/src/SchemaGenerator.ts @@ -12,6 +12,12 @@ import { removeUnreachable } from "./Utils/removeUnreachable"; import { Config } from "./Config"; import { hasJsDocTag } from "./Utils/hasJsDocTag"; +export interface TypeMap { + sourceFile: ts.SourceFile; + typeNames: string[]; + exports: string[]; +} + export class SchemaGenerator { public constructor( protected readonly program: ts.Program, @@ -20,22 +26,28 @@ export class SchemaGenerator { protected readonly config?: Config ) {} - public createSchema(fullName?: string): Schema { + public createSchema(fullName?: string, typeMapResult?: TypeMap[]): Schema { const rootNodes = this.getRootNodes(fullName); - return this.createSchemaFromNodes(rootNodes); + return this.createSchemaFromNodes(rootNodes, typeMapResult); } - public createSchemaFromNodes(rootNodes: ts.Node[]): Schema { + public createSchemaFromNodes(rootNodes: ts.Node[], typeMapResult?: TypeMap[]): Schema { const rootTypes = rootNodes.map((rootNode) => { return this.nodeParser.createType(rootNode, new Context()); }); const rootTypeDefinition = rootTypes.length === 1 ? this.getRootTypeDefinition(rootTypes[0]) : undefined; const definitions: StringMap = {}; - rootTypes.forEach((rootType) => this.appendRootChildDefinitions(rootType, definitions)); + const typeMaps = rootTypes.map((rootType, i) => ({ + sourceFile: rootNodes[i].getSourceFile(), + typeNames: this.appendRootChildDefinitions(rootType, definitions), + exports: [], + })); const reachableDefinitions = removeUnreachable(rootTypeDefinition, definitions); + typeMapResult?.splice(0, Infinity, ...this.mergedTypeMaps(typeMaps, reachableDefinitions)); + return { ...(this.config?.schemaId ? { $id: this.config.schemaId } : {}), $schema: "http://json-schema.org/draft-07/schema#", @@ -44,6 +56,36 @@ export class SchemaGenerator { }; } + protected mergedTypeMaps(typeMaps: TypeMap[], reachableDefinitions: StringMap): TypeMap[] { + const mergedTypeMaps: Record = {}; + + for (const typeMap of typeMaps) { + const mergedTypeMap = (mergedTypeMaps[typeMap.sourceFile.fileName] ??= { + sourceFile: typeMap.sourceFile, + typeNames: [], + exports: [], + }); + + const typeNames = typeMap.typeNames.filter( + (tn) => !!reachableDefinitions[tn] && !tn.startsWith("NamedParameters name.replace(/[<.].*/g, "")) + .filter((type) => symbolAtNode(typeMap.sourceFile)?.exports?.has(ts.escapeLeadingUnderscores(type))); + + mergedTypeMap.typeNames.push(...typeNames); + mergedTypeMap.exports.push(...exports); + } + + return Object.values(mergedTypeMaps).filter((typeMap) => { + typeMap.exports = [...new Set(typeMap.exports).values()]; + typeMap.typeNames = [...new Set(typeMap.typeNames).values()]; + + return typeMap.exports.length || typeMap.typeNames.length; + }); + } + protected getRootNodes(fullName: string | undefined): ts.Node[] { if (fullName && fullName !== "*") { return [this.findNamedNode(fullName)]; @@ -79,7 +121,7 @@ export class SchemaGenerator { protected getRootTypeDefinition(rootType: BaseType): Definition { return this.typeFormatter.getDefinition(rootType); } - protected appendRootChildDefinitions(rootType: BaseType, childDefinitions: StringMap): void { + protected appendRootChildDefinitions(rootType: BaseType, childDefinitions: StringMap): string[] { const seen = new Set(); const children = this.typeFormatter @@ -107,13 +149,16 @@ export class SchemaGenerator { ids.set(name, childId); } + const names: string[] = []; children.reduce((definitions, child) => { const name = child.getName(); if (!(name in definitions)) { definitions[name] = this.typeFormatter.getDefinition(child.getType()); + names.push(name); } return definitions; }, childDefinitions); + return names; } protected partitionFiles(): { projectFiles: ts.SourceFile[]; diff --git a/ts-json-schema-generator.ts b/ts-json-schema-generator.ts index 2511c73f3..91ceecba4 100644 --- a/ts-json-schema-generator.ts +++ b/ts-json-schema-generator.ts @@ -1,11 +1,13 @@ import { Command, Option } from "commander"; import stableStringify from "safe-stable-stringify"; +import { isExternalModule } from "typescript"; import { createGenerator } from "./factory/generator"; import { Config, DEFAULT_CONFIG } from "./src/Config"; import { BaseError } from "./src/Error/BaseError"; +import { TypeMap } from "./src/SchemaGenerator"; import { formatError } from "./src/Utils/formatError"; import * as pkg from "./package.json"; -import { dirname } from "path"; +import { dirname, relative } from "path"; import { mkdirSync, writeFileSync } from "fs"; const args = new Command() @@ -28,6 +30,7 @@ const args = new Command() .option("--no-type-check", "Skip type checks to improve performance") .option("--no-ref-encode", "Do not encode references") .option("-o, --out ", "Set the output file (default: stdout)") + .option("-m, --typemap ", "Generate a TypeScript type map file") .option( "--validation-keywords [value]", "Provide additional validation keywords to include", @@ -62,7 +65,8 @@ const config: Config = { }; try { - const schema = createGenerator(config).createSchema(args.type); + const typeMaps: TypeMap[] = []; + const schema = createGenerator(config).createSchema(args.type, typeMaps); const stringify = config.sortProps ? stableStringify : JSON.stringify; // need as string since TS can't figure out that the string | undefined case doesn't happen @@ -77,6 +81,10 @@ try { // write to stdout process.stdout.write(`${schemaString}\n`); } + + if (args.typemap) { + writeTypeMapFile(typeMaps, args.typemap); + } } catch (error) { if (error instanceof BaseError) { process.stderr.write(formatError(error)); @@ -85,3 +93,34 @@ try { throw error; } } + +function writeTypeMapFile(typeMaps: TypeMap[], typeMapeFile: string) { + const typeMapDir = dirname(typeMapeFile); + const typesSeen = new Set(); + let code = ""; + + typeMaps.forEach((typeMap) => { + const fileName = relative(typeMapDir, typeMap.sourceFile.fileName); + const imported = typeMap.exports.filter((type) => !typesSeen.has(type)); + imported.forEach((type) => typesSeen.add(type)); + + if (isExternalModule(typeMap.sourceFile)) { + code += `import type { ${imported.join(", ")} } from "./${fileName}";\n`; + } else { + code += `import "./${fileName}";\n`; + } + }); + + code += "\nexport default interface Definitions {\n"; + + typeMaps.forEach((typeMap) => + typeMap.typeNames.forEach((typeName) => { + code += ` [\`${typeName}\`]: ${typeName};\n`; + }) + ); + + code += `}\n`; + + mkdirSync(typeMapDir, { recursive: true }); + writeFileSync(typeMapeFile, code); +} From a99d44444c1e283a050c91d9ee47694a3ef1ed3a Mon Sep 17 00:00:00 2001 From: Martin Blom Date: Fri, 6 Oct 2023 20:49:24 +0200 Subject: [PATCH 2/2] fix: Don't expose TS compiler types in type maps. --- src/SchemaGenerator.ts | 60 ++++++++++++++++++------------------- ts-json-schema-generator.ts | 10 ++----- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/src/SchemaGenerator.ts b/src/SchemaGenerator.ts index 449f48522..ba4787659 100644 --- a/src/SchemaGenerator.ts +++ b/src/SchemaGenerator.ts @@ -13,9 +13,9 @@ import { Config } from "./Config"; import { hasJsDocTag } from "./Utils/hasJsDocTag"; export interface TypeMap { - sourceFile: ts.SourceFile; + fileName: string; typeNames: string[]; - exports: string[]; + exports?: string[]; } export class SchemaGenerator { @@ -24,7 +24,7 @@ export class SchemaGenerator { protected readonly nodeParser: NodeParser, protected readonly typeFormatter: TypeFormatter, protected readonly config?: Config - ) {} + ) { } public createSchema(fullName?: string, typeMapResult?: TypeMap[]): Schema { const rootNodes = this.getRootNodes(fullName); @@ -38,15 +38,11 @@ export class SchemaGenerator { const rootTypeDefinition = rootTypes.length === 1 ? this.getRootTypeDefinition(rootTypes[0]) : undefined; const definitions: StringMap = {}; - const typeMaps = rootTypes.map((rootType, i) => ({ - sourceFile: rootNodes[i].getSourceFile(), - typeNames: this.appendRootChildDefinitions(rootType, definitions), - exports: [], - })); + const rootTypeNames = rootTypes.map((rootType) => this.appendRootChildDefinitions(rootType, definitions)); const reachableDefinitions = removeUnreachable(rootTypeDefinition, definitions); - typeMapResult?.splice(0, Infinity, ...this.mergedTypeMaps(typeMaps, reachableDefinitions)); + typeMapResult?.splice(0, Infinity, ...this.createTypeMaps(rootNodes, rootTypeNames, reachableDefinitions)); return { ...(this.config?.schemaId ? { $id: this.config.schemaId } : {}), @@ -56,34 +52,38 @@ export class SchemaGenerator { }; } - protected mergedTypeMaps(typeMaps: TypeMap[], reachableDefinitions: StringMap): TypeMap[] { - const mergedTypeMaps: Record = {}; - - for (const typeMap of typeMaps) { - const mergedTypeMap = (mergedTypeMaps[typeMap.sourceFile.fileName] ??= { - sourceFile: typeMap.sourceFile, + protected createTypeMaps( + rootNodes: ts.Node[], + rootTypeNames: string[][], + reachableDefinitions: StringMap + ): TypeMap[] { + const typeMaps: Record = {}; + const typeSeen = new Set(); + const nameSeen = new Set(); + + rootNodes.forEach((rootNode, i) => { + const sourceFile = rootNode.getSourceFile(); + const fileName = sourceFile.fileName; + const typeMap = (typeMaps[fileName] ??= { + fileName, typeNames: [], - exports: [], + exports: ts.isExternalModule(sourceFile) ? [] : undefined, }); - const typeNames = typeMap.typeNames.filter( - (tn) => !!reachableDefinitions[tn] && !tn.startsWith("NamedParameters !!reachableDefinitions[typeName] && !typeName.startsWith("NamedParameters name.replace(/[<.].*/g, "")) - .filter((type) => symbolAtNode(typeMap.sourceFile)?.exports?.has(ts.escapeLeadingUnderscores(type))); - - mergedTypeMap.typeNames.push(...typeNames); - mergedTypeMap.exports.push(...exports); - } + .map((typeName) => typeName.replace(/[<.].*/g, "")) + .filter((type) => symbolAtNode(sourceFile)?.exports?.has(ts.escapeLeadingUnderscores(type))) + .filter((type) => !typeSeen.has(type) && typeSeen.add(type)); - return Object.values(mergedTypeMaps).filter((typeMap) => { - typeMap.exports = [...new Set(typeMap.exports).values()]; - typeMap.typeNames = [...new Set(typeMap.typeNames).values()]; - - return typeMap.exports.length || typeMap.typeNames.length; + typeMap.typeNames.push(...typeNames.filter((name) => !nameSeen.has(name) && nameSeen.add(name))); + typeMap.exports?.push(...exports); }); + + return Object.values(typeMaps).filter((tm) => !tm.exports || tm.exports.length || tm.typeNames.length); } protected getRootNodes(fullName: string | undefined): ts.Node[] { @@ -154,8 +154,8 @@ export class SchemaGenerator { const name = child.getName(); if (!(name in definitions)) { definitions[name] = this.typeFormatter.getDefinition(child.getType()); - names.push(name); } + names.push(name); return definitions; }, childDefinitions); return names; diff --git a/ts-json-schema-generator.ts b/ts-json-schema-generator.ts index 91ceecba4..0e9fb7dc8 100644 --- a/ts-json-schema-generator.ts +++ b/ts-json-schema-generator.ts @@ -1,6 +1,5 @@ import { Command, Option } from "commander"; import stableStringify from "safe-stable-stringify"; -import { isExternalModule } from "typescript"; import { createGenerator } from "./factory/generator"; import { Config, DEFAULT_CONFIG } from "./src/Config"; import { BaseError } from "./src/Error/BaseError"; @@ -96,16 +95,13 @@ try { function writeTypeMapFile(typeMaps: TypeMap[], typeMapeFile: string) { const typeMapDir = dirname(typeMapeFile); - const typesSeen = new Set(); let code = ""; typeMaps.forEach((typeMap) => { - const fileName = relative(typeMapDir, typeMap.sourceFile.fileName); - const imported = typeMap.exports.filter((type) => !typesSeen.has(type)); - imported.forEach((type) => typesSeen.add(type)); + const fileName = relative(typeMapDir, typeMap.fileName); - if (isExternalModule(typeMap.sourceFile)) { - code += `import type { ${imported.join(", ")} } from "./${fileName}";\n`; + if (typeMap.exports) { + code += `import type { ${typeMap.exports.join(", ")} } from "./${fileName}";\n`; } else { code += `import "./${fileName}";\n`; }