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..ba4787659 100644 --- a/src/SchemaGenerator.ts +++ b/src/SchemaGenerator.ts @@ -12,30 +12,38 @@ import { removeUnreachable } from "./Utils/removeUnreachable"; import { Config } from "./Config"; import { hasJsDocTag } from "./Utils/hasJsDocTag"; +export interface TypeMap { + fileName: string; + typeNames: string[]; + exports?: string[]; +} + export class SchemaGenerator { public constructor( protected readonly program: ts.Program, protected readonly nodeParser: NodeParser, protected readonly typeFormatter: TypeFormatter, 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 rootTypeNames = rootTypes.map((rootType) => this.appendRootChildDefinitions(rootType, definitions)); const reachableDefinitions = removeUnreachable(rootTypeDefinition, definitions); + typeMapResult?.splice(0, Infinity, ...this.createTypeMaps(rootNodes, rootTypeNames, reachableDefinitions)); + return { ...(this.config?.schemaId ? { $id: this.config.schemaId } : {}), $schema: "http://json-schema.org/draft-07/schema#", @@ -44,6 +52,40 @@ export class SchemaGenerator { }; } + 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: ts.isExternalModule(sourceFile) ? [] : undefined, + }); + + const typeNames = rootTypeNames[i].filter( + (typeName) => !!reachableDefinitions[typeName] && !typeName.startsWith("NamedParameters typeName.replace(/[<.].*/g, "")) + .filter((type) => symbolAtNode(sourceFile)?.exports?.has(ts.escapeLeadingUnderscores(type))) + .filter((type) => !typeSeen.has(type) && typeSeen.add(type)); + + 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[] { 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..0e9fb7dc8 100644 --- a/ts-json-schema-generator.ts +++ b/ts-json-schema-generator.ts @@ -3,9 +3,10 @@ import stableStringify from "safe-stable-stringify"; 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 +29,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 +64,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 +80,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 +92,31 @@ try { throw error; } } + +function writeTypeMapFile(typeMaps: TypeMap[], typeMapeFile: string) { + const typeMapDir = dirname(typeMapeFile); + let code = ""; + + typeMaps.forEach((typeMap) => { + const fileName = relative(typeMapDir, typeMap.fileName); + + if (typeMap.exports) { + code += `import type { ${typeMap.exports.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); +}