feat!: complete rework

- Use Prettier for formatting
- Use composite TypeScript projects
- Use generics in custom AST and resolve them for outputs that don’t support generics
- Add tests
- Export AST types
- Export single renderers
- JSON Schema renderer option to control unresolvedProperties/additionalProperties
- Tests

The Markdown generation may still be improved.
This commit is contained in:
Lukas Obermann
2023-11-09 20:04:36 +01:00
parent b4739d0916
commit 1960dd98d8
35 changed files with 4384 additions and 1549 deletions
+10 -10
View File
@@ -9,18 +9,18 @@ on:
- v[0-9]+.[0-9]+.[0-9]+*
jobs:
# build:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v2
# - uses: actions/setup-node@v2
# with:
# node-version: 16
# - run: npm ci
# - run: npm test
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
- run: npm ci
- run: npm test
publish-npm:
# needs: build
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
+4
View File
@@ -1,2 +1,6 @@
*.ast.json
*.ast.resolved.json
*.tsbuildinfo
node_modules
lib
libtest
+2
View File
@@ -1,7 +1,9 @@
.github
*.tsbuildinfo
.versionrc.json
.vscode
CODEOWNERS
libtest
src
test
tsconfig.json
+1
View File
@@ -0,0 +1 @@
semi: false
+18
View File
@@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"cwd": "${workspaceFolder}/../optolith-database-schema",
"program": "${workspaceFolder}/src/bin/cli.ts",
"outFiles": [
"${workspaceFolder}/lib/**/*.js"
]
}
]
}
+1360 -303
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -12,27 +12,29 @@
"main": "./lib/main.js",
"exports": {
".": "./lib/main.js",
"./config": "./lib/config.js",
"./renderers": "./lib/renderers.js"
"./ast": "./lib/ast.js",
"./renderers": "./lib/renderers.js",
"./renderers/*": "./lib/renderers/*.js"
},
"bin": {
"otjsmd": "./lib/bin/cli.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"build": "tsc -b",
"watch": "tsc -b -w",
"release": "standard-version",
"test": "node --test"
"test": "glob -c \"node --import tsx --test\" \"./test/**/*.test.ts\""
},
"author": "Lukas Obermann",
"license": "MPL-2.0",
"dependencies": {
"typescript": "^5.1.6"
"typescript": "^5.2.2"
},
"devDependencies": {
"@types/node": "^20.2.5",
"@types/node": "^20.9.0",
"glob": "^10.3.10",
"standard-version": "^9.5.0",
"ts-node": "^10.9.1"
"tsx": "^4.0.0"
},
"repository": "github:elyukai/optolith-tsjsonschemamd",
"bugs": {
+455
View File
@@ -0,0 +1,455 @@
/**
* The possible discriminator values to differenciate the different nodes.
*/
export enum NodeKind {
Root,
Group,
Record,
Dictionary,
Token,
Reference,
Enumeration,
EnumerationCase,
Array,
Union,
Literal,
Tuple,
ExportAssignment,
TypeDefinition,
TypeParameter,
DefaultImport,
NamedImport,
NamespaceImport,
Intersection,
}
/**
* The parsed JSDoc annotations for a node.
*/
export type Doc = {
/**
* The initial description text.
*/
comment?: string
/**
* A dictionary of supported tags (`@tag`) with parsed values, if present.
*/
tags: DocTags
}
/**
* A dictionary from all supported tag names to their JSON Schema data types.
*/
export type DocTagTypes = {
// General
main: "string"
title: "string"
default: "unknown"
// String
minLength: "integer"
maxLength: "integer"
pattern: "string"
format: "string"
markdown: "boolean"
// Numeric
integer: "boolean"
minimum: "number"
maximum: "number"
multipleOf: "number"
exclusiveMinimum: "number"
exclusiveMaximum: "number"
// Object
minProperties: "integer"
maxProperties: "integer"
patternProperties: "string"
// Array
minItems: "integer"
maxItems: "integer"
uniqueItems: "boolean"
}
/**
* A dictionary from all supported data types in JSON Schema to their
* corresponding TypeScript data types.
*/
type JSONSchemaTypeToTypeScriptType = {
number: number
integer: number
boolean: boolean
string: string
unknown: unknown
}
/**
* A dictionary from all supported tags to their values.
*/
export type DocTags = {
-readonly [K in keyof DocTagTypes]?: JSONSchemaTypeToTypeScriptType[DocTagTypes[K]]
}
/**
* An object with a fixed set of keys, which may have different value types.
*/
export type RecordNode = {
kind: NodeKind.Record
fileName: string
jsDoc?: Doc
/**
* All properties, keyed by the property name.
*/
children: {
[identifier: string]: {
jsDoc?: Doc
/**
* Is the property required?
*/
isRequired: boolean
/**
* The property value.
*/
value: ChildNode
/**
* Is the property read-only?
*/
isReadOnly: boolean
}
}
}
export const isRecordNode = (node: Node): node is RecordNode =>
node.kind === NodeKind.Record
/**
* An object with a variable set of keys with the same value type. The keys may
* be restricted to match a certain regular expression.
*/
export type DictionaryNode = {
kind: NodeKind.Dictionary
fileName: string
jsDoc?: Doc
/**
* The value type at all defined keys.
*/
children: ChildNode
/**
* An optional pattern in regular expression syntax all keys must match.
*/
pattern?: string
}
export const isDictionaryNode = (node: Node): node is DictionaryNode =>
node.kind === NodeKind.Dictionary
export type IntersectionNode = {
kind: NodeKind.Intersection
fileName: string
jsDoc?: Doc
/**
* The types to intersect.
*/
children: ChildNode[]
}
export const isIntersectionNode = (node: Node): node is IntersectionNode =>
node.kind === NodeKind.Intersection
/**
* The possible discriminator values to differenciate the different tokens.
*/
export enum TokenKind {
String,
Number,
Boolean,
}
/**
* A primitive type.
*/
export type TokenNode = {
kind: NodeKind.Token
fileName: string
jsDoc?: Doc
/**
* The specific primitive type.
*/
token: TokenKind
}
export const isTokenNode = (node: Node): node is TokenNode =>
node.kind === NodeKind.Token
export type QualifiedName = {
segment: string
right?: QualifiedName
}
/**
* A reference to another type.
*/
export type ReferenceNode = {
kind: NodeKind.Reference
fileName: string
jsDoc?: Doc
/**
* Only used if type parameters are resolved, since this involves copying
* nodes in different files.
*/
resolvedFileName?: string
name: QualifiedName
typeArguments?: ChildNode[]
}
export const isReferenceNode = (node: Node): node is ReferenceNode =>
node.kind === NodeKind.Reference
/**
* An array of elements of the same type.
*/
export type ArrayNode = {
kind: NodeKind.Array
fileName: string
jsDoc?: Doc
/**
* The type of all elements.
*/
children: ChildNode
}
export const isArrayNode = (node: Node): node is ArrayNode =>
node.kind === NodeKind.Array
/**
* A set of possible types.
*/
export type UnionNode = {
kind: NodeKind.Union
fileName: string
jsDoc?: Doc
/**
* The list of all possible types.
*/
children: ChildNode[]
}
export const isUnionNode = (node: Node): node is UnionNode =>
node.kind === NodeKind.Union
/**
* A constant value.
*/
export type LiteralNode = {
kind: NodeKind.Literal
fileName: string
jsDoc?: Doc
/**
* The constant value.
*/
value: string | number | boolean
}
export const isLiteralNode = (node: Node): node is LiteralNode =>
node.kind === NodeKind.Literal
/**
* A tuple of elements that may have different types.
*/
export type TupleNode = {
kind: NodeKind.Tuple
fileName: string
jsDoc?: Doc
/**
* The types of the different elements of the tuple. Each node index
* corresponds with the index in the tuple.
*/
children: ChildNode[]
}
export const isTupleNode = (node: Node): node is TupleNode =>
node.kind === NodeKind.Tuple
/**
* A fixed set of possible string or numeric values.
*/
export type EnumerationNode = {
kind: NodeKind.Enumeration
fileName: string
name: string
jsDoc?: Doc
/**
* All possible cases.
*/
children: EnumerationCase[]
}
export const isEnumerationNode = (node: Node): node is EnumerationNode =>
node.kind === NodeKind.Enumeration
/**
* A possible case from an enumeration.
*/
export type EnumerationCase = {
kind: NodeKind.EnumerationCase
jsDoc?: Doc
fileName: string
/**
* The case name.
*/
name: string
/**
* The value the case represents.
*/
value: string | number
}
export const isEnumerationCase = (node: Node): node is EnumerationCase =>
node.kind === NodeKind.EnumerationCase
/**
* A grouped/namespaced set of declarations.
*/
export type GroupNode = {
kind: NodeKind.Group
fileName: string
name: string
jsDoc?: Doc
/**
* All elements within, keyed by their identifier.
*/
children: StatementNode[]
}
export const isGroupNode = (node: Node): node is GroupNode =>
node.kind === NodeKind.Group
export type TypeParameterNode = {
kind: NodeKind.TypeParameter
name: string
fileName: string
constraint?: ChildNode
default?: ChildNode
}
export const isTypeParameterNode = (node: Node): node is TypeParameterNode =>
node.kind === NodeKind.TypeParameter
/**
* A type definition (alias or interface).
*/
export type TypeDefinitionNode = {
kind: NodeKind.TypeDefinition
fileName: string
name: string
jsDoc?: Doc
typeParameters?: TypeParameterNode[]
definition: ChildNode
}
export const isTypeDefinitionNode = (node: Node): node is TypeDefinitionNode =>
node.kind === NodeKind.TypeDefinition
export type ExportAssignmentNode = {
kind: NodeKind.ExportAssignment
fileName: string
name: string
jsDoc?: Doc
expression: ChildNode
}
export const isExportAssignmentNode = (
node: Node
): node is ExportAssignmentNode => node.kind === NodeKind.ExportAssignment
/**
* A supported nested type node in a TypeScript file.
*/
export type ChildNode =
| RecordNode
| DictionaryNode
| TokenNode
| ReferenceNode
| ArrayNode
| UnionNode
| LiteralNode
| TupleNode
| IntersectionNode
/**
* A supported top-level node in a TypeScript file.
*/
export type StatementNode =
| GroupNode
| EnumerationNode
| TypeDefinitionNode
| ExportAssignmentNode
export type NamespaceImport = {
kind: NodeKind.NamespaceImport
name: string
fileName: string
}
export const isNamespaceImport = (node: Node): node is NamespaceImport =>
node.kind === NodeKind.NamespaceImport
export type NamedImport = {
kind: NodeKind.NamedImport
name: string
alias?: string
fileName: string
}
export const isNamedImport = (node: Node): node is NamedImport =>
node.kind === NodeKind.NamedImport
export type DefaultImport = {
kind: NodeKind.DefaultImport
name: string
fileName: string
}
export const isDefaultImport = (node: Node): node is DefaultImport =>
node.kind === NodeKind.DefaultImport
export type ImportNode = NamespaceImport | NamedImport | DefaultImport
/**
* The file root node.
*/
export type RootNode = {
kind: NodeKind.Root
jsDoc?: Doc
fileName: string
imports: ImportNode[]
/**
* All top-level type declarations.
*/
children: StatementNode[]
}
export type ContentNode = RootNode | StatementNode | ChildNode
export type SupportNode = TypeParameterNode | EnumerationCase
export type Node = ContentNode | ImportNode | SupportNode
+21 -12
View File
@@ -4,7 +4,8 @@ import { resolve } from "node:path"
import { argv, cwd } from "node:process"
import { GeneratorOptions, generate } from "../main.js"
const cliOptions = argv.slice(2)
const cliOptions = argv
.slice(2)
.reduce<[lastOption: string | undefined, options: Map<string, string[]>]>(
([lastOption, map], arg) => {
if (/^-{1,2}/.test(arg)) {
@@ -18,17 +19,21 @@ const cliOptions = argv.slice(2)
[undefined, new Map()]
)[1]
const optionsPath = resolve(cwd(), cliOptions.get("-c")?.[0] ?? cliOptions.get("--config")?.[0] ?? "otjsmd.config.js")
const optionsPath = resolve(
cwd(),
cliOptions.get("-c")?.[0] ??
cliOptions.get("--config")?.[0] ??
"otjsmd.config.js"
)
const options = (await import(optionsPath)).default as GeneratorOptions
if (cliOptions.has("-w") || cliOptions.has("--watch")) {
try {
generate(options)
}
catch (err) {
} catch (err) {
console.error(err)
}
finally {
} finally {
console.log("Watching for changes ...")
const generate$ = debounce(generate, 300)
@@ -36,21 +41,25 @@ if (cliOptions.has("-w") || cliOptions.has("--watch")) {
for await (const _ of watch(options.sourceDir, { recursive: true })) {
try {
generate$(options)
}
catch (err) {
} catch (err) {
console.error(err)
}
}
}
}
else {
} else {
generate(options)
}
function debounce<T extends any[]>(this: any, f: (...args: T) => void, timeout: number): (...args: T) => void {
function debounce<T extends any[]>(
this: any,
f: (...args: T) => void,
timeout: number
): (...args: T) => void {
let timer: ReturnType<typeof setTimeout>
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => { f.apply(this, args) }, timeout)
timer = setTimeout(() => {
f.apply(this, args)
}, timeout)
}
}
-4
View File
@@ -1,4 +0,0 @@
export type JsonSchemaSpec =
| "Draft_07"
| "Draft_2019_09"
| "Draft_2020_12"
+129 -63
View File
@@ -1,7 +1,19 @@
import { Dirent, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"
import { basename, dirname, extname, format, join, relative, sep } from "node:path"
import { Dirent, existsSync } from "node:fs"
import { mkdir, readdir, rm, writeFile } from "node:fs/promises"
import {
basename,
dirname,
extname,
format,
join,
relative,
sep,
} from "node:path"
import ts from "typescript"
import { RootNode, fileToAst } from "./parser/ast.js"
import { RootNode } from "./ast.js"
import { fileToAst } from "./parser/ast.js"
import { resolveTypeArgumentsForFile } from "./parser/resolvetypeargs.js"
import { tsextPattern } from "./utils/path.js"
export type MetaInformation = {
/**
@@ -35,6 +47,12 @@ export type Renderer = {
* The file extension the output files should use, e.g. `.md`.
*/
fileExtension: string
/**
* If `true`, the root node will have its type parameters resolved and thus
* removed.
*/
resolveTypeParameters?: boolean
}
/**
@@ -106,122 +124,170 @@ export type GeneratorOptions = {
*
* @param options - The generator options.
*/
export const generate = (options: GeneratorOptions): void => {
export const generate = async (options: GeneratorOptions): Promise<void> => {
const {
sourceDir,
outputs,
dumpAst = false,
clean = false,
fileNamePredicate,
verbose = false
verbose = false,
} = options
const flattenTypeScriptFileNamesFromDir = (dirPath: string): string[] => {
const flattenTypeScriptFileNamesFromDir = async (
dirPath: string
): Promise<string[]> => {
const dirEntryToFilePath = (dirEntry: Dirent) =>
join(dirPath, dirEntry.name).split(sep).join("/")
return readdirSync(dirPath, { withFileTypes: true })
.flatMap(dirEntry => {
if (dirEntry.isDirectory()) {
return flattenTypeScriptFileNamesFromDir(dirEntryToFilePath(dirEntry))
}
else if (dirEntry.isFile() && extname(dirEntry.name) === ".ts") {
return [dirEntryToFilePath(dirEntry)]
}
else {
return []
}
})
const entries = await readdir(dirPath, { withFileTypes: true })
return (
await Promise.all(
entries.map(async (dirEntry) => {
if (dirEntry.isDirectory()) {
return flattenTypeScriptFileNamesFromDir(
dirEntryToFilePath(dirEntry)
)
} else if (dirEntry.isFile() && extname(dirEntry.name) === ".ts") {
return [dirEntryToFilePath(dirEntry)]
} else {
return []
}
})
)
).flat()
}
const tsFiles = flattenTypeScriptFileNamesFromDir(sourceDir)
const tsFiles = await flattenTypeScriptFileNamesFromDir(sourceDir)
const program = ts.createProgram(tsFiles, { strict: true })
// KEEP ALWAYS, SIDE EFFECT: it fills the parent references of nodes
const checker = program.getTypeChecker()
outputs.forEach(({ targetDir, clean: cleanSingle }) => {
for (const { targetDir, clean: cleanSingle } of outputs) {
if ((cleanSingle ?? clean) && existsSync(targetDir)) {
rmSync(targetDir, { recursive: true })
await rm(targetDir, { recursive: true })
}
mkdirSync(targetDir, { recursive: true })
})
await mkdir(targetDir, { recursive: true })
}
const rootFiles = program
.getSourceFiles()
.filter(file => tsFiles.includes(file.fileName))
.filter((file) => tsFiles.includes(file.fileName))
const filteredFiles =
fileNamePredicate
? rootFiles.filter(file => fileNamePredicate(file.fileName))
const rootFilesAsts = Object.fromEntries(
rootFiles.map((file) => [file.fileName, fileToAst(file, checker, program)])
)
const filteredFiles = fileNamePredicate
? rootFiles.filter((file) => fileNamePredicate(file.fileName))
: rootFiles
console.log(`Generating files for ${filteredFiles.length} input file(s) and ${outputs.length} output format(s) ...`)
console.log(
`Generating files for ${filteredFiles.length} input file(s) and ${outputs.length} output format(s) ...`
)
filteredFiles.forEach(file => {
let outputFilesCount = 0
for (const file of filteredFiles) {
const relativePath = relative(sourceDir, file.fileName)
const dir = dirname(relativePath)
const name = basename(relativePath).replace(/(?:\.d)?\.ts$/, "")
const dir = dirname(relativePath)
const name = basename(relativePath).replace(tsextPattern, "")
try {
if (verbose) {
console.log(`Generating output for "${relativePath}" ...`)
}
const ast = fileToAst(file, checker, program)
const ast = rootFilesAsts[file.fileName]!
const resolvedAst = resolveTypeArgumentsForFile(rootFilesAsts, ast)
if (Object.keys(ast.elements).length > 0) {
if (Object.keys(ast.children).length > 0) {
if (dumpAst) {
writeFileSync(`${file.fileName}.ast.json`, JSON.stringify(ast, undefined, 2))
}
outputs.forEach(({ targetDir, renderer: { transformer, fileExtension } }) => {
const outputDir = join(targetDir, dir)
mkdirSync(outputDir, { recursive: true })
const outputAbsoluteFilePath = format({ dir: outputDir, name, ext: fileExtension })
const outputRelativeFilePath = relative(targetDir, outputAbsoluteFilePath)
const output = transformer(
ast,
{
absolutePath: outputAbsoluteFilePath,
relativePath: outputRelativeFilePath,
}
await writeFile(
`${file.fileName}.ast.json`,
JSON.stringify(ast, undefined, 2),
"utf-8"
)
writeFileSync(outputAbsoluteFilePath, output)
if (verbose) {
console.log(`-> ${outputAbsoluteFilePath}`)
if (resolvedAst) {
await writeFile(
`${file.fileName}.ast.resolved.json`,
JSON.stringify(ast, undefined, 2),
"utf-8"
)
}
})
}
else {
}
for (const { targetDir, renderer } of outputs) {
const {
transformer,
fileExtension,
resolveTypeParameters = false,
} = renderer
const outputDir = join(targetDir, dir)
const outputAbsoluteFilePath = format({
dir: outputDir,
name,
ext: fileExtension,
})
const outputRelativeFilePath = relative(
targetDir,
outputAbsoluteFilePath
)
const meta: MetaInformation = {
absolutePath: outputAbsoluteFilePath,
relativePath: outputRelativeFilePath,
}
const output = resolveTypeParameters
? resolvedAst !== undefined
? transformer(resolvedAst, meta)
: undefined
: transformer(ast, meta)
if (output === undefined) {
if (verbose) {
console.log(`-> empty output`)
}
} else {
await mkdir(outputDir, { recursive: true })
await writeFile(outputAbsoluteFilePath, output, "utf-8")
outputFilesCount++
if (verbose) {
console.log(`-> ${outputAbsoluteFilePath}`)
}
}
}
} else {
if (verbose) {
console.log(`file does not contain renderable content`)
} else {
console.log(`"${relativePath}" does not contain renderable content`)
}
}
} catch (error) {
if (error instanceof Error) {
error.message = `${error.message} in TS file "${file.fileName}"`
throw error
}
else {
} else {
throw error
}
}
})
}
if (verbose) {
console.log(`Generating ${filteredFiles.length * outputs.length} output file(s) finished successfully.`)
console.log(
`Generating ${outputFilesCount} output file(s) finished successfully.`
)
} else {
console.log(`Generated ${filteredFiles.length * outputs.length} output file(s).`)
console.log(`Generated ${outputFilesCount} output file(s).`)
}
}
+599 -705
View File
File diff suppressed because it is too large Load Diff
+3 -18
View File
@@ -1,21 +1,7 @@
import ts from "typescript"
import { Doc } from "../ast.js"
import { flattenComment } from "./doccomment.js"
import { DocTags, parseDocTags } from "./doctags.js"
/**
* The parsed JSDoc annotations for a node.
*/
export type Doc = {
/**
* The initial description text.
*/
comment?: string
/**
* A dictionary of supported tags (`@tag`) with parsed values, if present.
*/
tags: DocTags
}
import { parseDocTags } from "./doctags.js"
const isDocEmpty = (doc: Doc): boolean =>
doc.comment === undefined && Object.keys(doc.tags).length === 0
@@ -54,8 +40,7 @@ export const parseModuleDoc = (file: ts.SourceFile): Doc | undefined => {
if (firstNode) {
if (ts.isImportDeclaration(firstNode)) {
return parseNodeDoc(firstNode)
}
else {
} else {
const jsDocs = firstNode.getChildren().filter(ts.isJSDoc)
return jsDocs.length > 1 ? parseDoc(jsDocs[0]) : undefined
+3 -1
View File
@@ -6,5 +6,7 @@ type JSDocComments = ts.NodeArray<ts.JSDocComment>
/**
* Flattens a TypeScript comment into a simple string.
*/
export const flattenComment = (comment: string | JSDocComments | undefined): string | undefined =>
export const flattenComment = (
comment: string | JSDocComments | undefined
): string | undefined =>
ts.getTextOfJSDocComment(comment)?.replaceAll(EOL, "\n")
+32 -35
View File
@@ -1,10 +1,11 @@
import ts from "typescript"
import { DocTagTypes, DocTags } from "../ast.js"
import { flattenComment } from "./doccomment.js"
/**
* A dictionary from all supported tag names to their value types.
*/
const docTagTypes = {
const docTagTypes: DocTagTypes = {
// General
main: "string",
title: "string",
@@ -34,49 +35,45 @@ const docTagTypes = {
minItems: "integer",
maxItems: "integer",
uniqueItems: "boolean",
} as const
/**
* A dictionary from all supported tag names to their JSON Schema data types.
*/
export type DocTagTypes = typeof docTagTypes
/**
* A dictionary from all supported data types in JSON Schema to their
* corresponding TypeScript data types.
*/
type JSONSchemaTypeToTypeScriptType = {
number: number
integer: number
boolean: boolean
string: string
unknown: unknown
}
/**
* A dictionary from all supported tags to their values.
*/
export type DocTags = {
-readonly [K in keyof DocTagTypes]?: JSONSchemaTypeToTypeScriptType[DocTagTypes[K]]
}
const parseDocTagComment = <K extends keyof DocTags>(name: K, comment: string | undefined): DocTags[K] => {
const parseDocTagComment = <K extends keyof DocTags>(
name: K,
comment: string | undefined
): DocTags[K] => {
switch (docTagTypes[name]) {
case "boolean": return (comment === "true" || !comment) as DocTags[K]
case "number": return (comment === undefined ? 0 : Number.parseFloat(comment)) as DocTags[K]
case "integer": return (comment === undefined ? 0 : Number.parseInt(comment)) as DocTags[K]
case "unknown": return (comment === undefined ? undefined : JSON.parse(comment)) as DocTags[K]
default: return (comment ?? "") as DocTags[K]
case "boolean":
return (comment === "true" || !comment) as DocTags[K]
case "number":
return (
comment === undefined ? 0 : Number.parseFloat(comment)
) as DocTags[K]
case "integer":
return (
comment === undefined ? 0 : Number.parseInt(comment)
) as DocTags[K]
case "unknown":
return (
comment === undefined ? undefined : JSON.parse(comment)
) as DocTags[K]
default:
return (comment ?? "") as DocTags[K]
}
}
const parseDocTag = (tag: ts.JSDocTag): [keyof DocTags, DocTags[keyof DocTags]] => [
const parseDocTag = (
tag: ts.JSDocTag
): [keyof DocTags, DocTags[keyof DocTags]] => [
tag.tagName.text as keyof DocTags,
parseDocTagComment(tag.tagName.text as keyof DocTags, flattenComment(tag.comment))
parseDocTagComment(
tag.tagName.text as keyof DocTags,
flattenComment(tag.comment)
),
]
/**
* Parses all JSON Schema tags into their proper types.
*/
export const parseDocTags = (tags: ts.NodeArray<ts.JSDocTag> | undefined): DocTags =>
Object.fromEntries(tags?.map(parseDocTag) ?? [])
export const parseDocTags = (
tags: ts.NodeArray<ts.JSDocTag> | undefined
): DocTags => Object.fromEntries(tags?.map(parseDocTag) ?? [])
+483
View File
@@ -0,0 +1,483 @@
import {
ContentNode,
EnumerationNode,
ExportAssignmentNode,
NodeKind,
QualifiedName,
ReferenceNode,
RootNode,
StatementNode,
TypeDefinitionNode,
isExportAssignmentNode,
} from "../ast.js"
import { assertExhaustive } from "../utils/assertExhaustive.js"
import { isNotNullish } from "../utils/nullable.js"
enum ScopeTypeKind {
Default,
TypeArgument,
NamespaceImport,
}
type TypeInScope<Node> = {
node: Node
kind: ScopeTypeKind
}
type TypesInScope = {
[key: string]: TypeInScope<RootNode | StatementNode>
}
const resolveQualifiedName = (
name: QualifiedName,
node: RootNode | StatementNode
): EnumerationNode | TypeDefinitionNode | ExportAssignmentNode | undefined => {
switch (node.kind) {
case NodeKind.Root:
case NodeKind.Group: {
const childNode = node.children.find(
(child) => child.name === name.segment
)
switch (childNode?.kind) {
case NodeKind.Group:
return name.right === undefined
? undefined
: resolveQualifiedName(name.right, childNode)
case NodeKind.Enumeration:
return name.right === undefined ? childNode : undefined
case NodeKind.TypeDefinition:
return name.right === undefined ? childNode : undefined
case NodeKind.ExportAssignment:
return name.right === undefined ? childNode : undefined
case undefined:
return undefined
default:
return undefined
}
}
case NodeKind.Enumeration:
case NodeKind.TypeDefinition:
case NodeKind.ExportAssignment:
return undefined
default:
return assertExhaustive(node)
}
}
const resolveQualifiedNameInScope = (
name: QualifiedName,
typesInScope: TypesInScope
):
| TypeInScope<EnumerationNode | TypeDefinitionNode | ExportAssignmentNode>
| undefined => {
const type = typesInScope[name.segment]
if (name.right === undefined) {
if (type === undefined) {
return undefined
}
switch (type.node.kind) {
case NodeKind.Group:
return undefined
case NodeKind.Enumeration:
return { node: type.node, kind: type.kind }
case NodeKind.TypeDefinition:
return { node: type.node, kind: type.kind }
case NodeKind.ExportAssignment:
return { node: type.node, kind: type.kind }
case NodeKind.Root:
return undefined
default:
return assertExhaustive(type.node)
}
} else if (type !== undefined) {
const node = resolveQualifiedName(name.right, type.node)
if (node === undefined) {
return undefined
}
return { node, kind: type.kind }
} else {
return undefined
}
}
const mapTypeParametersToArgumentsInScope = (
node: TypeDefinitionNode,
typesInScope: TypesInScope,
file: RootNode,
files: Record<string, RootNode>,
reference?: ReferenceNode
) =>
Object.fromEntries(
node.typeParameters?.map(
(child, index): [string, TypeInScope<RootNode | StatementNode>] => {
const argument = reference?.typeArguments?.[index] ?? child.default
if (argument === undefined) {
throw new Error(
`Type argument ${index} is missing for type parameter "${child.name}" for type "${node.name}" and no default value is provided`
)
}
const resolvedArgument = resolveTypeArgumentsForNode(
argument,
typesInScope,
file,
files
)
if (resolvedArgument === undefined) {
throw new Error(
`Type argument ${index} could not be resolved for type parameter "${child.name}" for type "${node.name}"`
)
}
return [
child.name,
{
node: {
kind: NodeKind.TypeDefinition,
fileName: node.fileName,
name: child.name,
definition: resolvedArgument,
},
kind: ScopeTypeKind.TypeArgument,
},
]
}
) ?? []
)
const resolveTypeArgumentsForNode = <T extends ContentNode>(
node: T,
typesInScope: TypesInScope,
file: RootNode,
files: Record<string, RootNode>
): T | undefined => {
switch (node.kind) {
case NodeKind.Root: {
const children = node.children
.map((child) =>
resolveTypeArgumentsForNode(child, typesInScope, file, files)
)
.filter(isNotNullish)
if (children.length > 0) {
return {
...node,
children,
}
} else {
return undefined
}
}
case NodeKind.Group: {
const children = node.children
.map((child) =>
resolveTypeArgumentsForNode(child, typesInScope, file, files)
)
.filter(isNotNullish)
if (children.length === 0) {
return undefined
}
return {
...node,
children,
}
}
case NodeKind.Record: {
const children = Object.entries(node.children).map(([key, value]) => {
const child = resolveTypeArgumentsForNode(
value.value,
typesInScope,
file,
files
)
if (child === undefined) {
return undefined
}
return [key, { ...value, value: child }]
})
if (children.every(isNotNullish)) {
return {
...node,
children: Object.fromEntries(children),
}
}
return undefined
}
case NodeKind.Dictionary: {
const children = resolveTypeArgumentsForNode(
node.children,
typesInScope,
file,
files
)
if (children === undefined) {
return undefined
}
return {
...node,
children,
}
}
case NodeKind.Token:
return node
case NodeKind.Reference: {
const actualTypesInScope = {
...rootTypesInScope(files, files[node.fileName]!),
...Object.fromEntries(
Object.entries(typesInScope).filter(
([_key, value]) => value.kind === ScopeTypeKind.TypeArgument
)
),
}
const { node: referencedType, kind = ScopeTypeKind.Default } =
resolveQualifiedNameInScope(node.name, actualTypesInScope) ?? {}
if (referencedType === undefined) {
return undefined
}
const nodeWithTargetFileName: ReferenceNode = {
...node,
resolvedFileName: referencedType.fileName,
}
if (
referencedType.kind !== NodeKind.TypeDefinition ||
node.typeArguments === undefined
) {
if (kind === ScopeTypeKind.TypeArgument) {
switch (referencedType.kind) {
case NodeKind.Enumeration:
return nodeWithTargetFileName as T
case NodeKind.TypeDefinition:
return referencedType.definition as T
case NodeKind.ExportAssignment:
return nodeWithTargetFileName as T
default:
return nodeWithTargetFileName as T
}
} else {
return nodeWithTargetFileName as T
}
}
const newTypeArguments = mapTypeParametersToArgumentsInScope(
referencedType,
typesInScope,
file,
files,
node
)
return resolveTypeArgumentsForNode(
referencedType.definition,
{
...typesInScope,
...newTypeArguments,
},
file,
files
) as T | undefined
}
case NodeKind.Enumeration:
return node
case NodeKind.Array: {
const children = resolveTypeArgumentsForNode(
node.children,
typesInScope,
file,
files
)
if (children === undefined) {
return undefined
}
return {
...node,
children,
}
}
case NodeKind.Union: {
const children = node.children.map((child) =>
resolveTypeArgumentsForNode(child, typesInScope, file, files)
)
if (children.every(isNotNullish)) {
return {
...node,
children,
}
}
return undefined
}
case NodeKind.Intersection: {
const children = node.children.map((child) =>
resolveTypeArgumentsForNode(child, typesInScope, file, files)
)
if (children.every(isNotNullish)) {
return {
...node,
children,
}
}
return undefined
}
case NodeKind.Literal:
return node
case NodeKind.Tuple: {
const children = node.children.map((child) =>
resolveTypeArgumentsForNode(child, typesInScope, file, files)
)
if (children.every(isNotNullish)) {
return {
...node,
children,
}
}
return undefined
}
case NodeKind.TypeDefinition: {
if (
(node.typeParameters?.filter((p) => p.default === undefined).length ??
0) > 0
) {
return undefined
}
const newTypeArguments = mapTypeParametersToArgumentsInScope(
node,
typesInScope,
file,
files
)
const newTypesInScope = Object.entries(newTypeArguments).reduce(
(acc, [key, value]) => {
if (
acc[key] === undefined ||
acc[key]!.kind !== ScopeTypeKind.TypeArgument
) {
acc[key] = value
}
return acc
},
{ ...typesInScope }
)
const definition = resolveTypeArgumentsForNode(
node.definition,
newTypesInScope,
file,
files
)
if (definition === undefined) {
return undefined
}
return {
...node,
definition,
}
}
case NodeKind.ExportAssignment:
return undefined
default:
return assertExhaustive(node)
}
}
const rootTypesInScope = (
files: Record<string, RootNode>,
file: RootNode
): TypesInScope => {
const importedTypesInScope =
file.imports?.reduce<TypesInScope>((acc, importNode) => {
const importedFile = files[importNode.fileName]
switch (importNode.kind) {
case NodeKind.DefaultImport: {
const defaultImport = importedFile?.children.find(
isExportAssignmentNode
)
if (defaultImport) {
acc[importNode.name] = {
node: defaultImport,
kind: ScopeTypeKind.Default,
}
}
break
}
case NodeKind.NamedImport: {
const namedImport = importedFile?.children.find(
(statement) => statement.name === importNode.name
)
if (namedImport) {
acc[importNode.alias ?? importNode.name] = {
node: namedImport,
kind: ScopeTypeKind.Default,
}
}
break
}
case NodeKind.NamespaceImport: {
if (importedFile) {
acc[importNode.name] = {
node: importedFile,
kind: ScopeTypeKind.NamespaceImport,
}
}
break
}
default:
return assertExhaustive(importNode)
}
return acc
}, {}) ?? {}
const localTypesInScope = file.children.reduce<TypesInScope>((acc, child) => {
switch (child.kind) {
case NodeKind.Enumeration:
acc[child.name] = { node: child, kind: ScopeTypeKind.Default }
break
case NodeKind.TypeDefinition:
acc[child.name] = { node: child, kind: ScopeTypeKind.Default }
break
case NodeKind.ExportAssignment:
acc[child.name] = { node: child, kind: ScopeTypeKind.Default }
break
case NodeKind.Group:
acc[child.name] = { node: child, kind: ScopeTypeKind.Default }
break
default:
return assertExhaustive(child)
}
return acc
}, {})
return { ...importedTypesInScope, ...localTypesInScope }
}
export const resolveTypeArgumentsForFile = (
files: Record<string, RootNode>,
file: RootNode
): RootNode | undefined =>
resolveTypeArgumentsForNode(file, rootTypesInScope(files, file), file, files)
+264 -130
View File
@@ -1,10 +1,20 @@
import { EOL } from "node:os"
import { sep } from "node:path"
import { JsonSchemaSpec } from "../config.js"
import { sep } from "node:path/posix"
import {
ChildNode,
Doc,
DocTagTypes,
NodeKind,
RootNode,
StatementNode,
TokenKind,
} from "../ast.js"
import { AstTransformer, Renderer } from "../main.js"
import { ChildNode, NodeKind, TokenKind, parentGroupToArray } from "../parser/ast.js"
import { Doc } from "../parser/doc.js"
import { DocTagTypes } from "../parser/doctags.js"
import { assertExhaustive } from "../utils/assertExhaustive.js"
import {
getFullyQualifiedNameAsPath,
getRelativeExternalPath,
} from "../utils/references.js"
/**
* Descriptive annotations of the JSON type definition
@@ -30,14 +40,17 @@ interface StrictObject extends ObjectBase {
[key: string]: Definition
}
required: string[]
additionalProperties: false
additionalProperties?: boolean
}
const isStrictObject = (def: Definition): def is StrictObject =>
typeof def === "object" && "properties" in def
interface PatternDictionary extends ObjectBase {
patternProperties: {
[pattern: string]: Definition
}
additionalProperties: false
additionalProperties?: boolean
}
interface Dictionary extends ObjectBase {
@@ -62,7 +75,7 @@ interface Tuple07 extends Annotated {
items: Definition[]
minItems: number
maxItems: number
additionalItems: false
additionalItems: boolean
}
interface Tuple202012 extends Annotated {
@@ -104,6 +117,11 @@ interface Union extends Annotated {
oneOf: Definition[]
}
interface Intersection extends Annotated {
allOf: Definition[]
unresolvedProperties?: boolean
}
interface Constant extends Annotated {
const: string | number | boolean
}
@@ -116,8 +134,11 @@ interface Reference extends Annotated {
$ref: string
}
const isReference = (def: Definition): def is Reference =>
typeof def === "object" && "$ref" in def
interface Group {
"guard Group": any
_groupBrand: any
[identifier: string]: Definition
}
@@ -131,43 +152,29 @@ type Definition =
| Boolean
| Reference
| Union
| Intersection
| Constant
| Enum
| Tuple
| Group
export interface JsonSchema_07 extends Annotated {
$schema: string
$id: string
$ref?: string
definitions: {
[id: string]: Definition
}
}
export interface JsonSchema_2019_09 extends Annotated {
$schema: string
$id: string
$ref?: string
$defs: {
[id: string]: Definition
}
}
const toAnnotations = (jsDoc: Doc | undefined) => ({
title: jsDoc?.tags.title,
description: jsDoc?.comment,
})
const toDefault = (jsDoc: Doc | undefined) => jsDoc?.tags.default !== undefined ? {
default: jsDoc?.tags.default,
} : undefined
const toDefault = (jsDoc: Doc | undefined) =>
jsDoc?.tags.default !== undefined
? {
default: jsDoc?.tags.default,
}
: undefined
type ConstraintsByType = {
number: NumberConstraints,
string: StringConstraints,
object: ObjectConstraints,
array: ArrayConstraints,
number: NumberConstraints
string: StringConstraints
object: ObjectConstraints
array: ArrayConstraints
}
type IgnoreValue<T> = { [K in keyof T]-?: 0 }
@@ -176,31 +183,46 @@ type IgnoreValueEach<T> = { [K in keyof T]: IgnoreValue<T[K]> }
// ensures that each key is present in a runtime object
const constraintsByType: IgnoreValueEach<ConstraintsByType> = {
number: { maximum: 0, minimum: 0, exclusiveMinimum: 0, exclusiveMaximum: 0, multipleOf: 0 },
number: {
maximum: 0,
minimum: 0,
exclusiveMinimum: 0,
exclusiveMaximum: 0,
multipleOf: 0,
},
string: { minLength: 0, maxLength: 0, format: 0, pattern: 0 },
object: { minProperties: 0, maxProperties: 0 },
array: { minItems: 0, maxItems: 0, uniqueItems: 0 },
}
const toConstraints = <T extends keyof ConstraintsByType>(jsDoc: Doc | undefined, type: T): ConstraintsByType[T] =>
const toConstraints = <T extends keyof ConstraintsByType>(
jsDoc: Doc | undefined,
type: T
): ConstraintsByType[T] =>
Object.fromEntries(
jsDoc
? (Object.keys(constraintsByType[type]) as (keyof ConstraintsByType[T])[])
.flatMap(
(key) => {
if (jsDoc.tags[key as keyof DocTagTypes] !== undefined) {
return [[key, jsDoc.tags[key as keyof DocTagTypes]]]
}
else {
return []
}
? (
Object.keys(constraintsByType[type]) as (keyof ConstraintsByType[T])[]
).flatMap((key) => {
if (jsDoc.tags[key as keyof DocTagTypes] !== undefined) {
return [[key, jsDoc.tags[key as keyof DocTagTypes]]]
} else {
return []
}
)
})
: []
)
const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isReadOnly?: boolean } = {}): Definition => {
const { isReadOnly } = options
const nodeToDefinition = (
node: ChildNode,
file: RootNode,
options: Required<JsonSchemaRendererOptions>,
shallowOptions: {
isReadOnly?: boolean
} = {}
): Definition => {
const { spec, allowAdditionalProperties } = options
const { isReadOnly } = shallowOptions
switch (node.kind) {
case NodeKind.Record: {
@@ -209,14 +231,19 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
type: "object",
...toDefault(node.jsDoc),
properties: Object.fromEntries(
Object.entries(node.elements)
.map(([key, config]) => [key, nodeToDefinition(spec, config.value, { isReadOnly: config.isReadOnly })])),
required: Object.entries(node.elements)
Object.entries(node.children).map(([key, config]) => [
key,
nodeToDefinition(config.value, file, options, {
isReadOnly: config.isReadOnly,
}),
])
),
required: Object.entries(node.children)
.filter(([_, config]) => config.isRequired)
.map(([key]) => key),
...toConstraints(node.jsDoc, "object"),
...(isReadOnly ? { readOnly: false } : {}),
additionalProperties: false
...(isReadOnly ? { readOnly: true } : undefined),
additionalProperties: allowAdditionalProperties,
}
}
case NodeKind.Dictionary: {
@@ -226,21 +253,20 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
type: "object",
...toDefault(node.jsDoc),
patternProperties: {
[node.pattern]: nodeToDefinition(spec, node.elements)
[node.pattern]: nodeToDefinition(node.children, file, options),
},
...toConstraints(node.jsDoc, "object"),
...(isReadOnly ? { readOnly: false } : {}),
additionalProperties: false
...(isReadOnly ? { readOnly: true } : undefined),
additionalProperties: allowAdditionalProperties,
}
}
else {
} else {
return {
...toAnnotations(node.jsDoc),
type: "object",
...toDefault(node.jsDoc),
additionalProperties: nodeToDefinition(spec, node.elements),
additionalProperties: nodeToDefinition(node.children, file, options),
...toConstraints(node.jsDoc, "object"),
...(isReadOnly ? { readOnly: false } : {}),
...(isReadOnly ? { readOnly: true } : undefined),
}
}
}
@@ -249,76 +275,108 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
...toAnnotations(node.jsDoc),
type: "array",
...toDefault(node.jsDoc),
items: nodeToDefinition(spec, node.elements),
items: nodeToDefinition(node.children, file, options),
...toConstraints(node.jsDoc, "array"),
...(isReadOnly ? { readOnly: false } : {}),
}
}
case NodeKind.Enumeration: {
return {
...toAnnotations(node.jsDoc),
enum: node.cases.map(({ value }) => value),
...toDefault(node.jsDoc),
...(isReadOnly ? { readOnly: false } : {}),
...(isReadOnly ? { readOnly: true } : undefined),
}
}
case NodeKind.Tuple: {
switch (spec) {
case "Draft_07":
case "Draft_2019_09": return {
...toAnnotations(node.jsDoc),
type: "array",
items: node.elements.map(element => nodeToDefinition(spec, element)),
...toDefault(node.jsDoc),
minItems: node.elements.length,
maxItems: node.elements.length,
additionalItems: false,
...(isReadOnly ? { readOnly: false } : {}),
}
case "Draft_2020_12": return {
...toAnnotations(node.jsDoc),
type: "array",
prefixItems: node.elements.map(element => nodeToDefinition(spec, element)),
...toDefault(node.jsDoc),
minItems: node.elements.length,
maxItems: node.elements.length,
items: false,
...(isReadOnly ? { readOnly: false } : {}),
}
default: throw TypeError("invalid spec")
case JsonSchemaSpec.Draft_07:
case JsonSchemaSpec.Draft_2019_09:
return {
...toAnnotations(node.jsDoc),
type: "array",
items: node.children.map((child) =>
nodeToDefinition(child, file, options)
),
...toDefault(node.jsDoc),
minItems: node.children.length,
maxItems: node.children.length,
additionalItems: false,
...(isReadOnly ? { readOnly: true } : undefined),
}
case JsonSchemaSpec.Draft_2020_12:
return {
...toAnnotations(node.jsDoc),
type: "array",
prefixItems: node.children.map((child) =>
nodeToDefinition(child, file, options)
),
...toDefault(node.jsDoc),
minItems: node.children.length,
maxItems: node.children.length,
items: false,
...(isReadOnly ? { readOnly: true } : undefined),
}
default:
return assertExhaustive(spec, "invalid spec")
}
}
case NodeKind.Union: {
return {
...toAnnotations(node.jsDoc),
oneOf: node.cases.map(element => nodeToDefinition(spec, element)),
oneOf: node.children.map((child) =>
nodeToDefinition(child, file, options)
),
...toDefault(node.jsDoc),
...(isReadOnly ? { readOnly: false } : {}),
...(isReadOnly ? { readOnly: true } : undefined),
}
}
case NodeKind.Group: {
return Object.fromEntries(
Object.entries(node.elements)
.map(([key, node]) => [key, nodeToDefinition(spec, node)])
) as Group
case NodeKind.Intersection: {
const allOf = node.children.map((child) =>
nodeToDefinition(child, file, options)
)
const base = {
...toAnnotations(node.jsDoc),
allOf,
...toDefault(node.jsDoc),
...(isReadOnly ? { readOnly: true } : undefined),
}
if (allOf.every((e) => isStrictObject(e) || isReference(e))) {
if (isUnresolvedPropertiesSupported(spec)) {
allOf.forEach(
(e) =>
"additionalProperties" in e &&
typeof e.additionalProperties === "boolean" &&
delete e.additionalProperties
)
return {
...base,
unresolvedProperties: allowAdditionalProperties,
}
} else {
console.warn(
'The requested JSON Schema spec does not support intersecting record types with "additionalProperties" set to false, which will likely result in unexpected validation errors. Consider switching to a newer JSON Schema spec or do not use intersection types.'
)
}
}
return base
}
case NodeKind.Literal: {
return {
...toAnnotations(node.jsDoc),
const: node.value,
...toDefault(node.jsDoc),
...(isReadOnly ? { readOnly: false } : {}),
...(isReadOnly ? { readOnly: true } : undefined),
}
}
case NodeKind.Reference: {
const externalFilePath = node.externalFilePath ? `${node.externalFilePath}.schema.json` : ""
const qualifiedName = [...parentGroupToArray(node.parentGroup), node.name].join("/")
const externalFilePath = getRelativeExternalPath(
node,
file,
".schema.json"
)
const qualifiedName = getFullyQualifiedNameAsPath(node, file)
return {
...toAnnotations(node.jsDoc),
$ref: `${externalFilePath}#/${defsKey(spec)}/${qualifiedName}`,
...toDefault(node.jsDoc),
...(isReadOnly ? { readOnly: false } : {}),
...(isReadOnly ? { readOnly: true } : undefined),
}
}
case NodeKind.Token: {
@@ -329,7 +387,7 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
type: node.jsDoc?.tags.integer ? "integer" : "number",
...toDefault(node.jsDoc),
...toConstraints(node.jsDoc, "number"),
...(isReadOnly ? { readOnly: false } : {}),
...(isReadOnly ? { readOnly: true } : undefined),
}
}
@@ -339,7 +397,7 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
type: "string",
...toDefault(node.jsDoc),
...toConstraints(node.jsDoc, "string"),
...(isReadOnly ? { readOnly: false } : {}),
...(isReadOnly ? { readOnly: true } : undefined),
}
}
@@ -348,22 +406,59 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
...toAnnotations(node.jsDoc),
type: "boolean",
...toDefault(node.jsDoc),
...(isReadOnly ? { readOnly: false } : {}),
...(isReadOnly ? { readOnly: true } : undefined),
}
}
}
}
default:
return assertExhaustive(node)
}
}
const toForwardSlashAbsolutePath = (path: string) => "/" + path.split(sep).join("/")
const statementToDefinition = (
node: StatementNode,
file: RootNode,
options: Required<JsonSchemaRendererOptions>,
shallowOptions: { isReadOnly?: boolean } = {}
): Definition => {
const { isReadOnly } = shallowOptions
type TransformerOptions = {
spec: JsonSchemaSpec
switch (node.kind) {
case NodeKind.TypeDefinition: {
return nodeToDefinition(node.definition, file, options)
}
case NodeKind.ExportAssignment: {
return nodeToDefinition(node.expression, file, options)
}
case NodeKind.Enumeration: {
return {
...toAnnotations(node.jsDoc),
enum: node.children.map(({ value }) => value),
...toDefault(node.jsDoc),
...(isReadOnly ? { readOnly: true } : undefined),
}
}
case NodeKind.Group: {
return Object.fromEntries(
Object.entries(node.children).map(([key, node]) => [
key,
statementToDefinition(node, file, options),
])
) as Group
}
default:
return assertExhaustive(node, "invalid statement")
}
}
const astToJsonSchema = ({ spec }: TransformerOptions): AstTransformer =>
const toForwardSlashAbsolutePath = (path: string) =>
"/" + path.split(sep).join("/")
const astToJsonSchema =
(options: Required<JsonSchemaRendererOptions>): AstTransformer =>
(file, { relativePath }): string => {
const { spec } = options
const mainType = file.jsDoc?.tags.main
const jsonSchema = {
@@ -371,43 +466,82 @@ const astToJsonSchema = ({ spec }: TransformerOptions): AstTransformer =>
$id: toForwardSlashAbsolutePath(relativePath),
$ref: mainType ? `#/${defsKey(spec)}/${mainType}` : mainType,
[defsKey(spec)]: Object.fromEntries(
Object.entries(file.elements)
.map(([key, node]) => [key, nodeToDefinition(spec, node)])
)
file.children.map((node) => [
node.name,
statementToDefinition(node, file, options),
])
),
}
return `${JSON.stringify(jsonSchema, undefined, 2).replace(/\n/g, EOL)}${EOL}`
return `${JSON.stringify(jsonSchema, undefined, 2).replace(
/\n/g,
EOL
)}${EOL}`
}
const defsKey = (spec: JsonSchemaSpec): string => {
const defsKey = (spec: JsonSchemaSpec) => {
switch (spec) {
case "Draft_07": return "definitions"
case "Draft_2019_09":
case "Draft_2020_12": return "$defs"
default: throw TypeError("invalid spec")
case JsonSchemaSpec.Draft_07:
return "definitions"
case JsonSchemaSpec.Draft_2019_09:
case JsonSchemaSpec.Draft_2020_12:
return "$defs"
default:
return assertExhaustive(spec, "invalid spec")
}
}
const schemaUri = (spec: JsonSchemaSpec): string => {
switch (spec) {
case "Draft_07": return "https://json-schema.org/draft-07/schema"
case "Draft_2019_09": return "https://json-schema.org/draft/2019-09/schema"
case "Draft_2020_12": return "https://json-schema.org/draft/2020-12/schema"
default: throw TypeError("invalid spec")
case JsonSchemaSpec.Draft_07:
return "https://json-schema.org/draft-07/schema"
case JsonSchemaSpec.Draft_2019_09:
return "https://json-schema.org/draft/2019-09/schema"
case JsonSchemaSpec.Draft_2020_12:
return "https://json-schema.org/draft/2020-12/schema"
default:
return assertExhaustive(spec, "invalid spec")
}
}
type RendererOptions = {
const isUnresolvedPropertiesSupported = (spec: JsonSchemaSpec): boolean => {
switch (spec) {
case JsonSchemaSpec.Draft_07:
return false
case JsonSchemaSpec.Draft_2019_09:
case JsonSchemaSpec.Draft_2020_12:
return true
default:
return assertExhaustive(spec, "invalid spec")
}
}
export enum JsonSchemaSpec {
Draft_07 = "Draft_07",
Draft_2019_09 = "Draft_2019_09",
Draft_2020_12 = "Draft_2020_12",
}
export type JsonSchemaRendererOptions = {
/**
* The used JSON Schema specification.
* @default "Draft_2020_12"
* @default JsonSchemaSpec.Draft_2020_12
*/
spec?: JsonSchemaSpec
/**
* Whether to allow unresolved additional keys in object definitions.
* @default false
*/
allowAdditionalProperties?: boolean
}
export const jsonSchemaRenderer = ({
spec = "Draft_2020_12"
}: RendererOptions = {}): Renderer => Object.freeze({
transformer: astToJsonSchema({ spec }),
fileExtension: ".schema.json",
})
spec = JsonSchemaSpec.Draft_2020_12,
allowAdditionalProperties = false,
}: JsonSchemaRendererOptions = {}): Renderer =>
Object.freeze({
transformer: astToJsonSchema({ spec, allowAdditionalProperties }),
fileExtension: ".schema.json",
resolveTypeParameters: true,
})
+418 -191
View File
@@ -1,10 +1,41 @@
import { EOL } from "node:os"
import {
ArrayNode,
ChildNode,
DictionaryNode,
Doc,
EnumerationNode,
LiteralNode,
NodeKind,
RecordNode,
ReferenceNode,
RootNode,
StatementNode,
TokenKind,
TokenNode,
TupleNode,
TypeParameterNode,
UnionNode,
isReferenceNode,
isTokenNode,
} from "../ast.js"
import { AstTransformer, Renderer } from "../main.js"
import { ArrayNode, ChildNode, DictionaryNode, EnumerationNode, LiteralNode, NodeKind, RecordNode, ReferenceNode, RootNode, TokenKind, TokenNode, TupleNode, UnionNode, parentGroupToArray } from "../parser/ast.js"
import { Doc } from "../parser/doc.js"
import { assertExhaustive } from "../utils/assertExhaustive.js"
import { isNotNullish } from "../utils/nullable.js"
import {
getRightmostQualifiedNameSegment,
qualifiedNameToArray,
} from "../utils/qualifiedName.js"
import {
getFullyQualifiedNameAsPath,
getRelativeExternalPath,
} from "../utils/references.js"
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max)
const h = (level: number, text: string, anchor?: string) => {
const safeLevel = level < 1 ? 1 : level > 6 ? 6 : level
const safeLevel = clamp(level, 1, 6)
const anchorElement = anchor === undefined ? "" : ` <a name="${anchor}"></a>`
return `${"#".repeat(safeLevel)}${anchorElement} ${text}`
@@ -12,29 +43,85 @@ const h = (level: number, text: string, anchor?: string) => {
const a = (text: string, href: string) => `<a href="${href}">${text}</a>`
const namedLink = (anchorName: string, fileUrl = "") => `${fileUrl}#${anchorName}`
const anchorUrl = (anchorName: string, fileUrl = "") =>
`${fileUrl}#${anchorName}`
const icode = (code: string | number | boolean) => `\`${code}\``
const icodejson = (code: unknown | string | number | boolean) => `\`${JSON.stringify(code)}\``
const icodejson = (code: unknown | string | number | boolean) =>
`\`${JSON.stringify(code)}\``
const boolean = (boolean: boolean) => boolean ? "Yes" : "No"
const boolean = (boolean: boolean) => (boolean ? "Yes" : "No")
const docHeader = (schema: RootNode, jsDoc: Doc | undefined) => {
const title = jsDoc?.tags.title ?? schema.jsDoc?.tags.title ?? "[TITLE MISSING]"
const title =
jsDoc?.tags.title ?? schema.jsDoc?.tags.title ?? "[TITLE MISSING]"
const description = jsDoc?.comment ?? schema.jsDoc?.comment
return headerWithDescription(h(1, title), description)
}
const definitionHeader = (id: string, jsDoc: Doc | undefined) => {
const definitionHeader = (
id: string,
jsDoc: Doc | undefined,
typeParameters?: TypeParameterNode[]
) => {
const fullName = typeParameters
? `${id}<${typeParameters
.map((param) => {
const constraint =
param.constraint?.kind === NodeKind.Token
? param.constraint.token === TokenKind.Number
? "Number"
: param.constraint.token === TokenKind.String
? "String"
: param.constraint.token === TokenKind.Boolean
? "Boolean"
: "..."
: param.constraint?.kind === NodeKind.Reference
? qualifiedNameToArray(param.constraint.name).join("/")
: param.constraint !== undefined
? "..."
: undefined
const defaultValue =
param.default?.kind === NodeKind.Token
? param.default.token === TokenKind.Number
? "Number"
: param.default.token === TokenKind.String
? "String"
: param.default.token === TokenKind.Boolean
? "Boolean"
: "..."
: param.default?.kind === NodeKind.Reference
? qualifiedNameToArray(param.default.name).join("/")
: param.default !== undefined
? "..."
: undefined
return `${param.name}${
constraint !== undefined ? ` extends ${constraint}` : ""
}${defaultValue !== undefined ? ` = ${defaultValue}` : ""}`
})
.join(", ")}>`
: id
return headerWithDescription(
h(3, jsDoc?.tags.title ? `${jsDoc?.tags.title} (\`${id}\`)` : `\`${id}\``, id),
h(
3,
jsDoc?.tags.title
? `${jsDoc?.tags.title} (\`${fullName}\`)`
: `\`${fullName}\``,
id
),
jsDoc?.comment
)
}
const headerWithDescription = (title: string, description: string | undefined) => {
const headerWithDescription = (
title: string,
description: string | undefined
) => {
if (description === undefined) {
return title
}
@@ -50,51 +137,73 @@ namespace LabelledList {
const linemd = (label: string, value: any, indent = 0) =>
`${" ".repeat(indent)}- **${label}:** ${value}`
const isNonNullable = <T>(x: T): x is NonNullable<T> => x != null
export const line = <T>(
label: string,
value: T | undefined,
transform?: (value: NonNullable<T>) => string | number | boolean,
config: Config = {}
) =>
isNonNullable (value)
? transform
? linemd(label, transform(value), config.indent)
: linemd(label, value, config.indent)
: undefined
isNotNullish(value)
? transform
? linemd(label, transform(value), config.indent)
: linemd(label, value, config.indent)
: undefined
export const create = (items: (string | undefined)[]) =>
items.filter(item => item !== undefined).join(EOL)
items.filter((item) => item !== undefined).join(EOL)
}
type SimpleNode =
| TokenNode
| LiteralNode
| ReferenceNode
| EnumerationNode
type SimpleNode = TokenNode | LiteralNode | ReferenceNode | EnumerationNode
const simpleBody = (node: SimpleNode): string => {
const simpleBody = (node: SimpleNode, file: RootNode): string => {
switch (node.kind) {
case NodeKind.Token: {
switch (node.token) {
case TokenKind.Number: {
return LabelledList.create([
LabelledList.line("Type", node.jsDoc?.tags.integer ?? false, value => value ? "Integer" : "Number"),
LabelledList.line(
"Type",
node.jsDoc?.tags.integer ?? false,
(value) => (value ? "Integer" : "Number")
),
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
LabelledList.line("Minimum", node.jsDoc?.tags.minimum, icode),
LabelledList.line("Exclusive Minimum", node.jsDoc?.tags.exclusiveMinimum, icode),
LabelledList.line(
"Exclusive Minimum",
node.jsDoc?.tags.exclusiveMinimum,
icode
),
LabelledList.line("Maximum", node.jsDoc?.tags.maximum, icode),
LabelledList.line("Exclusive Maximum", node.jsDoc?.tags.exclusiveMaximum, icode),
LabelledList.line("Multiple of", node.jsDoc?.tags.multipleOf, icode),
LabelledList.line(
"Exclusive Maximum",
node.jsDoc?.tags.exclusiveMaximum,
icode
),
LabelledList.line(
"Multiple of",
node.jsDoc?.tags.multipleOf,
icode
),
])
}
case TokenKind.String: {
return LabelledList.create([
LabelledList.line("Type", node.jsDoc?.tags.markdown ?? false, value => value ? "Markdown-formatted text" : "String"),
LabelledList.line(
"Type",
node.jsDoc?.tags.markdown ?? false,
(value) => (value ? "Markdown-formatted text" : "String")
),
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
LabelledList.line("Minimum Length", node.jsDoc?.tags.minLength, icode),
LabelledList.line("Maximum Length", node.jsDoc?.tags.maxLength, icode),
LabelledList.line(
"Minimum Length",
node.jsDoc?.tags.minLength,
icode
),
LabelledList.line(
"Maximum Length",
node.jsDoc?.tags.maxLength,
icode
),
LabelledList.line("Format", node.jsDoc?.tags.format, icode),
LabelledList.line("Pattern", node.jsDoc?.tags.pattern, icode),
])
@@ -105,6 +214,8 @@ const simpleBody = (node: SimpleNode): string => {
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
])
}
default:
return assertExhaustive(node.token)
}
}
case NodeKind.Literal: {
@@ -114,32 +225,71 @@ const simpleBody = (node: SimpleNode): string => {
}
case NodeKind.Reference: {
return LabelledList.create([
LabelledList.line(
"Type",
node,
({ name, parentGroup, externalFilePath }) => {
const fullQualifiedName = [...parentGroupToArray(parentGroup), name].join("/")
LabelledList.line("Type", node, () => {
const externalFilePath = getRelativeExternalPath(node, file, ".md")
const fullQualifiedName = getFullyQualifiedNameAsPath(node, file)
return a(
fullQualifiedName,
`${externalFilePath ? `${externalFilePath}.md` : ""}#${fullQualifiedName}`
)
const mainType = a(
fullQualifiedName,
`${externalFilePath ?? ""}#${fullQualifiedName}`
)
if (node.typeArguments) {
const parameters = node.typeArguments
.map((arg) => {
if (isReferenceNode(arg)) {
const externalFilePath = getRelativeExternalPath(
arg,
file,
".md"
)
const fullQualifiedName = getFullyQualifiedNameAsPath(
arg,
file
)
return a(
fullQualifiedName,
`${externalFilePath ?? ""}#${fullQualifiedName}`
)
} else if (isTokenNode(arg)) {
switch (arg.token) {
case TokenKind.Boolean:
return "Boolean"
case TokenKind.Number:
return "Number"
case TokenKind.String:
return "String"
default:
return assertExhaustive(arg.token)
}
} else {
return "..."
}
})
.join(", ")
return `${mainType}&lt;${parameters}&gt;`
}
),
return mainType
}),
])
}
case NodeKind.Enumeration: {
return LabelledList.create([
LabelledList.line(
"Possible values",
node.cases,
cases => cases
.map(value => value.value)
LabelledList.line("Possible values", node.children, (cases) =>
cases
.map((value) => value.value)
.map(icodejson)
.join(", ")
),
])
}
default:
return assertExhaustive(node)
}
}
@@ -148,12 +298,15 @@ type SectionNode = {
append: SectionNode[]
}
const mergeParagraphs = ({ inline, append }: SectionNode): string[] =>
[ ...inline, ...append.flatMap(mergeParagraphs) ]
const mergeParagraphs = ({ inline, append }: SectionNode): string[] => [
...inline,
...append.flatMap(mergeParagraphs),
]
const arrayBody = (
node: ArrayNode,
propertyPath: string
propertyPath: string,
file: RootNode
): SectionNode => {
const itemsPropertyPath = `${propertyPath}[]`
@@ -161,22 +314,27 @@ const arrayBody = (
inline: [
LabelledList.create([
LabelledList.line("Type", "List"),
LabelledList.line("Items", itemsPropertyPath, anchor => a(anchor, namedLink(anchor))),
LabelledList.line("Items", itemsPropertyPath, (anchor) =>
a(anchor, anchorUrl(anchor))
),
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
LabelledList.line("Minimum Items", node.jsDoc?.tags.minItems, icode),
LabelledList.line("Maximum Items", node.jsDoc?.tags.maxItems, icode),
LabelledList.line("Unique Items", node.jsDoc?.tags.uniqueItems, boolean),
])
LabelledList.line(
"Unique Items",
node.jsDoc?.tags.uniqueItems,
boolean
),
]),
],
append: [
definitionToMarkdown(itemsPropertyPath, node.elements),
]
append: [definitionToMarkdown(itemsPropertyPath, node.children, file)],
}
}
const tupleBody = (
node: TupleNode,
propertyPath: string
propertyPath: string,
file: RootNode
): SectionNode => {
const indexedPropertyPath = (index: number) => `${propertyPath}[${index}]`
@@ -186,32 +344,32 @@ const tupleBody = (
LabelledList.line("Type", "Tuple"),
LabelledList.line(
"Items",
node.elements,
childNodes => `[${
childNodes
node.children,
(childNodes) =>
`[${childNodes
.map((_, index) =>
a(indexedPropertyPath(index), indexedPropertyPath(index)))
.join(", ")
}]`
a(indexedPropertyPath(index), indexedPropertyPath(index))
)
.join(", ")}]`
),
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
]),
],
append: node.elements.map(
(childNode, index) =>
definitionToMarkdown(indexedPropertyPath(index), childNode)
)
append: node.children.map((childNode, index) =>
definitionToMarkdown(indexedPropertyPath(index), childNode, file)
),
}
}
const unionBody = (
node: UnionNode,
propertyPath: string
propertyPath: string,
file: RootNode
): SectionNode => {
const id = (childNode: ChildNode, index: number) => {
switch (childNode.kind) {
case NodeKind.Record: {
const tagProperty = childNode.elements["tag"]
const tagProperty = childNode.children["tag"]
return tagProperty && tagProperty.value.kind === NodeKind.Literal
? tagProperty.value.value.toString()
@@ -219,12 +377,22 @@ const unionBody = (
}
case NodeKind.Reference: {
return childNode.name
return getRightmostQualifiedNameSegment(childNode.name)
}
default: {
case NodeKind.Record:
case NodeKind.Dictionary:
case NodeKind.Token:
case NodeKind.Reference:
case NodeKind.Array:
case NodeKind.Union:
case NodeKind.Literal:
case NodeKind.Tuple:
case NodeKind.Intersection: {
return index.toFixed(0)
}
default:
return assertExhaustive(childNode)
}
}
@@ -234,30 +402,31 @@ const unionBody = (
inline: [
LabelledList.create([
LabelledList.line("Type", "Union"),
LabelledList.line(
"Cases",
node.cases,
cases =>
cases
.map((childNode, index) => {
const caseId = casePropertyPath(id(childNode, index))
LabelledList.line("Cases", node.children, (cases) =>
cases
.map((childNode, index) => {
const caseId = casePropertyPath(id(childNode, index))
return a(caseId, namedLink(caseId))
})
.join(" | ")
return a(caseId, anchorUrl(caseId))
})
.join(" | ")
),
])
]),
],
append: node.cases.map(
(childNode, index) =>
definitionToMarkdown(casePropertyPath(id(childNode, index)), childNode)
)
append: node.children.map((childNode, index) =>
definitionToMarkdown(
casePropertyPath(id(childNode, index)),
childNode,
file
)
),
}
}
const dictionaryBody = (
node: DictionaryNode,
propertyPath: string
propertyPath: string,
file: RootNode
): SectionNode => {
const itemsPropertyPath = `${propertyPath}[key]`
@@ -265,139 +434,144 @@ const dictionaryBody = (
inline: [
LabelledList.create([
LabelledList.line("Type", "Dictionary"),
LabelledList.line("Property Values", itemsPropertyPath, anchor => a(anchor, namedLink(anchor))),
LabelledList.line("Property Values", itemsPropertyPath, (anchor) =>
a(anchor, anchorUrl(anchor))
),
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
LabelledList.line("Pattern", node.pattern, icode),
LabelledList.line("Minimum Properties", node.jsDoc?.tags.minProperties, icode),
LabelledList.line(
"Minimum Properties",
node.jsDoc?.tags.minProperties,
icode
),
]),
],
append: [
definitionToMarkdown(itemsPropertyPath, node.elements),
]
append: [definitionToMarkdown(itemsPropertyPath, node.children, file)],
}
}
const strictObjectBody = (
node: RecordNode,
propertyPath: string
propertyPath: string,
file: RootNode
): SectionNode => {
const nodeElements = Object.entries(node.elements)
const nodeElements = Object.entries(node.children)
if (nodeElements.length === 0) {
return {
inline: [
LabelledList.create([
LabelledList.line("Type", "Empty Object"),
])
LabelledList.create([LabelledList.line("Type", "Empty Object")]),
],
append: []
append: [],
}
}
else {
const propertiesOverview = Object.entries(node.elements)
} else {
const propertiesOverview = Object.entries(node.children)
.map(([key, config]) => {
const propertyPropertyPath = `${propertyPath}/${key}`
const title = `\`${key}${config.isRequired ? "" : "?"}\``
return [
title,
config.jsDoc?.comment
?.split("\n\n")[0]
?.replaceAll("\n", " ")
?? "",
a("See details", namedLink(propertyPropertyPath))
config.jsDoc?.comment?.split("\n\n")[0]?.replaceAll("\n", " ") ?? "",
a("See details", anchorUrl(propertyPropertyPath)),
].join(" | ")
})
.join(EOL)
const properties = nodeElements
.reduce<SectionNode>(
(
{
inline,
append,
},
[key, propertyNode]
) => {
const propertyPropertyPath = `${propertyPath}/${key}`
const title = h(4, `\`${key}${propertyNode.isRequired ? "" : "?"}\``, propertyPropertyPath)
const properties = nodeElements.reduce<SectionNode>(
({ inline, append }, [key, propertyNode]) => {
const propertyPropertyPath = `${propertyPath}/${key}`
const title = h(
4,
`\`${key}${propertyNode.isRequired ? "" : "?"}\``,
propertyPropertyPath
)
if (propertyNode.value.kind === NodeKind.Record) {
return {
inline: [
...inline,
headerWithDescription(title, propertyNode.jsDoc?.comment),
LabelledList.create([
LabelledList.line("Type", propertyPropertyPath, anchor => a("Object", namedLink(anchor))),
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
]),
],
append: [
...append,
definitionToMarkdown(propertyPropertyPath, propertyNode.value)
],
}
if (propertyNode.value.kind === NodeKind.Record) {
return {
inline: [
...inline,
headerWithDescription(title, propertyNode.jsDoc?.comment),
LabelledList.create([
LabelledList.line("Type", propertyPropertyPath, (anchor) =>
a("Object", anchorUrl(anchor))
),
LabelledList.line(
"Default",
node.jsDoc?.tags.default,
icodejson
),
]),
],
append: [
...append,
definitionToMarkdown(
propertyPropertyPath,
propertyNode.value,
file
),
],
}
else {
const { inline: inlineCurrent, append: appendCurrent } = definitionToMarkdown(
} else {
const { inline: inlineCurrent, append: appendCurrent } =
definitionToMarkdown(
propertyPropertyPath,
propertyNode.value,
file,
true,
headerWithDescription(title, propertyNode.jsDoc?.comment)
)
return {
inline: [
...inline,
...inlineCurrent,
],
append: [
...append,
...appendCurrent
],
}
return {
inline: [...inline, ...inlineCurrent],
append: [...append, ...appendCurrent],
}
},
{
inline: [],
append: [],
}
)
},
{
inline: [],
append: [],
}
)
return {
inline: [
LabelledList.create([
LabelledList.line("Type", "Object"),
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
LabelledList.line("Minimum Properties", node.jsDoc?.tags.minProperties, icode),
LabelledList.line(
"Minimum Properties",
node.jsDoc?.tags.minProperties,
icode
),
]),
`Key | Description | Details${EOL}:-- | :-- | :--${EOL}${propertiesOverview}`,
...properties.inline,
],
append: properties.append
append: properties.append,
}
}
}
const prependHeader = (
propertyPath: string,
node: ChildNode,
node: StatementNode | ChildNode,
skipLine: boolean,
header: string | undefined,
{ inline, append }: SectionNode,
) =>
({
inline: [
...(!skipLine ? ["---"] : []),
header ?? definitionHeader(propertyPath, node.jsDoc),
...inline
],
append
})
{ inline, append }: SectionNode
) => ({
inline: [
...(!skipLine ? ["---"] : []),
header ?? definitionHeader(propertyPath, node.jsDoc),
...inline,
],
append,
})
const definitionToMarkdown = (
propertyPath: string,
node: ChildNode,
node: StatementNode | ChildNode,
file: RootNode,
skipLine = false,
header?: string
): SectionNode => {
@@ -407,59 +581,112 @@ const definitionToMarkdown = (
case NodeKind.Reference:
case NodeKind.Enumeration: {
return prependHeader(propertyPath, node, skipLine, header, {
inline: [simpleBody(node)],
append: []
inline: [simpleBody(node, file)],
append: [],
})
}
case NodeKind.Record: {
return prependHeader(propertyPath, node, skipLine, header,
strictObjectBody(node, propertyPath))
return prependHeader(
propertyPath,
node,
skipLine,
header,
strictObjectBody(node, propertyPath, file)
)
}
case NodeKind.Array: {
return prependHeader(propertyPath, node, skipLine, header,
arrayBody(node, propertyPath))
return prependHeader(
propertyPath,
node,
skipLine,
header,
arrayBody(node, propertyPath, file)
)
}
case NodeKind.Union: {
return prependHeader(propertyPath, node, skipLine, header,
unionBody(node, propertyPath))
return prependHeader(
propertyPath,
node,
skipLine,
header,
unionBody(node, propertyPath, file)
)
}
case NodeKind.Dictionary: {
return prependHeader(propertyPath, node, skipLine, header,
dictionaryBody(node, propertyPath))
return prependHeader(
propertyPath,
node,
skipLine,
header,
dictionaryBody(node, propertyPath, file)
)
}
case NodeKind.Tuple: {
return prependHeader(propertyPath, node, skipLine, header,
tupleBody(node, propertyPath))
return prependHeader(
propertyPath,
node,
skipLine,
header,
tupleBody(node, propertyPath, file)
)
}
case NodeKind.Group: {
return {
inline: [],
append: Object.entries(node.elements)
.map(([key, childNode], i) =>
definitionToMarkdown(`${propertyPath}/${key}`, childNode, skipLine && i === 0)
append: Object.entries(node.children).map(([key, childNode], i) =>
definitionToMarkdown(
`${propertyPath}/${key}`,
childNode,
file,
skipLine && i === 0
)
),
}
}
case NodeKind.TypeDefinition: {
return definitionToMarkdown(
propertyPath,
node.definition,
file,
skipLine,
definitionHeader(propertyPath, node.jsDoc, node.typeParameters)
)
}
case NodeKind.ExportAssignment: {
// ignore export assignment
return { append: [], inline: [] }
}
case NodeKind.Intersection: {
// TODO: implement intersection
return { append: [], inline: [] }
}
default:
return assertExhaustive(node)
}
}
const astToMarkdown: AstTransformer = file => {
const ref = file.jsDoc?.tags.main !== undefined ? file.elements[file.jsDoc?.tags.main] : undefined
const astToMarkdown: AstTransformer = (file) => {
const ref =
file.jsDoc?.tags.main !== undefined
? file.children.find(({ name }) => name === file.jsDoc?.tags.main)
: undefined
const definitions = Object.entries(file.elements)
.map(([id, definition], i) =>
definitionToMarkdown(id, definition, i === 0)
const definitions = file.children
.map((definition, i) =>
definitionToMarkdown(definition.name, definition, file, i === 0)
)
.flatMap(mergeParagraphs)
return [
docHeader(file, ref?.jsDoc),
h(2, "Definitions"),
...definitions
].join(EOL + EOL) + EOL
return (
[docHeader(file, ref?.jsDoc), h(2, "Definitions"), ...definitions].join(
EOL + EOL
) + EOL
)
}
export const markdownRenderer = (): Renderer => Object.freeze({
transformer: astToMarkdown,
fileExtension: ".md",
})
export const markdownRenderer = (): Renderer =>
Object.freeze({
transformer: astToMarkdown,
fileExtension: ".md",
resolveTypeParameters: false,
})
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"composite": true,
"declaration": true,
"lib": ["ESNext"],
"module": "NodeNext",
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"outDir": "../lib",
"sourceMap": true,
"strict": true,
"target": "ESNext",
}
}
+6
View File
@@ -0,0 +1,6 @@
export function assertExhaustive(
_x: never,
msg: string = "The switch is not exhaustive."
): never {
throw new Error(msg)
}
+2
View File
@@ -0,0 +1,2 @@
export const isNotNullish = <T>(value: T): value is NonNullable<T> =>
value !== null && value !== undefined
+7
View File
@@ -0,0 +1,7 @@
export const tsextPattern = /(?:\.d)?\.ts$/
export const changeExtension = (
fileName: string,
oldExt: string,
newExt: string
) => fileName.slice(0, -oldExt.length) + newExt
+17
View File
@@ -0,0 +1,17 @@
import { QualifiedName } from "../ast.js"
export const qualifiedNameToArray = (name: QualifiedName): string[] => {
if (name.right) {
return [name.segment, ...qualifiedNameToArray(name.right)]
}
return [name.segment]
}
export const getRightmostQualifiedNameSegment = (
name: QualifiedName
): string => {
if (name.right) {
return getRightmostQualifiedNameSegment(name.right)
}
return name.segment
}
+47
View File
@@ -0,0 +1,47 @@
import { dirname, format, parse, relative } from "node:path/posix"
import { NodeKind, ReferenceNode, RootNode } from "../ast.js"
import { qualifiedNameToArray } from "./qualifiedName.js"
export const getRelativeExternalPath = (
node: ReferenceNode,
file: RootNode,
ext: string
) => {
const externalSourceFilePath =
node.resolvedFileName !== file.fileName ? node.resolvedFileName : undefined
const externalSourceFilePathParts = externalSourceFilePath
? parse(relative(dirname(file.fileName), externalSourceFilePath))
: undefined
if (externalSourceFilePathParts) {
// @ts-expect-error Allowed by Node.js documentation
externalSourceFilePathParts.base = undefined
externalSourceFilePathParts.ext = ext
}
let externalFilePath = externalSourceFilePathParts
? format(externalSourceFilePathParts)
: ""
if (!externalFilePath.startsWith(".") && externalFilePath !== "") {
externalFilePath = "./" + externalFilePath
}
return externalFilePath
}
export const getFullyQualifiedNameAsPath = (
node: ReferenceNode,
file: RootNode
) => {
const isNamespaceImport = file.imports.some(
(importNode) =>
importNode.kind === NodeKind.NamespaceImport &&
importNode.name === node.name.segment
)
return qualifiedNameToArray(node.name)
.slice(isNamespaceImport ? 1 : 0)
.join("/")
}
-52
View File
@@ -1,52 +0,0 @@
// @ts-check
import * as assert from "node:assert/strict"
import { sep } from "node:path"
import { dirname, join } from "node:path/posix"
import { describe, it } from "node:test"
import { fileURLToPath } from "node:url"
import ts from "typescript"
import { fileToAst, NodeKind, TokenKind } from "../../lib/parser/ast.js"
describe("ast", () => {
it("should treat a record with defaulted type parameters as record without type parameters for output", () => {
const { checker, program } = prepareTypeScriptInstance(["typeParameterWithDefaults.ts"])
const file = /** @type {import("typescript").SourceFile} */ (program.getSourceFiles().find(file => file.fileName.includes("typeParameterWithDefaults")))
const actual = fileToAst(file, checker, program)
/** @type {import("../../src/parser/ast.js").RootNode} */
const expected = {
kind: NodeKind.Main,
jsDoc: undefined,
elements: {
Record: {
kind: NodeKind.Record,
jsDoc: undefined,
elements: {
id: {
isRequired: true,
jsDoc: undefined,
value: {
kind: NodeKind.Token,
jsDoc: undefined,
token: TokenKind.Number
}
}
}
}
}
}
assert.deepEqual(actual, expected)
})
})
/**
* @param {string[]} filePaths
* @return {{ checker: ts.TypeChecker; program: ts.Program }}
*/
const prepareTypeScriptInstance = filePaths => {
const root = join(dirname(fileURLToPath(import.meta.url).split(sep).join("/")), "files")
const program = ts.createProgram(filePaths.map(file => join(root, file)), { strict: true })
const checker = program.getTypeChecker()
return { program, checker }
}
+178
View File
@@ -0,0 +1,178 @@
import * as assert from "node:assert/strict"
import { normalize, sep } from "node:path"
import { dirname, join } from "node:path/posix"
import { describe, it } from "node:test"
import { fileURLToPath } from "node:url"
import ts from "typescript"
import { NodeKind, RootNode, TokenKind } from "../../src/ast.js"
import { fileToAst } from "../../src/parser/ast.js"
const prepareTypeScriptInstance = (
filePaths: string[][]
): { checker: ts.TypeChecker; program: ts.Program; root: string } => {
const root = join(dirname(fileURLToPath(import.meta.url)), "files")
const program = ts.createProgram(
filePaths.map((file) => join(root, ...file)),
{ strict: true }
)
const checker = program.getTypeChecker()
return { program, checker, root }
}
describe("fileToAst", () => {
const { checker, program, root } = prepareTypeScriptInstance([
["a.ts"],
["b.ts"],
])
const a = program
.getSourceFiles()
.find((file) => normalize(file.fileName).endsWith(sep + "a.ts"))!
const b = program
.getSourceFiles()
.find((file) => normalize(file.fileName).endsWith(sep + "b.ts"))!
it("transforms the content into a custom ast (a.ts)", () => {
const actual = fileToAst(a, checker, program)
assert.deepEqual<RootNode>(actual, {
kind: NodeKind.Root,
fileName: a.fileName,
jsDoc: undefined,
imports: [],
children: [
{
kind: NodeKind.TypeDefinition,
fileName: a.fileName,
jsDoc: undefined,
name: "A",
typeParameters: [
{
kind: NodeKind.TypeParameter,
fileName: a.fileName,
name: "T",
constraint: {
kind: NodeKind.Token,
fileName: a.fileName,
jsDoc: undefined,
token: TokenKind.Number,
},
default: {
kind: NodeKind.Token,
fileName: a.fileName,
jsDoc: undefined,
token: TokenKind.Number,
},
},
],
definition: {
kind: NodeKind.Record,
fileName: a.fileName,
jsDoc: undefined,
children: {
id: {
isReadOnly: false,
isRequired: true,
jsDoc: undefined,
value: {
kind: NodeKind.Reference,
fileName: a.fileName,
jsDoc: undefined,
name: { segment: "T", right: undefined },
typeArguments: undefined,
resolvedFileName: undefined,
},
},
},
},
},
{
kind: NodeKind.TypeDefinition,
fileName: a.fileName,
jsDoc: undefined,
name: "Def",
typeParameters: undefined,
definition: {
kind: NodeKind.Token,
fileName: a.fileName,
jsDoc: undefined,
token: TokenKind.Number,
},
},
{
kind: NodeKind.ExportAssignment,
fileName: a.fileName,
jsDoc: undefined,
name: "default",
expression: {
kind: NodeKind.Reference,
fileName: a.fileName,
jsDoc: undefined,
name: { segment: "Def" },
},
},
],
})
})
it("transforms the content into a custom ast (b.ts)", () => {
const actual = fileToAst(b, checker, program)
assert.deepEqual<RootNode>(actual, {
kind: NodeKind.Root,
fileName: b.fileName,
jsDoc: undefined,
imports: [
{
kind: NodeKind.DefaultImport,
name: "D",
fileName: join(root, "a.ts"),
},
{
kind: NodeKind.NamedImport,
name: "A",
alias: "TypeA",
fileName: join(root, "a.ts"),
},
],
children: [
{
kind: NodeKind.TypeDefinition,
fileName: b.fileName,
jsDoc: undefined,
name: "B",
typeParameters: undefined,
definition: {
kind: NodeKind.Reference,
fileName: b.fileName,
resolvedFileName: a.fileName,
jsDoc: undefined,
name: { segment: "TypeA", right: undefined },
typeArguments: [
{
kind: NodeKind.Reference,
fileName: b.fileName,
resolvedFileName: undefined,
jsDoc: undefined,
name: { segment: "D", right: undefined },
typeArguments: undefined,
},
],
},
},
{
kind: NodeKind.TypeDefinition,
fileName: b.fileName,
jsDoc: undefined,
name: "C",
typeParameters: undefined,
definition: {
kind: NodeKind.Token,
fileName: b.fileName,
jsDoc: undefined,
token: TokenKind.String,
},
},
],
})
})
})
+7
View File
@@ -0,0 +1,7 @@
export type A<T extends number = number> = {
id: T
}
type Def = number
export default Def
+5
View File
@@ -0,0 +1,5 @@
import D, { A as TypeA } from "./a.js"
export type B = TypeA<D>
export type C = string
@@ -1,3 +0,0 @@
export type Record<T extends number = number> = {
id: T
}
+255
View File
@@ -0,0 +1,255 @@
import * as assert from "node:assert/strict"
import { normalize, sep } from "node:path"
import { dirname, join } from "node:path/posix"
import { describe, it } from "node:test"
import { fileURLToPath } from "node:url"
import ts from "typescript"
import { NodeKind, RootNode, TokenKind } from "../../src/ast.js"
import { fileToAst } from "../../src/parser/ast.js"
import { resolveTypeArgumentsForFile } from "../../src/parser/resolvetypeargs.js"
const prepareTypeScriptInstance = (
suite: string,
filePaths: string[][]
): { checker: ts.TypeChecker; program: ts.Program; root: string } => {
const root = join(dirname(fileURLToPath(import.meta.url)), suite)
const program = ts.createProgram(
filePaths.map((file) => join(root, ...file)),
{ strict: true }
)
const checker = program.getTypeChecker()
return { program, checker, root }
}
describe("resolveTypeArgumentsForFile", () => {
const { checker, program, root } = prepareTypeScriptInstance("files", [
["a.ts"],
["b.ts"],
])
const a = program
.getSourceFiles()
.find((file) => normalize(file.fileName).endsWith(sep + "a.ts"))!
const b = program
.getSourceFiles()
.find((file) => normalize(file.fileName).endsWith(sep + "b.ts"))!
const asta = fileToAst(a, checker, program)
const astb = fileToAst(b, checker, program)
it("keeps generic types with default arguments", () => {
const actual = resolveTypeArgumentsForFile(
{ [a.fileName]: asta, [b.fileName]: astb },
asta
)
assert.deepEqual<RootNode>(actual, {
kind: NodeKind.Root,
fileName: a.fileName,
jsDoc: undefined,
imports: [],
children: [
{
kind: NodeKind.TypeDefinition,
fileName: a.fileName,
jsDoc: undefined,
name: "A",
typeParameters: [
{
kind: NodeKind.TypeParameter,
fileName: a.fileName,
name: "T",
constraint: {
kind: NodeKind.Token,
fileName: a.fileName,
jsDoc: undefined,
token: TokenKind.Number,
},
default: {
kind: NodeKind.Token,
fileName: a.fileName,
jsDoc: undefined,
token: TokenKind.Number,
},
},
],
definition: {
kind: NodeKind.Record,
fileName: a.fileName,
jsDoc: undefined,
children: {
id: {
isReadOnly: false,
isRequired: true,
jsDoc: undefined,
value: {
kind: NodeKind.Token,
fileName: a.fileName,
jsDoc: undefined,
token: TokenKind.Number,
},
},
},
},
},
{
kind: NodeKind.TypeDefinition,
fileName: a.fileName,
jsDoc: undefined,
name: "Def",
typeParameters: undefined,
definition: {
kind: NodeKind.Token,
fileName: a.fileName,
jsDoc: undefined,
token: TokenKind.Number,
},
},
],
})
})
it("resolves generic types only as deep as required", () => {
const actual = resolveTypeArgumentsForFile(
{ [a.fileName]: asta, [b.fileName]: astb },
astb
)
assert.deepEqual<RootNode>(actual, {
kind: NodeKind.Root,
fileName: b.fileName,
jsDoc: undefined,
imports: [
{
kind: NodeKind.DefaultImport,
fileName: a.fileName,
name: "D",
},
{
kind: NodeKind.NamedImport,
fileName: a.fileName,
name: "A",
alias: "TypeA",
},
],
children: [
{
kind: NodeKind.TypeDefinition,
fileName: b.fileName,
jsDoc: undefined,
name: "B",
typeParameters: undefined,
definition: {
kind: NodeKind.Record,
fileName: a.fileName,
jsDoc: undefined,
children: {
id: {
isReadOnly: false,
isRequired: true,
jsDoc: undefined,
value: {
kind: NodeKind.Reference,
fileName: b.fileName,
jsDoc: undefined,
name: { segment: "D", right: undefined },
typeArguments: undefined,
resolvedFileName: a.fileName,
},
},
},
},
},
{
kind: NodeKind.TypeDefinition,
fileName: b.fileName,
jsDoc: undefined,
name: "C",
typeParameters: undefined,
definition: {
kind: NodeKind.Token,
fileName: b.fileName,
jsDoc: undefined,
token: TokenKind.String,
},
},
],
})
})
it("maintains cross-file references", () => {
const { checker, program, root } = prepareTypeScriptInstance(
"resolvetypeargsfiles",
[["a.ts"], ["b.ts"], ["c.ts"]]
)
const a = program
.getSourceFiles()
.find((file) => normalize(file.fileName).endsWith(sep + "a.ts"))!
const b = program
.getSourceFiles()
.find((file) => normalize(file.fileName).endsWith(sep + "b.ts"))!
const c = program
.getSourceFiles()
.find((file) => normalize(file.fileName).endsWith(sep + "c.ts"))!
const asta = fileToAst(a, checker, program)
const astb = fileToAst(b, checker, program)
const astc = fileToAst(c, checker, program)
const files = {
[a.fileName]: asta,
[b.fileName]: astb,
[c.fileName]: astc,
}
const actual = resolveTypeArgumentsForFile(files, astc)
assert.deepEqual<RootNode>(actual, {
kind: NodeKind.Root,
fileName: c.fileName,
jsDoc: undefined,
imports: [
{
kind: NodeKind.NamedImport,
fileName: b.fileName,
name: "B",
},
],
children: [
{
kind: NodeKind.TypeDefinition,
fileName: c.fileName,
jsDoc: undefined,
name: "C",
typeParameters: undefined,
definition: {
kind: NodeKind.Record,
fileName: b.fileName,
jsDoc: undefined,
children: {
object: {
isReadOnly: false,
isRequired: true,
jsDoc: undefined,
value: {
kind: NodeKind.Reference,
fileName: b.fileName,
resolvedFileName: a.fileName,
jsDoc: undefined,
name: { segment: "A", right: undefined },
typeArguments: undefined,
},
},
value: {
isReadOnly: false,
isRequired: true,
jsDoc: undefined,
value: {
kind: NodeKind.Token,
fileName: c.fileName,
jsDoc: undefined,
token: TokenKind.Number,
},
},
},
},
},
],
})
})
})
+3
View File
@@ -0,0 +1,3 @@
export type A = {
label: string
}
+6
View File
@@ -0,0 +1,6 @@
import { A } from "./a.js"
export type B<T> = {
object: A
value: T
}
+3
View File
@@ -0,0 +1,3 @@
import { B } from "./b.js"
export type C = B<number>
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"composite": true,
"declaration": true,
"lib": ["ESNext"],
"module": "NodeNext",
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"outDir": "../libtest",
"sourceMap": true,
"strict": true,
"target": "ESNext",
},
"references": [
{ "path": "../src" },
]
}
+5 -14
View File
@@ -1,16 +1,7 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"lib": ["ESNext"],
"module": "NodeNext",
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"outDir": "lib",
"strict": true,
"target": "ESNext",
},
"include": [
"src/**/*"
]
"files": [],
"references": [
{ "path": "src" },
{ "path": "test" },
],
}