docs: diagrams plugin tooling (#5741)

* added plugin

* updated plugin + added component

* dummy data TO BE REMOVED

* (wip) workflow generator tool

* add workflow generator tooling

* updated the generator tool

* added code file creation

* fix design of diagrams

* configured diagram theme

* added build script

* removed comments + unnecessary files

* general fixes

* refactored plugin

* added README + more output types
This commit is contained in:
Shahed Nasser
2023-11-28 14:30:23 +00:00
committed by GitHub
parent d27b86ab8e
commit fa4935259c
19 changed files with 2961 additions and 167 deletions
@@ -0,0 +1,210 @@
import { TransactionStepsDefinition } from "@medusajs/orchestration"
import getRandomString from "../utils/get-random-string.js"
type DiagramBuilderOptions = {
theme?: boolean
prettyNames?: boolean
}
type ReturnedSteps = {
escapedStepNames: string[]
links: string[]
defsStr: string
}
export default class DiagramBuilder {
private options: DiagramBuilderOptions
static SPACING = "\t"
constructor(options: DiagramBuilderOptions) {
this.options = options
}
buildDiagram(workflow: TransactionStepsDefinition): string {
let diagram = `${this.getThemeConfig()}${
this.options.theme ? this.getLinePrefix(1) : ""
}flowchart TB`
const stepsDiagram = this.getSteps(workflow, this.options.theme ? 2 : 1)
diagram +=
stepsDiagram.defsStr + `\n` + this.formatLinks(stepsDiagram.links)
return diagram
}
getThemeConfig(): string {
return this.options.theme
? `%%{
init: {
'theme': 'base',
'themeVariables': {
'background': '#FFFFFF',
'mainBkg': '#FFFFFF',
'primaryColor': '#FFFFFF',
'primaryTextColor': '#030712',
'primaryBorderColor': '#D1D5DB',
'nodeBorder': '#D1D5DB',
'lineColor': '#11181C',
'fontFamily': 'Inter',
'fontSize': '13px',
'tertiaryColor': '#F3F4F6',
'tertiaryBorderColor': '#D1D5DB',
'tertiaryTextColor': '#030712'
}
}
}%%`
: ""
}
getSteps(
flow: TransactionStepsDefinition | TransactionStepsDefinition[],
level: number
): ReturnedSteps {
const links: string[] = []
let defsStr = ""
const escapedStepNames: string[] = []
const linePrefix = this.getLinePrefix(level)
const flowArr: TransactionStepsDefinition[] | undefined = Array.isArray(
flow
)
? flow
: !flow.action && Array.isArray(flow.next)
? flow.next
: undefined
if (flowArr) {
// these are steps running in parallel
// since there are changes where the flowArr contains
// one item, we check the length before treating the
// main steps as steps running in parallel
const areStepsParallel = flowArr.length > 1
const parallelDefinitions: Record<string, string> = {}
flowArr.forEach((flowItem) => {
const flowSteps = this.getSteps(flowItem, level)
if (areStepsParallel) {
const escapedName = this.getEscapedStepName(flowItem.action)
if (escapedName) {
const itemDefinition = `${linePrefix}${escapedName}(${this.formatStepName(
flowItem.action!
)})`
parallelDefinitions[itemDefinition] = flowSteps.defsStr.replace(
itemDefinition,
""
)
} else {
// if the step doesn't have an action name
// we just show it as a regular step rather than
// a subgraph
defsStr += `${linePrefix}${flowSteps.defsStr}`
}
} else {
// if the steps aren't parallel
// just show them as regular steps
defsStr += `${linePrefix}${flowSteps.defsStr}`
}
links.push(...flowSteps.links)
escapedStepNames.push(...flowSteps.escapedStepNames)
})
// if there are steps in parallel,
// we show them as a subgraph
const definitionKeys = Object.keys(parallelDefinitions)
if (definitionKeys.length) {
defsStr += `${this.getSubgraph(
definitionKeys.join(""),
linePrefix
)}${linePrefix}${Object.values(parallelDefinitions).join("")}`
}
} else {
const flowItem = flow as TransactionStepsDefinition
const escapedName = this.getEscapedStepName(flowItem.action)
if (escapedName.length) {
escapedStepNames.push(escapedName)
defsStr += `${linePrefix}${escapedName}(${this.formatStepName(
flowItem.action!
)})`
}
if (flowItem.next) {
const nextSteps = this.getSteps(flowItem.next, level)
defsStr += `${linePrefix}${nextSteps.defsStr}`
if (escapedName.length) {
nextSteps.escapedStepNames.forEach((escapedStep) => {
links.push(`${linePrefix}${escapedName} --> ${escapedStep}`)
})
} else {
escapedStepNames.push(...nextSteps.escapedStepNames)
}
links.push(...nextSteps.links)
}
}
return {
escapedStepNames,
links,
defsStr,
}
}
getSubgraph(defsStr: string, linePrefix: string): string {
return `${linePrefix}subgraph parallel${getRandomString()} [Parallel]${linePrefix}${defsStr}${linePrefix}end`
}
getEscapedStepName(originalName: string | undefined): string {
return originalName?.replaceAll("-", "") || ""
}
formatStepName(originalName: string): string {
if (!this.options?.prettyNames) {
return originalName
}
return originalName
.replaceAll("-", " ")
.replaceAll(/([A-Z])/g, " $1")
.split(" ")
.map((word) => `${word.charAt(0).toUpperCase()}${word.substring(1)}`)
.join(" ")
}
getLinePrefix(indentation = 0): string {
return `\n${DiagramBuilder.SPACING.repeat(indentation)}`
}
// TODO need to explore with this function
// for now it just returns the joined links, but
// it should split links on multiple lines in the
// diagram
formatLinks(links: string[], level = 2): string {
const linePrefix = this.getLinePrefix(level)
return links.join(linePrefix)
// This is used to ensure that a line doesn't get too long
// let nodesInCurrentLine = 0
// // TODO change this to be a command line option
// const maxNodesInLine = 3
// if (links.length <= maxNodesInLine) {
// return links.join(linePrefix)
// }
// let finalStr = ""
// links.forEach((link) => {
// if (nodesInCurrentLine === 0) {
// finalStr += "subgraph"
// }
// finalStr += link
// ++nodesInCurrentLine
// if (nodesInCurrentLine === maxNodesInLine) {
// finalStr += "end"
// nodesInCurrentLine = 0
// }
// })
// return finalStr
}
}
@@ -0,0 +1,83 @@
/* eslint-disable no-case-declarations */
import { WorkflowManager } from "@medusajs/orchestration"
import * as path from "path"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs"
import registerWorkflows from "../utils/register-workflows.js"
import DiagramBuilder from "../classes/diagram-builder.js"
import { run as runMermaid } from "@mermaid-js/mermaid-cli"
type Options = {
output: string
type: "docs" | "markdown" | "mermaid" | "console" | "svg" | "png" | "pdf"
theme: boolean
prettyNames: boolean
}
export default async function (workflowPath: string, options: Options) {
const workflowDefinitions = await registerWorkflows(workflowPath)
const diagramBuilder = new DiagramBuilder(options)
if (
workflowDefinitions.size > 0 &&
["svg", "png", "pdf"].includes(options.type)
) {
console.log(
`Generating ${options.type} file(s) with mermaid. This may take some time...`
)
}
for (const [name, code] of workflowDefinitions) {
const workflow = WorkflowManager.getWorkflow(name)
if (!workflow) {
continue
}
const diagram = diagramBuilder.buildDiagram(workflow.flow_)
if (!existsSync(options.output)) {
mkdirSync(options.output, { recursive: true })
}
switch (options.type) {
case "docs":
const workflowPath = path.join(options.output, name)
if (!existsSync(workflowPath)) {
mkdirSync(workflowPath, { recursive: true })
}
// write files
writeFileSync(path.join(workflowPath, "diagram.mermaid"), diagram)
if (code) {
writeFileSync(path.join(workflowPath, "code.ts"), code)
}
break
case "mermaid":
writeFileSync(path.join(options.output, `${name}.mermaid`), diagram)
break
case "markdown":
writeFileSync(
path.join(options.output, `${name}.md`),
`\`\`\`mermaid\n${diagram}\n\`\`\``
)
break
case "console":
console.log(`Diagram for workflow ${name}:\n${diagram}`)
break
case "svg":
case "png":
case "pdf":
const tempFilePath = path.join(options.output, `${name}.mermaid`)
writeFileSync(tempFilePath, diagram)
await runMermaid(
tempFilePath,
path.join(options.output, `${name}.${options.type}`),
{
quiet: true,
}
)
rmSync(tempFilePath)
}
}
console.log(`Generated diagrams for ${workflowDefinitions.size} workflows.`)
}
@@ -0,0 +1,37 @@
#!/usr/bin/env node
import { Command, Option } from "commander"
import generate from "./commands/generate.js"
const program = new Command()
program
.name("workflows-diagram-generator")
.description("Generate diagram(s) for workflow(s).")
program
.command("run")
.description(
"Generate Mermaid.js diagrams for your workflows based on the type you choose."
)
.argument(
"<workflowPath>",
"The path to a workflow file or a directory of workflow files."
)
.requiredOption(
"-o, --output <output>",
"The directory to output the files in."
)
.addOption(
new Option("-t, --type <type>", "Type of diagrams to be generated.")
.choices(["docs", "markdown", "mermaid", "console", "svg", "png", "pdf"])
.default("docs")
)
.option("--no-theme", "Remove theming from outputted diagrams.", true)
.option(
"--pretty-names",
"Prettify step names. Useful for creating presentational diagrams.",
false
)
.action(generate)
program.parse()
@@ -0,0 +1,3 @@
export default function (str: string): string {
return str.replaceAll(`"`, "#quot;").replaceAll("-", "&ndash;")
}
@@ -0,0 +1,5 @@
export const SPACING = "\t"
export function getLinePrefix(indentation = 0): string {
return `\n${SPACING.repeat(indentation)}`
}
@@ -0,0 +1,12 @@
export default function (length = 4) {
let result = ""
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
const charactersLength = characters.length
let counter = 0
while (counter < length) {
result += characters.charAt(Math.floor(Math.random() * charactersLength))
counter += 1
}
return result
}
@@ -0,0 +1,79 @@
import { statSync, readFileSync } from "fs"
import * as glob from "glob"
import path from "path"
import { fileURLToPath } from "url"
type FileInfo = {
workflowId: string
code: string
}
export default async function (
workflowPath: string
): Promise<Map<string, string>> {
const workflowDefinitions = new Map<string, string>()
const fileStat = statSync(workflowPath)
if (fileStat.isFile()) {
const fileInfo = await importFile(workflowPath)
if (fileInfo.workflowId.length && fileInfo.code.length) {
workflowDefinitions.set(fileInfo.workflowId, fileInfo.code)
}
} else {
const files = glob.sync(`${workflowPath}/**/*.{ts,js}`, {})
await Promise.all(
files.map(async (file) => {
const fileInfo = await importFile(file)
if (fileInfo.workflowId.length && fileInfo.code.length) {
workflowDefinitions.set(fileInfo.workflowId, fileInfo.code)
}
})
)
}
return workflowDefinitions
}
function getRelativeImportPath(filePath: string) {
const __filename = fileURLToPath(import.meta.url)
return path.relative(path.dirname(__filename), filePath)
}
async function importFile(filePath: string): Promise<FileInfo> {
const fileInfo: FileInfo = {
workflowId: "",
code: "",
}
const relativeFilePath = getRelativeImportPath(filePath)
const imported = await import(relativeFilePath)
fileInfo.code = readFileSync(filePath, "utf-8")
if (imported.default) {
switch (typeof imported.default) {
case "function":
fileInfo.workflowId = getWorkflowName(imported.default) || ""
break
case "object":
Object.values(imported.default).find((exportedVariable: unknown) => {
fileInfo.workflowId = getWorkflowName(exportedVariable) || ""
return fileInfo.workflowId.length !== 0
})
}
} else if (typeof imported === "object") {
Object.values(imported).find((exportedVariable: unknown) => {
fileInfo.workflowId = getWorkflowName(exportedVariable) || ""
return fileInfo.workflowId.length !== 0
})
}
return fileInfo
}
function getWorkflowName(variable: unknown): string | undefined {
return typeof variable === "function" &&
"getName" in variable &&
typeof variable.getName === "function"
? variable.getName()
: undefined
}