diff --git a/packages/editor/src/code/code-block-drawing-utils.test.ts b/packages/editor/src/code/code-block-drawing-utils.test.ts new file mode 100644 index 00000000..04a78a96 --- /dev/null +++ b/packages/editor/src/code/code-block-drawing-utils.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" +import { + createCodeDrawingNodeFromCodeBlock, + getCodeBlockText, + isMermaidCodeBlockLanguage, +} from "./code-block-drawing-utils" +import { CODE_DRAWING_KEY } from "./code-drawing-kit" + +describe("code-block-drawing-utils", () => { + it("detects the Mermaid code block language case-insensitively", () => { + expect(isMermaidCodeBlockLanguage("mermaid")).toBe(true) + expect(isMermaidCodeBlockLanguage(" Mermaid ")).toBe(true) + expect(isMermaidCodeBlockLanguage("plantuml")).toBe(false) + }) + + it("joins code block lines into a single code string", () => { + const code = getCodeBlockText({ + children: [ + { type: "code_line", children: [{ text: "flowchart TD" }] }, + { type: "code_line", children: [{ text: " A --> B" }] }, + ], + } as any) + + expect(code).toBe("flowchart TD\n A --> B") + }) + + it("creates a Mermaid code drawing node from a code block", () => { + const node = createCodeDrawingNodeFromCodeBlock({ + children: [ + { type: "code_line", children: [{ text: "sequenceDiagram" }] }, + { type: "code_line", children: [{ text: " Alice->>Bob: Hello" }] }, + ], + } as any) + + expect(node).toEqual({ + type: CODE_DRAWING_KEY, + data: { + code: "sequenceDiagram\n Alice->>Bob: Hello", + drawingMode: "Both", + drawingType: "Mermaid", + }, + children: [{ text: "" }], + }) + }) +}) diff --git a/packages/editor/src/code/code-block-drawing-utils.ts b/packages/editor/src/code/code-block-drawing-utils.ts new file mode 100644 index 00000000..112467d6 --- /dev/null +++ b/packages/editor/src/code/code-block-drawing-utils.ts @@ -0,0 +1,34 @@ +import { NodeApi, type TCodeBlockElement } from "platejs" +import { CODE_DRAWING_KEY } from "./code-drawing-kit" + +export const MERMAID_CODE_BLOCK_LANGUAGE = "mermaid" + +export function isMermaidCodeBlockLanguage(lang: string): boolean { + return lang.trim().toLowerCase() === MERMAID_CODE_BLOCK_LANGUAGE +} + +export function getCodeBlockText( + codeBlock: Pick, +): string { + if (!Array.isArray(codeBlock.children)) { + return "" + } + + return codeBlock.children + .map((child) => NodeApi.string(child as any)) + .join("\n") +} + +export function createCodeDrawingNodeFromCodeBlock( + codeBlock: Pick, +) { + return { + type: CODE_DRAWING_KEY, + data: { + code: getCodeBlockText(codeBlock), + drawingMode: "Both", + drawingType: "Mermaid", + }, + children: [{ text: "" }], + } +} diff --git a/packages/editor/src/code/node-code-block.tsx b/packages/editor/src/code/node-code-block.tsx index 0bd7c359..479e2847 100644 --- a/packages/editor/src/code/node-code-block.tsx +++ b/packages/editor/src/code/node-code-block.tsx @@ -26,6 +26,10 @@ import { useReadOnly, } from "platejs/react" import { useEffect, useMemo, useState } from "react" +import { + createCodeDrawingNodeFromCodeBlock, + isMermaidCodeBlockLanguage, +} from "./code-block-drawing-utils" export function CodeBlockElement(props: PlateElementProps) { const { editor, element } = props @@ -140,10 +144,20 @@ function CodeBlockCombobox() { className="cursor-pointer" value={language.value} onSelect={(value) => { - editor.tf.setNodes( - { lang: value }, - { at: element }, - ) + if (isMermaidCodeBlockLanguage(value)) { + const path = editor.api.findPath(element) + if (path) { + editor.tf.replaceNodes( + createCodeDrawingNodeFromCodeBlock(element) as any, + { at: path }, + ) + } + } else { + editor.tf.setNodes( + { lang: value }, + { at: element }, + ) + } setSearchValue(value) setOpen(false) }} diff --git a/packages/editor/src/markdown/markdown-kit.test.ts b/packages/editor/src/markdown/markdown-kit.test.ts index 7ea7d394..5b516cf3 100644 --- a/packages/editor/src/markdown/markdown-kit.test.ts +++ b/packages/editor/src/markdown/markdown-kit.test.ts @@ -1,6 +1,7 @@ import { deserializeMd, serializeMd } from "@platejs/markdown" import { createSlateEditor, KEYS } from "platejs" import { describe, expect, it } from "vitest" +import { CODE_DRAWING_KEY } from "../code" import { createNoteTitleBlock } from "../title" import { MarkdownKit, MarkdownKitNoMdx } from "./markdown-kit" import { OBSIDIAN_EMBED_KEY } from "./obsidian-embed-plugin" @@ -392,6 +393,44 @@ describe("markdown-kit serialization", () => { expect(markdown).toContain("\\end{equation}") }) + it("serializes Mermaid code drawings as fenced Mermaid blocks", async () => { + const editor = createMarkdownEditor() + const value = [ + { + type: CODE_DRAWING_KEY, + data: { + code: "flowchart TD\n A --> B", + drawingType: "Mermaid", + }, + children: [{ text: "" }], + }, + ] + + const markdown = serializeMd(editor, { value }) + expect(markdown).toContain("```mermaid") + expect(markdown).toContain("flowchart TD") + expect(markdown).toContain("A --> B") + }) + + it("serializes PlantUml code drawings as fenced plantuml blocks", async () => { + const editor = createMarkdownEditor() + const value = [ + { + type: CODE_DRAWING_KEY, + data: { + code: "@startuml\nAlice -> Bob: Hello\n@enduml", + drawingType: "PlantUml", + }, + children: [{ text: "" }], + }, + ] + + const markdown = serializeMd(editor, { value }) + expect(markdown).toContain("```plantuml") + expect(markdown).toContain("@startuml") + expect(markdown).toContain("Alice -> Bob: Hello") + }) + it("serializes canonical tags rows as a yaml list", async () => { const editor = createMarkdownEditor() const value = [ @@ -591,6 +630,53 @@ describe("markdown-kit deserialization", () => { }) }) + it("deserializes Mermaid fenced code blocks into code drawings", async () => { + const editor = createMarkdownEditor() + const value = deserializeMd( + editor, + "```mermaid\nflowchart TD\n A --> B\n```", + ) + const codeDrawingNode = findNodeByType(value as any[], CODE_DRAWING_KEY) + + expect(codeDrawingNode).toMatchObject({ + type: CODE_DRAWING_KEY, + data: { + code: "flowchart TD\n A --> B", + drawingType: "Mermaid", + }, + }) + }) + + it("deserializes plantuml fenced code blocks into PlantUml drawings", async () => { + const editor = createMarkdownEditor() + const value = deserializeMd( + editor, + "```plantuml\n@startuml\nAlice -> Bob: Hello\n@enduml\n```", + ) + const codeDrawingNode = findNodeByType(value as any[], CODE_DRAWING_KEY) + + expect(codeDrawingNode).toMatchObject({ + type: CODE_DRAWING_KEY, + data: { + code: "@startuml\nAlice -> Bob: Hello\n@enduml", + drawingType: "PlantUml", + }, + }) + }) + + it("round-trips Mermaid fenced code blocks", async () => { + const editor = createMarkdownEditor() + const value = deserializeMd( + editor, + "```mermaid\nsequenceDiagram\n Alice->>Bob: Hello\n```", + ) + + const markdown = serializeMd(editor, { value: value as any }) + expect(markdown).toContain("```mermaid") + expect(markdown).toContain("sequenceDiagram") + expect(markdown).toContain("Alice->>Bob: Hello") + }) + it("deserializes Obsidian callouts into callout nodes", async () => { const editor = createMarkdownEditor() const value = deserializeMd(editor, "> [!warning] Watch out\n> Body") diff --git a/packages/editor/src/markdown/markdown-kit.ts b/packages/editor/src/markdown/markdown-kit.ts index ea335648..e0b6cc8e 100644 --- a/packages/editor/src/markdown/markdown-kit.ts +++ b/packages/editor/src/markdown/markdown-kit.ts @@ -21,6 +21,7 @@ import remarkFrontmatter from "remark-frontmatter" import remarkGfm from "remark-gfm" import remarkMath from "remark-math" import YAML from "yaml" +import { CODE_DRAWING_KEY } from "../code/code-drawing-kit" import type { FrontmatterRow as KVRow } from "../frontmatter" import { convertValueToType, @@ -67,9 +68,11 @@ type MdastRoot = { type MdastNode = { type?: string children?: MdastNode[] + lang?: string value?: string data?: { alias?: string + drawingType?: string hName?: string hProperties?: Record path?: string @@ -81,6 +84,16 @@ type SlateNodeWithChildren = { [key: string]: unknown } +const DRAWING_TYPE_BY_FENCE_LANGUAGE = { + flowchart: "Flowchart", + graphviz: "Graphviz", + mermaid: "Mermaid", + plantuml: "PlantUml", +} as const + +type SupportedDrawingType = + (typeof DRAWING_TYPE_BY_FENCE_LANGUAGE)[keyof typeof DRAWING_TYPE_BY_FENCE_LANGUAGE] + function createRowId() { return Math.random().toString(36).slice(2, 9) } @@ -244,6 +257,56 @@ function createEmbedNodeData( } } +function visitMdast(node: MdastNode, visitor: (node: MdastNode) => void) { + visitor(node) + + for (const child of node.children ?? []) { + visitMdast(child, visitor) + } +} + +function fenceLanguageToDrawingType( + lang: unknown, +): SupportedDrawingType | undefined { + if (typeof lang !== "string") { + return undefined + } + + return DRAWING_TYPE_BY_FENCE_LANGUAGE[ + lang.trim().toLowerCase() as keyof typeof DRAWING_TYPE_BY_FENCE_LANGUAGE + ] +} + +function drawingTypeToFenceLanguage(drawingType: unknown): string { + switch (drawingType) { + case "Flowchart": + return "flowchart" + case "Graphviz": + return "graphviz" + case "PlantUml": + return "plantuml" + default: + return "mermaid" + } +} + +export function remarkCodeDrawingBridge() { + return (tree: MdastRoot) => { + visitMdast(tree, (node) => { + if (node.type !== "code") return + + const drawingType = fenceLanguageToDrawingType(node.lang) + if (!drawingType) return + + node.type = "codeDrawing" + node.data = { + ...node.data, + drawingType, + } + }) + } +} + export type CreateMarkdownKitOptions = { mdx?: boolean } @@ -259,6 +322,7 @@ export const createMarkdownKit = ({ remarkFrontmatter, remarkWikiLink, remarkCallout, + remarkCodeDrawingBridge, remarkObsidianCalloutBridge, ] as any[] @@ -335,6 +399,20 @@ export const createMarkdownKit = ({ } }, }, + [CODE_DRAWING_KEY]: { + serialize: ( + node: SlateNodeWithChildren & { + data?: { + code?: string + drawingType?: string + } + }, + ) => ({ + type: "code", + lang: drawingTypeToFenceLanguage(node.data?.drawingType), + value: node.data?.code ?? "", + }), + }, yaml: { deserialize: ( mdastNode: MdRootContent, @@ -352,6 +430,22 @@ export const createMarkdownKit = ({ } }, }, + codeDrawing: { + deserialize: (mdastNode: MdastNode) => { + const drawingType = + fenceLanguageToDrawingType(mdastNode.data?.drawingType) ?? + "Mermaid" + + return { + type: CODE_DRAWING_KEY, + data: { + code: mdastNode.value ?? "", + drawingType, + }, + children: [{ text: "" }], + } + }, + }, [KEYS.link]: { serialize: ( node: SlateNodeWithChildren & {