From 1b27258a91659f1db92d5b1aa2ab4ed0c3e48987 Mon Sep 17 00:00:00 2001 From: Arthur Fiorette <47537704+arthurfiorette@users.noreply.github.com> Date: Thu, 23 May 2024 10:39:50 -0300 Subject: [PATCH 1/2] feat: support `export *` statements (#1962) --- src/SchemaGenerator.ts | 196 ++++++++++++++++-------- src/Utils/symbolAtNode.ts | 7 +- test/sourceless-nodes/index.test.ts | 2 +- test/valid-data-type.test.ts | 2 + test/valid-data/export-star/literal.ts | 5 + test/valid-data/export-star/main.ts | 6 + test/valid-data/export-star/object.ts | 11 ++ test/valid-data/export-star/schema.json | 43 ++++++ 8 files changed, 199 insertions(+), 73 deletions(-) create mode 100644 test/valid-data/export-star/literal.ts create mode 100644 test/valid-data/export-star/main.ts create mode 100644 test/valid-data/export-star/object.ts create mode 100644 test/valid-data/export-star/schema.json diff --git a/src/SchemaGenerator.ts b/src/SchemaGenerator.ts index 723aaa7c9..2d3fbfcaf 100644 --- a/src/SchemaGenerator.ts +++ b/src/SchemaGenerator.ts @@ -1,16 +1,15 @@ import ts from "typescript"; +import type { Config } from "./Config.js"; import { NoRootTypeError } from "./Error/NoRootTypeError.js"; -import { Context, NodeParser } from "./NodeParser.js"; -import { Definition } from "./Schema/Definition.js"; -import { Schema } from "./Schema/Schema.js"; -import { BaseType } from "./Type/BaseType.js"; +import { Context, type NodeParser } from "./NodeParser.js"; +import type { Definition } from "./Schema/Definition.js"; +import type { Schema } from "./Schema/Schema.js"; +import type { BaseType } from "./Type/BaseType.js"; import { DefinitionType } from "./Type/DefinitionType.js"; -import { TypeFormatter } from "./TypeFormatter.js"; -import { StringMap } from "./Utils/StringMap.js"; -import { localSymbolAtNode, symbolAtNode } from "./Utils/symbolAtNode.js"; -import { removeUnreachable } from "./Utils/removeUnreachable.js"; -import { Config } from "./Config.js"; +import type { TypeFormatter } from "./TypeFormatter.js"; +import type { StringMap } from "./Utils/StringMap.js"; import { hasJsDocTag } from "./Utils/hasJsDocTag.js"; +import { removeUnreachable } from "./Utils/removeUnreachable.js"; export class SchemaGenerator { public constructor( @@ -32,7 +31,10 @@ export class SchemaGenerator { const rootTypeDefinition = rootTypes.length === 1 ? this.getRootTypeDefinition(rootTypes[0]) : undefined; const definitions: StringMap = {}; - rootTypes.forEach((rootType) => this.appendRootChildDefinitions(rootType, definitions)); + + for (const rootType of rootTypes) { + this.appendRootChildDefinitions(rootType, definitions); + } const reachableDefinitions = removeUnreachable(rootTypeDefinition, definitions); @@ -47,15 +49,15 @@ export class SchemaGenerator { protected getRootNodes(fullName: string | undefined): ts.Node[] { if (fullName && fullName !== "*") { return [this.findNamedNode(fullName)]; - } else { - const rootFileNames = this.program.getRootFileNames(); - const rootSourceFiles = this.program - .getSourceFiles() - .filter((sourceFile) => rootFileNames.includes(sourceFile.fileName)); - const rootNodes = new Map(); - this.appendTypes(rootSourceFiles, this.program.getTypeChecker(), rootNodes); - return [...rootNodes.values()]; } + + const rootFileNames = this.program.getRootFileNames(); + const rootSourceFiles = this.program + .getSourceFiles() + .filter((sourceFile) => rootFileNames.includes(sourceFile.fileName)); + const rootNodes = new Map(); + this.appendTypes(rootSourceFiles, this.program.getTypeChecker(), rootNodes); + return [...rootNodes.values()]; } protected findNamedNode(fullName: string): ts.Node { const typeChecker = this.program.getTypeChecker(); @@ -129,6 +131,7 @@ export class SchemaGenerator { return { projectFiles, externalFiles }; } + protected appendTypes( sourceFiles: readonly ts.SourceFile[], typeChecker: ts.TypeChecker, @@ -138,72 +141,131 @@ export class SchemaGenerator { this.inspectNode(sourceFile, typeChecker, types); } } + protected inspectNode(node: ts.Node, typeChecker: ts.TypeChecker, allTypes: Map): void { - switch (node.kind) { - case ts.SyntaxKind.VariableDeclaration: { - const variableDeclarationNode = node as ts.VariableDeclaration; - if ( - variableDeclarationNode.initializer?.kind === ts.SyntaxKind.ArrowFunction || - variableDeclarationNode.initializer?.kind === ts.SyntaxKind.FunctionExpression - ) { - this.inspectNode(variableDeclarationNode.initializer, typeChecker, allTypes); - } - return; + if (ts.isVariableDeclaration(node)) { + if ( + node.initializer?.kind === ts.SyntaxKind.ArrowFunction || + node.initializer?.kind === ts.SyntaxKind.FunctionExpression + ) { + this.inspectNode(node.initializer, typeChecker, allTypes); } - case ts.SyntaxKind.InterfaceDeclaration: - case ts.SyntaxKind.ClassDeclaration: - case ts.SyntaxKind.EnumDeclaration: - case ts.SyntaxKind.TypeAliasDeclaration: - if ( - this.config?.expose === "all" || - (this.isExportType(node) && !this.isGenericType(node as ts.TypeAliasDeclaration)) - ) { - allTypes.set(this.getFullName(node, typeChecker), node); - return; - } - return; - case ts.SyntaxKind.ConstructorType: - case ts.SyntaxKind.FunctionDeclaration: - case ts.SyntaxKind.FunctionExpression: - case ts.SyntaxKind.ArrowFunction: + + return; + } + + if ( + ts.isInterfaceDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isTypeAliasDeclaration(node) + ) { + if ( + this.config?.expose === "all" || + (this.isExportType(node) && !this.isGenericType(node as ts.TypeAliasDeclaration)) + ) { allTypes.set(this.getFullName(node, typeChecker), node); return; - case ts.SyntaxKind.ExportSpecifier: { - const exportSpecifierNode = node as ts.ExportSpecifier; - const symbol = typeChecker.getExportSpecifierLocalTargetSymbol(exportSpecifierNode); - if (symbol?.declarations?.length === 1) { - const declaration = symbol.declarations[0]; - if (declaration.kind === ts.SyntaxKind.ImportSpecifier) { - // Handling the `Foo` in `import { Foo } from "./lib"; export { Foo };` - const importSpecifierNode = declaration as ts.ImportSpecifier; - const type = typeChecker.getTypeAtLocation(importSpecifierNode); - if (type.symbol?.declarations?.length === 1) { - this.inspectNode(type.symbol.declarations[0], typeChecker, allTypes); - } - } else { - // Handling the `Bar` in `export { Bar } from './lib';` - this.inspectNode(declaration, typeChecker, allTypes); + } + return; + } + + if ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isConstructorTypeNode(node) + ) { + allTypes.set(this.getFullName(node, typeChecker), node); + return; + } + + if (ts.isExportSpecifier(node)) { + const symbol = typeChecker.getExportSpecifierLocalTargetSymbol(node); + + if (symbol?.declarations?.length === 1) { + const declaration = symbol.declarations[0]; + + if (ts.isImportSpecifier(declaration)) { + // Handling the `Foo` in `import { Foo } from "./lib"; export { Foo };` + const type = typeChecker.getTypeAtLocation(declaration); + + if (type.symbol?.declarations?.length === 1) { + this.inspectNode(type.symbol.declarations[0], typeChecker, allTypes); } + } else { + // Handling the `Bar` in `export { Bar } from './lib';` + this.inspectNode(declaration, typeChecker, allTypes); } + } + + return; + } + + if (ts.isExportDeclaration(node)) { + if (!ts.isExportDeclaration(node)) { return; } - default: - ts.forEachChild(node, (subnode) => this.inspectNode(subnode, typeChecker, allTypes)); + + // export { variable } clauses + if (!node.moduleSpecifier) { return; + } + + const symbol = typeChecker.getSymbolAtLocation(node.moduleSpecifier); + + // should never hit this (maybe type error in user's code) + if (!symbol || !symbol.declarations) { + return; + } + + // module augmentation can result in more than one source file + for (const source of symbol.declarations) { + const sourceSymbol = typeChecker.getSymbolAtLocation(source); + + if (!sourceSymbol) { + return; + } + + const moduleExports = typeChecker.getExportsOfModule(sourceSymbol); + + for (const moduleExport of moduleExports) { + const nodes = + moduleExport.declarations || + (!!moduleExport.valueDeclaration && [moduleExport.valueDeclaration]); + + // should never hit this (maybe type error in user's code) + if (!nodes) { + return; + } + + for (const subnodes of nodes) { + this.inspectNode(subnodes, typeChecker, allTypes); + } + } + } + + return; } + + ts.forEachChild(node, (subnode) => this.inspectNode(subnode, typeChecker, allTypes)); } - protected isExportType(node: ts.Node): boolean { + + protected isExportType( + node: ts.InterfaceDeclaration | ts.ClassDeclaration | ts.EnumDeclaration | ts.TypeAliasDeclaration, + ): boolean { if (this.config?.jsDoc !== "none" && hasJsDocTag(node, "internal")) { return false; } - const localSymbol = localSymbolAtNode(node); - return localSymbol ? "exportSymbol" in localSymbol : false; + + return !!node.localSymbol?.exportSymbol; } + protected isGenericType(node: ts.TypeAliasDeclaration): boolean { return !!(node.typeParameters && node.typeParameters.length > 0); } - protected getFullName(node: ts.Node, typeChecker: ts.TypeChecker): string { - const symbol = symbolAtNode(node)!; - return typeChecker.getFullyQualifiedName(symbol).replace(/".*"\./, ""); + + protected getFullName(node: ts.Declaration, typeChecker: ts.TypeChecker): string { + return typeChecker.getFullyQualifiedName(node.symbol).replace(/".*"\./, ""); } } diff --git a/src/Utils/symbolAtNode.ts b/src/Utils/symbolAtNode.ts index ab0335e15..1a6988d2b 100644 --- a/src/Utils/symbolAtNode.ts +++ b/src/Utils/symbolAtNode.ts @@ -1,8 +1,5 @@ -import ts from "typescript"; +import type ts from "typescript"; export function symbolAtNode(node: ts.Node): ts.Symbol | undefined { - return (node as any).symbol; -} -export function localSymbolAtNode(node: ts.Node): ts.Symbol | undefined { - return (node as any).localSymbol; + return (node as ts.Declaration).symbol; } diff --git a/test/sourceless-nodes/index.test.ts b/test/sourceless-nodes/index.test.ts index cb73297f5..90c2e6369 100644 --- a/test/sourceless-nodes/index.test.ts +++ b/test/sourceless-nodes/index.test.ts @@ -41,7 +41,7 @@ describe("sourceless-nodes", () => { }); }); -// From github.com/arthurfiorette/kita/blob/main/packages/generator/src/util/type-resolver.ts +// From https://github.com/kitajs/kitajs/blob/ebf23297de07887c78becff52120f941e69386ec/packages/parser/src/util/nodes.ts#L64 function getReturnType(node: ts.SignatureDeclaration, typeChecker: ts.TypeChecker) { const signature = typeChecker.getSignatureFromDeclaration(node); const implicitType = typeChecker.getReturnTypeOfSignature(signature!); diff --git a/test/valid-data-type.test.ts b/test/valid-data-type.test.ts index c1699a58e..39e626b47 100644 --- a/test/valid-data-type.test.ts +++ b/test/valid-data-type.test.ts @@ -145,4 +145,6 @@ describe("valid-data-type", () => { it("lowercase", assertValidSchema("lowercase", "MyType")); it("promise-extensions", assertValidSchema("promise-extensions", "*")); + + it("export-star", assertValidSchema("export-star", "*", undefined, { mainTsOnly: true })); }); diff --git a/test/valid-data/export-star/literal.ts b/test/valid-data/export-star/literal.ts new file mode 100644 index 000000000..e7a9fe6eb --- /dev/null +++ b/test/valid-data/export-star/literal.ts @@ -0,0 +1,5 @@ +export type A = 1; + +export type B = "string"; + +type C = "internal"; diff --git a/test/valid-data/export-star/main.ts b/test/valid-data/export-star/main.ts new file mode 100644 index 000000000..524c70e91 --- /dev/null +++ b/test/valid-data/export-star/main.ts @@ -0,0 +1,6 @@ +export * from "./literal"; +export * from "./object"; + +export type External = 1; + +type Internal = 2; diff --git a/test/valid-data/export-star/object.ts b/test/valid-data/export-star/object.ts new file mode 100644 index 000000000..e9ebcacea --- /dev/null +++ b/test/valid-data/export-star/object.ts @@ -0,0 +1,11 @@ +export interface D { + a: 1; +} + +export class E { + b: 2; +} + +interface F { + internal: true; +} diff --git a/test/valid-data/export-star/schema.json b/test/valid-data/export-star/schema.json new file mode 100644 index 000000000..0c34121c2 --- /dev/null +++ b/test/valid-data/export-star/schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "A": { + "const": 1, + "type": "number" + }, + "B": { + "const": "string", + "type": "string" + }, + "D": { + "additionalProperties": false, + "properties": { + "a": { + "const": 1, + "type": "number" + } + }, + "required": [ + "a" + ], + "type": "object" + }, + "E": { + "additionalProperties": false, + "properties": { + "b": { + "const": 2, + "type": "number" + } + }, + "required": [ + "b" + ], + "type": "object" + }, + "External": { + "const": 1, + "type": "number" + } + } +} From 4ac21b26a50695cb2be04e65c15250c0cf07b05e Mon Sep 17 00:00:00 2001 From: Dominik Moritz Date: Thu, 23 May 2024 09:51:36 -0400 Subject: [PATCH 2/2] feat: add back cjs output (#1964) * initial config * fix: lint * fix * fixed esm/cjs fuckery * fix: removed unused comment * feat: importHelpers for esm * remove build script and just read package.json file --------- Co-authored-by: Arthur Fiorette Co-authored-by: Arthur Fiorette <47537704+arthurfiorette@users.noreply.github.com> --- .gitignore | 3 ++- eslint.config.js | 2 +- package.json | 12 ++++++++++-- ts-json-schema-generator.ts | 10 ++++++---- tsconfig.cjs.json | 12 ++++++++++++ tsconfig.json | 5 +++-- 6 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 tsconfig.cjs.json diff --git a/.gitignore b/.gitignore index 9a630cb59..9edc6872c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /*.ts coverage/ dist/ +cjs/ node_modules/ !auto.config.ts /.idea/ @@ -10,4 +11,4 @@ node_modules/ # Other package managers pnpm-lock.yaml -package-lock.json +package-lock.json \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 1963ae3ee..74c0e841a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" /** @type {import('@types/eslint').Linter.FlatConfig[]} */ export default tseslint.config( { - ignores: ["dist"], + ignores: ["dist", "cjs", "build"], }, eslint.configs.recommended, { diff --git a/package.json b/package.json index 4598454e9..32bc25d3a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ts-json-schema-generator", "version": "2.0.0", "description": "Generate JSON schema from your Typescript sources", - "main": "dist/index.js", + "module": "dist/index.js", "types": "dist/index.d.ts", "type": "module", "bin": { @@ -10,6 +10,7 @@ }, "files": [ "dist", + "cjs", "src", "factory", "index.*", @@ -44,6 +45,10 @@ "engines": { "node": ">=18.0.0" }, + "exports": { + "import": "./dist/index.js", + "require": "./cjs/index.js" + }, "dependencies": { "@types/json-schema": "^7.0.15", "commander": "^12.0.0", @@ -51,6 +56,7 @@ "json5": "^2.2.3", "normalize-path": "^3.0.0", "safe-stable-stringify": "^2.4.3", + "tslib": "^2.6.2", "typescript": "^5.4.5" }, "devDependencies": { @@ -84,7 +90,9 @@ }, "scripts": { "prepublishOnly": "yarn build", - "build": "tsc", + "build": "npm run build:cjs && npm run build:esm", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build:esm": "tsc -p tsconfig.json", "watch": "tsc -w", "lint": "eslint", "format": "eslint --fix", diff --git a/ts-json-schema-generator.ts b/ts-json-schema-generator.ts index 8b28fc575..255d0e539 100644 --- a/ts-json-schema-generator.ts +++ b/ts-json-schema-generator.ts @@ -1,12 +1,14 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; import { Command, Option } from "commander"; import stableStringify from "safe-stable-stringify"; import { createGenerator } from "./factory/generator.js"; -import { Config } from "./src/Config.js"; +import type { Config } from "./src/Config.js"; import { BaseError } from "./src/Error/BaseError.js"; import { formatError } from "./src/Utils/formatError.js"; -import pkg from "./package.json" with { type: "json" }; -import { dirname } from "path"; -import { mkdirSync, writeFileSync } from "fs"; +import fs from "node:fs"; + +const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); const args = new Command() .option("-p, --path ", "Source file path") diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 000000000..de02fb0c0 --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "cjs" + }, + "files": ["ts-json-schema-generator.ts", "index.ts"], + "include": ["src/**/*.ts", "factory/**/*.ts"], + "exclude": ["node_modules", "dist", "cjs"] +} diff --git a/tsconfig.json b/tsconfig.json index 6d82b123a..d0f20b4a0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,9 +22,10 @@ "pretty": true, "typeRoots": ["node_modules/@types"], "outDir": "dist", - "incremental": true + "incremental": true, + "importHelpers": true }, "files": ["ts-json-schema-generator.ts", "index.ts"], "include": ["src/**/*.ts", "factory/**/*.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "cjs"] }