docs-util: support models implemented with DML in typedoc custom plugins (#7847)

- Support generating reference for models implemented with DML
- Support resolving and generating mermaid diagram for relations

The Currency Module was used an example so its reference is generated to showcase the work of this PR.
This commit is contained in:
Shahed Nasser
2024-07-01 10:34:51 +03:00
committed by GitHub
parent b62f23ea00
commit 1f360a3245
59 changed files with 1455 additions and 1005 deletions

View File

@@ -4,7 +4,6 @@ const fileOptions: FormattingOptionsType = {
"^file/.*AbstractFileProviderService": {
reflectionGroups: {
Properties: false,
Constructors: false,
},
reflectionDescription: `In this document, youll learn how to create a file provider module and the methods you must implement in its main service.`,
frontmatterData: {

View File

@@ -4,7 +4,6 @@ const notificationOptions: FormattingOptionsType = {
"^notification/.*AbstractNotificationProviderService": {
reflectionGroups: {
Properties: false,
Constructors: false,
},
reflectionDescription: `In this document, youll learn how to create a notification provider module and the methods you must implement in it.`,
frontmatterData: {

View File

@@ -6,6 +6,7 @@ import { modules } from "./references.js"
import {
customModuleServiceNames,
customModuleTitles,
dmlModules,
} from "./references-details.js"
import { FormattingOptionType } from "types"
import { kebabToCamel, kebabToPascal, kebabToSnake, kebabToTitle } from "utils"
@@ -28,7 +29,12 @@ const mergerOptions: Partial<TypeDocOptions> = {
objectLiteralTypeDeclarationStyle: "component",
mdxOutput: true,
maxLevel: 3,
allReflectionsHaveOwnDocument: [...modules, "dml", "workflows"],
allReflectionsHaveOwnDocument: [
...modules,
...dmlModules.map((module) => `${module}-models`),
"dml",
"workflows",
],
allReflectionsHaveOwnDocumentInNamespace: ["Utilities"],
formatting: {
"*": {
@@ -55,6 +61,8 @@ const mergerOptions: Partial<TypeDocOptions> = {
)
? customModuleServiceNames[moduleName]
: `I${kebabToPascal(moduleName)}ModuleService`
const isDmlModule = dmlModules.includes(moduleName)
return Object.assign(obj, {
// module config
[`^${snakeCaseModuleName}`]: {
@@ -107,11 +115,15 @@ const mergerOptions: Partial<TypeDocOptions> = {
typeParameters: false,
suffix: `- ${titleModuleName} Module Data Models Reference`,
},
reflectionGroups: {
Constructors: false,
Functions: false,
Methods: false,
},
reflectionGroups: isDmlModule
? {
Variables: true,
}
: {
Constructors: false,
Functions: false,
Methods: false,
},
},
[`^modules/${snakeCaseModuleName}_models`]: {
reflectionDescription: `This documentation provides a reference to the data models in the ${titleModuleName} Module`,
@@ -122,6 +134,11 @@ const mergerOptions: Partial<TypeDocOptions> = {
reflectionTitle: {
fullReplacement: `${titleModuleName} Module Data Models Reference`,
},
reflectionGroupRename: isDmlModule
? {
Variables: "Data Models",
}
: {},
},
} as FormattingOptionType)
}, {} as FormattingOptionType),

View File

@@ -23,3 +23,6 @@ export const customModulesOptions: Record<string, Partial<TypeDocOptions>> = {
),
},
}
// a list of modules that now support DML
export const dmlModules = ["currency"]

View File

@@ -18,6 +18,10 @@ export default function getModelOptions({
tsConfigName: `${moduleName}.json`,
generateModelsDiagram: true,
diagramAddToFile: entryPath,
resolveDmlRelations: true,
generateDMLsDiagram: true,
diagramDMLAddToFile: entryPath,
normalizeDmlTypes: true,
...typedocOptions,
})
}

View File

@@ -0,0 +1 @@
export const RELATION_NAMES = ["HasOne", "HasMany", "BelongsTo", "ManyToMany"]

View File

@@ -0,0 +1,111 @@
import {
Application,
Context,
Converter,
DeclarationReflection,
ParameterType,
ReferenceType,
} from "typedoc"
import { getDmlProperties, isDmlEntity } from "utils"
import { RELATION_NAMES } from "./constants"
export class DmlRelationsResolver {
private app: Application
private dmlReflectionsAndProperties: {
reflection: DeclarationReflection
properties: DeclarationReflection[]
}[]
constructor(app: Application) {
this.app = app
this.dmlReflectionsAndProperties = []
this.app.options.addDeclaration({
name: "resolveDmlRelations",
help: "Whether to enable resolving DML relations.",
type: ParameterType.Boolean,
defaultValue: false,
})
this.app.converter.on(
Converter.EVENT_CREATE_DECLARATION,
this.addReflection.bind(this)
)
this.app.converter.on(
Converter.EVENT_RESOLVE_BEGIN,
this.resolveRelations.bind(this)
)
}
addReflection(_context: Context, reflection: DeclarationReflection) {
if (!this.app.options.getValue("resolveDmlRelations")) {
return
}
if (isDmlEntity(reflection)) {
this.dmlReflectionsAndProperties?.push({
reflection,
properties: getDmlProperties(reflection.type as ReferenceType),
})
}
}
resolveRelations(context: Context) {
if (!this.app.options.getValue("resolveDmlRelations")) {
return
}
this.dmlReflectionsAndProperties.forEach(({ properties }) => {
properties.forEach((property) => {
if (
property.type?.type !== "reference" ||
!RELATION_NAMES.includes(property.type.name)
) {
return
}
// try to find the reflection that this relation points to
const relatedReflectionType = property.type.typeArguments?.[0]
if (
relatedReflectionType?.type !== "reflection" ||
!relatedReflectionType.declaration.signatures?.length ||
relatedReflectionType.declaration.signatures[0].type?.type !==
"reference"
) {
return
}
const relatedReflection = this.findReflectionMatchingProperties(
getDmlProperties(relatedReflectionType.declaration.signatures[0].type)
)
if (!relatedReflection) {
return
}
// replace type argument with reference to related reflection
property.type.typeArguments = [
ReferenceType.createResolvedReference(
relatedReflection.name,
relatedReflection,
context.project
),
]
})
})
}
findReflectionMatchingProperties(
properties: DeclarationReflection[]
): DeclarationReflection | undefined {
return this.dmlReflectionsAndProperties.find(({ properties: refProps }) => {
return properties.every((property) => {
return refProps.find(
(refProp) =>
refProp.name === property.name &&
(refProp.type as ReferenceType).name ===
(property.type as ReferenceType).name
)
})
})?.reflection
}
}

View File

@@ -0,0 +1,69 @@
import {
Application,
Context,
Converter,
DeclarationReflection,
ParameterType,
ReferenceType,
ReflectionFlag,
ReflectionKind,
} from "typedoc"
import { getDmlProperties, isDmlEntity } from "utils"
export function load(app: Application) {
app.options.addDeclaration({
name: "normalizeDmlTypes",
help: "Whether to normalize DML types.",
type: ParameterType.Boolean,
defaultValue: false,
})
app.converter.on(Converter.EVENT_RESOLVE_BEGIN, (context: Context) => {
if (!app.options.getValue("normalizeDmlTypes")) {
return
}
for (const reflection of context.project.getReflectionsByKind(
ReflectionKind.Variable
)) {
if (
!(reflection instanceof DeclarationReflection) ||
!isDmlEntity(reflection)
) {
break
}
const properties = getDmlProperties(reflection.type as ReferenceType)
properties.forEach((property) => {
if (property.type?.type !== "reference") {
return
}
normalizeNullable(property)
})
}
})
}
function normalizeNullable(property: DeclarationReflection) {
const propertyReference = property.type as ReferenceType
if (
propertyReference.name !== "NullableModifier" ||
!propertyReference.typeArguments ||
propertyReference.typeArguments?.length < 2
) {
return
}
const actualType = propertyReference.typeArguments[1]
if (actualType.type !== "reference") {
return
}
// change the property's type to reference the actual type
property.type = actualType
// set a flag on the property to consider it optional
property.setFlag(ReflectionFlag.Optional)
}

View File

@@ -8,6 +8,9 @@ import { load as signatureModifierPlugin } from "./signature-modifier"
import { MermaidDiagramGenerator } from "./mermaid-diagram-generator"
import { load as parentIgnorePlugin } from "./parent-ignore"
import { GenerateNamespacePlugin } from "./generate-namespace"
import { DmlRelationsResolver } from "./dml-relations-resolver"
import { load as dmlTypesNormalizer } from "./dml-types-normalizer"
import { MermaidDiagramDMLGenerator } from "./mermaid-diagram-dml-generator"
export function load(app: Application) {
resolveReferencesPluginLoad(app)
@@ -17,7 +20,10 @@ export function load(app: Application) {
eslintExamplePlugin(app)
signatureModifierPlugin(app)
parentIgnorePlugin(app)
dmlTypesNormalizer(app)
new GenerateNamespacePlugin(app)
new MermaidDiagramGenerator(app)
new DmlRelationsResolver(app)
new MermaidDiagramDMLGenerator(app)
}

View File

@@ -0,0 +1,251 @@
import path from "path"
import {
Application,
Comment,
Context,
Converter,
DeclarationReflection,
ParameterType,
ReferenceType,
Reflection,
ReflectionKind,
TypeDocOptionMap,
} from "typedoc"
import { RELATION_NAMES } from "./constants"
import { getDmlProperties, isDmlEntity } from "utils"
type Relations = Map<
string,
{
target: string
left: MermaidRelationType
right?: MermaidRelationType
name: string
}[]
>
type PluginOptions = Pick<
TypeDocOptionMap,
"generateModelsDiagram" | "diagramAddToFile"
>
const ALLOWED_RELATION_NAMES = RELATION_NAMES.filter(
(name) => name !== "BelongsTo"
)
type MermaidRelationType =
| "one-to-one"
| "one-to-many"
| "many-to-one"
| "many-to-many"
export class MermaidDiagramDMLGenerator {
private app: Application
private options?: PluginOptions
private mainFileReflection?: Reflection
constructor(app: Application) {
this.app = app
this.app.options.addDeclaration({
name: "generateDMLsDiagram",
help: "Whether to generate a Mermaid.js class diagram for data models in the reference.",
type: ParameterType.Boolean,
defaultValue: false,
})
this.app.options.addDeclaration({
name: "diagramDMLAddToFile",
help: "The file to add the mermaid diagram to. The diagram is added as a package comment.",
type: ParameterType.String,
})
app.converter.on(
Converter.EVENT_CREATE_DECLARATION,
this.setMainFile.bind(this)
)
app.converter.on(
Converter.EVENT_RESOLVE_BEGIN,
this.findRelations.bind(this)
)
}
getPluginOptions(): PluginOptions {
if (this.options) {
return this.options
}
this.options = {
generateModelsDiagram: this.app.options.getValue("generateDMLsDiagram"),
diagramAddToFile: this.app.options.getValue("diagramDMLAddToFile"),
}
return this.options
}
setMainFile(context: Context) {
const options = this.getPluginOptions()
if (
this.mainFileReflection ||
!options.generateModelsDiagram ||
!options.diagramAddToFile
) {
return
}
const mainFilePath = options.diagramAddToFile.startsWith("packages")
? path.resolve("..", "..", "..", "..", options.diagramAddToFile)
: options.diagramAddToFile
const mainFileSource = context.program.getSourceFile(mainFilePath)
if (!mainFileSource) {
return
}
const mainFileSymbol = context.checker.getSymbolAtLocation(mainFileSource)
if (!mainFileSymbol) {
return
}
this.mainFileReflection =
context.project.getReflectionFromSymbol(mainFileSymbol)
}
findRelations(context: Context) {
const options = this.getPluginOptions()
if (
!this.mainFileReflection ||
!options.generateModelsDiagram ||
!options.diagramAddToFile
) {
return
}
const relations: Relations = new Map()
for (const reflection of context.project.getReflectionsByKind(
ReflectionKind.Variable
)) {
if (
!(reflection instanceof DeclarationReflection) ||
!isDmlEntity(reflection)
) {
return
}
const reflectionProperties = getDmlProperties(
reflection.type as ReferenceType
)
// find relations of that reflection
reflectionProperties.forEach((property) => {
if (
property.type?.type !== "reference" ||
!ALLOWED_RELATION_NAMES.includes(property.type.name) ||
property.type.typeArguments?.length !== 1 ||
property.type.typeArguments[0].type !== "reference"
) {
return
}
const targetReflection = property.type.typeArguments[0].reflection
if (!targetReflection) {
return
}
// if entry already exists in relation, don't add anything
const exists =
relations
.get(reflection.name)
?.some((relation) => relation.target === targetReflection.name) ||
relations
.get(targetReflection.name)
?.some((relation) => relation.target === reflection.name)
if (exists) {
return
}
const relationType = this.getMermaidRelation(property.type.name)
if (!relationType) {
return
}
if (!relations.has(reflection.name)) {
relations.set(reflection.name, [])
}
relations.get(reflection.name)?.push({
target: targetReflection.name,
left: relationType,
right: this.getReverseRelationType(relationType),
name: property.name,
})
})
}
if (!relations.size) {
return
}
this.mainFileReflection.comment = new Comment([
{
text: "## Relations Overview\n\n",
kind: "text",
},
{
text: this.buildMermaidDiagram(relations),
kind: "code",
},
])
}
getMermaidRelation(relation: string): MermaidRelationType | undefined {
switch (relation) {
case "HasOne":
return "one-to-one"
case "HasMany":
return "one-to-many"
case "ManyToMany":
return "many-to-many"
}
}
getReverseRelationType(
relationType: MermaidRelationType
): MermaidRelationType {
return relationType.split("-").reverse().join("-") as MermaidRelationType
}
buildMermaidDiagram(relations: Relations): string {
const linePrefix = `\t`
const lineSuffix = `\n`
let diagram = `erDiagram${lineSuffix}`
relations.forEach((itemRelations, itemName) => {
itemRelations.forEach((itemRelation) => {
diagram += `${linePrefix}${itemName} ${this.getRelationTypeSymbol(
itemRelation.left,
"left"
)}--${this.getRelationTypeSymbol(itemRelation.right!, "right")} ${
itemRelation.target
} : ${itemRelation.name}${lineSuffix}`
})
})
return "```mermaid\n" + diagram + "\n```"
}
getRelationTypeSymbol(
relationType: MermaidRelationType,
direction: "left" | "right"
): string {
switch (relationType) {
case "one-to-one":
return "||"
case "one-to-many":
return direction === "left" ? "||" : "|{"
case "many-to-many":
return direction === "left" ? "}|" : "|{"
case "many-to-one":
return direction === "left" ? "}|" : "||"
}
}
}

View File

@@ -64,6 +64,8 @@ import ifShowSeparatorForTitleLevelHelper from "./resources/helpers/if-show-sepa
import shouldExpandPropertiesHelper from "./resources/helpers/should-expand-properties"
import shouldExpandDeclarationChildrenHelper from "./resources/helpers/should-expand-declaration-children"
import startSectionsHelper from "./resources/helpers/start-sections"
import ifDmlEntityHelper from "./resources/helpers/if-dml-entity"
import dmlPropertiesHelper from "./resources/helpers/dml-properties"
import { MarkdownTheme } from "./theme"
const TEMPLATE_PATH = path.join(__dirname, "resources", "templates")
@@ -156,4 +158,6 @@ export function registerHelpers(theme: MarkdownTheme) {
shouldExpandPropertiesHelper(theme)
shouldExpandDeclarationChildrenHelper(theme)
startSectionsHelper(theme)
ifDmlEntityHelper()
dmlPropertiesHelper()
}

View File

@@ -0,0 +1,23 @@
import * as Handlebars from "handlebars"
import { DeclarationReflection, ReferenceType } from "typedoc"
import { getDmlProperties, isDmlEntity } from "utils"
export default function () {
Handlebars.registerHelper(
"dmlProperties",
function (this: DeclarationReflection) {
if (!isDmlEntity(this)) {
return ""
}
const properties = getDmlProperties(this.type as ReferenceType)
// TODO resolve the property types to names/native types
return Handlebars.helpers.typeDeclarationMembers.call(properties, {
hash: {
sectionTitle: this.name,
},
})
}
)
}

View File

@@ -0,0 +1,12 @@
import * as Handlebars from "handlebars"
import { DeclarationReflection } from "typedoc"
import { isDmlEntity } from "utils"
export default function () {
Handlebars.registerHelper(
"ifDmlEntity",
function (this: DeclarationReflection, options: Handlebars.HelperOptions) {
return isDmlEntity(this) ? options.fn(this) : options.inverse(this)
}
)
}

View File

@@ -14,7 +14,8 @@ export default function (theme: MarkdownTheme) {
const md: string[] = []
const { hideInPageTOC } = theme
const { hideTocHeaders } = theme.getFormattingOptionsForLocation()
const { hideTocHeaders, reflectionGroupRename = {} } =
theme.getFormattingOptionsForLocation()
const isVisible = this.groups?.some((group) =>
group.allChildrenHaveOwnDocument()
@@ -36,7 +37,9 @@ export default function (theme: MarkdownTheme) {
}
const headingLevel = hideInPageTOC ? `##` : `###`
this.groups?.forEach((group) => {
const groupTitle = group.title
const groupTitle = Object.hasOwn(reflectionGroupRename, group.title)
? reflectionGroupRename[group.title]
: group.title
if (group.categories) {
group.categories.forEach((category) => {
md.push(`${headingLevel} ${category.title} ${groupTitle}\n\n`)

View File

@@ -27,10 +27,6 @@ export default function (theme: MarkdownTheme) {
[]
) as DeclarationReflection[]
// if (typeof options.hash.sectionTitle !== "string") {
// console.log("here2")
// }
let result = ""
switch (theme.objectLiteralTypeDeclarationStyle) {
case "list": {

View File

@@ -0,0 +1,7 @@
{{#if (sectionEnabled "member_declaration_comment")}}
{{> comment}}
{{/if}}
{{{dmlProperties}}}

View File

@@ -46,8 +46,16 @@
{{#if (sectionEnabled "member_declaration")}}
{{#ifDmlEntity}}
{{> member.dml}}
{{else}}
{{> member.declaration}}
{{/ifDmlEntity}}
{{/if}}
{{/ifIsReference}}

View File

@@ -2,6 +2,7 @@ import {
Comment,
DeclarationReflection,
ProjectReflection,
ReferenceType,
ReflectionKind,
ReflectionType,
} from "typedoc"
@@ -15,6 +16,7 @@ import {
stripLineBreaks,
} from "utils"
import { MarkdownTheme } from "../theme"
import { getDmlProperties, isDmlEntity } from "utils"
const ALLOWED_KINDS: ReflectionKind[] = [
ReflectionKind.EnumMember,
@@ -153,7 +155,18 @@ export function reflectionComponentFormatter({
const hasChildren = "children" in reflection && reflection.children?.length
if (
if (reflection.variant === "declaration" && isDmlEntity(reflection)) {
componentItem.children = getDmlProperties(
reflection.type as ReferenceType
).map((childItem) =>
reflectionComponentFormatter({
reflection: childItem,
level: level + 1,
maxLevel,
project,
})
)
} else if (
(reflection.type || hasChildren) &&
level + 1 <= (maxLevel || MarkdownTheme.MAX_LEVEL)
) {

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
"rootDir": "./src",
"lib": ["es2022"]
},
"include": ["src"]
}

View File

@@ -54,6 +54,9 @@ export type FormattingOptionType = {
reflectionGroups?: {
[k: string]: boolean
}
reflectionGroupRename?: {
[k: string]: string
}
reflectionCategories?: {
[k: string]: boolean
}
@@ -234,5 +237,25 @@ export declare module "typedoc" {
* The file to add the mermaid diagram to. The diagram is added as a package comment.
*/
diagramAddToFile: string
/**
* Whether to generate a Mermaid.js class diagram for data models in the reference.
* (Used for DML)
*/
generateDMLsDiagram: boolean
/**
* The file to add the mermaid diagram to. The diagram is added as a package comment.
* (Used for DML)
*/
diagramDMLAddToFile: string
/**
* Whether to enable resolving DML relations.
* @defaultValue false
*/
resolveDmlRelations: boolean
/**
* Whether to normalize DML types.
* @defaultValue false
*/
normalizeDmlTypes: boolean
}
}

View File

@@ -0,0 +1,24 @@
import { DeclarationReflection, ReferenceType, ReflectionType } from "typedoc"
export function isDmlEntity(reflection: DeclarationReflection) {
if (reflection.type?.type !== "reference") {
return false
}
return reflection.type.name === "DmlEntity"
}
export function getDmlProperties(
reflectionType: ReferenceType
): DeclarationReflection[] {
if (
!reflectionType.typeArguments?.length ||
reflectionType.typeArguments[0].type !== "intersection"
) {
return []
}
const schemaType = reflectionType.typeArguments[0].types[0] as ReflectionType
return schemaType.declaration.children || []
}

View File

@@ -1,3 +1,4 @@
export * from "./dml-utils"
export * from "./get-type-children"
export * from "./get-project-child"
export * from "./get-type-str"