Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: generate type maps. #1787

Draft
wants to merge 2 commits into
base: next
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
@@ -1,4 +1,5 @@
/*.ts
!/ts-json-schema-generator.ts
coverage/
dist/
node_modules/
Expand Down
57 changes: 51 additions & 6 deletions src/SchemaGenerator.ts
Expand Up @@ -12,30 +12,38 @@
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
) {}
) { }

Check failure on line 27 in src/SchemaGenerator.ts

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

Delete `·`

Check failure on line 27 in src/SchemaGenerator.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

Delete `·`

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<Definition> = {};
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#",
Expand All @@ -44,6 +52,40 @@
};
}

protected createTypeMaps(
rootNodes: ts.Node[],
rootTypeNames: string[][],
reachableDefinitions: StringMap<Definition>
): TypeMap[] {
const typeMaps: Record<string, TypeMap> = {};
const typeSeen = new Set<string>();
const nameSeen = new Set<string>();

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<typeof ")
);

const exports = typeNames
.map((typeName) => 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)];
Expand Down Expand Up @@ -79,7 +121,7 @@
protected getRootTypeDefinition(rootType: BaseType): Definition {
return this.typeFormatter.getDefinition(rootType);
}
protected appendRootChildDefinitions(rootType: BaseType, childDefinitions: StringMap<Definition>): void {
protected appendRootChildDefinitions(rootType: BaseType, childDefinitions: StringMap<Definition>): string[] {
const seen = new Set<string>();

const children = this.typeFormatter
Expand Down Expand Up @@ -107,13 +149,16 @@
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[];
Expand Down
39 changes: 37 additions & 2 deletions ts-json-schema-generator.ts
Expand Up @@ -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()
Expand All @@ -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 <file>", "Set the output file (default: stdout)")
.option("-m, --typemap <file>", "Generate a TypeScript type map file")
.option(
"--validation-keywords [value]",
"Provide additional validation keywords to include",
Expand Down Expand Up @@ -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
Expand All @@ -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));
Expand All @@ -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);
}