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:
@@ -0,0 +1,52 @@
|
||||
# Workflows Diagram Generator
|
||||
|
||||
An internal tool to generate [Mermaid](https://mermaid.js.org/) diagrams for workflows.
|
||||
|
||||
> Note: This tool is a beta tool created to generate diagrams that can be used in the Medusa documentation.
|
||||
|
||||
## Usage
|
||||
|
||||
After installing the dependencies, run the following command:
|
||||
|
||||
```bash
|
||||
yarn start run ./path/to/workflow -o ./path/to/output/dir
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- `./path/to/workflow` is the path to a file containing a Workflow, or a directory containing more than one file.
|
||||
- `./path/to/output/dir` is the path to the directory that outputted diagrams should be placed in.
|
||||
|
||||
### Command Options
|
||||
|
||||
#### --t, --type
|
||||
|
||||
```bash
|
||||
yarn start run ./path/to/workflow -o ./path/to/output/dir -t markdown
|
||||
```
|
||||
|
||||
The `type` of diagram to be generated. It can be one of the following:
|
||||
|
||||
- `docs` (default): For each workflow, it creates a directory holding the diagram of the workflow and its code in separate files. Diagrams are placed in `.mermaid` files.
|
||||
- `markdown`: Generates the diagram of each workflow in a `.md` file.
|
||||
- `mermaid`: Generates the diagram of each workflow in a `.mermaid` file.
|
||||
- `console`: Outputs the diagrams in the console.
|
||||
- `svg`: Generates the diagram in SVG format.
|
||||
- `png`: Generates the diagram in PNG format.
|
||||
- `pdf`: Generates the diagram in PDF format.
|
||||
|
||||
#### --no-theme
|
||||
|
||||
```bash
|
||||
yarn start run ./path/to/workflow -o ./path/to/output/dir --no-theme
|
||||
```
|
||||
|
||||
Removes Medusa's default theming from the outputted diagram. Note that Medusa's theme doesn't support dark mode.
|
||||
|
||||
#### --pretty-names
|
||||
|
||||
```bash
|
||||
yarn start run ./path/to/workflow -o ./path/to/output/dir --pretty-names
|
||||
```
|
||||
|
||||
Changes slug and camel-case names of steps to capitalized names.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "workflows-diagram-generator",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "ts-node src/index.ts",
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"prepublishOnly": "cross-env NODE_ENV=production tsc --build"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
"bin": {
|
||||
"workflow-diagrams-generator": "dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/workflows-sdk": "latest",
|
||||
"@mermaid-js/mermaid-cli": "^10.6.1",
|
||||
"commander": "^11.1.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.4"
|
||||
}
|
||||
}
|
||||
@@ -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("-", "–")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "node16",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
},
|
||||
"include": ["src"],
|
||||
"ts-node": {
|
||||
"esm": true,
|
||||
"experimentalSpecifierResolution": "node",
|
||||
"transpileOnly": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user