feat: render mermaid note diagrams
This commit is contained in:
@@ -76,7 +76,8 @@ function processMarkdownLine(
|
||||
}
|
||||
|
||||
function isFenceDelimiter({ line }: MarkdownLineValue): boolean {
|
||||
return line.trimStart().startsWith('```')
|
||||
const trimmed = line.trimStart()
|
||||
return trimmed.startsWith('```') || trimmed.startsWith('~~~')
|
||||
}
|
||||
|
||||
function normalizeMarkdownLine({ line }: MarkdownLineValue): string {
|
||||
|
||||
84
src/utils/mermaidMarkdown.test.ts
Normal file
84
src/utils/mermaidMarkdown.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MERMAID_BLOCK_TYPE,
|
||||
injectMermaidInBlocks,
|
||||
preProcessMermaidMarkdown,
|
||||
serializeMermaidAwareBlocks,
|
||||
} from './mermaidMarkdown'
|
||||
|
||||
describe('mermaid markdown round-trip', () => {
|
||||
it('injects fenced Mermaid source into dedicated diagram blocks', () => {
|
||||
const markdown = [
|
||||
'```mermaid',
|
||||
'flowchart LR',
|
||||
' A --> B',
|
||||
'```',
|
||||
].join('\n')
|
||||
const preprocessed = preProcessMermaidMarkdown({ markdown })
|
||||
const blocks = [{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: preprocessed, styles: {} }],
|
||||
children: [],
|
||||
}]
|
||||
|
||||
const [block] = injectMermaidInBlocks(blocks) as Array<{
|
||||
type: string
|
||||
props: { source: string; diagram: string }
|
||||
}>
|
||||
|
||||
expect(block.type).toBe(MERMAID_BLOCK_TYPE)
|
||||
expect(block.props.source).toBe(markdown)
|
||||
expect(block.props.diagram).toBe('flowchart LR\n A --> B\n')
|
||||
})
|
||||
|
||||
it('preserves multiple Mermaid blocks independently when serializing', () => {
|
||||
const editor = {
|
||||
blocksToMarkdownLossy: vi.fn((blocks: unknown[]) => {
|
||||
return (blocks as Array<{ content?: Array<{ text?: string }> }>)
|
||||
.map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '')
|
||||
.join('\n\n')
|
||||
}),
|
||||
}
|
||||
const firstSource = '```mermaid\nflowchart TD\nA --> B\n```'
|
||||
const secondSource = '~~~mermaid\nsequenceDiagram\nAlice->>Bob: Hi\n~~~'
|
||||
const blocks = [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Intro' }], children: [] },
|
||||
{ type: MERMAID_BLOCK_TYPE, props: { source: firstSource, diagram: 'flowchart TD\nA --> B\n' }, children: [] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Between' }], children: [] },
|
||||
{ type: MERMAID_BLOCK_TYPE, props: { source: secondSource, diagram: 'sequenceDiagram\nAlice->>Bob: Hi\n' }, children: [] },
|
||||
]
|
||||
|
||||
expect(serializeMermaidAwareBlocks(editor, blocks)).toBe([
|
||||
'Intro',
|
||||
firstSource,
|
||||
'Between',
|
||||
secondSource,
|
||||
].join('\n\n'))
|
||||
})
|
||||
|
||||
it('leaves non-Mermaid and unclosed fences as normal Markdown', () => {
|
||||
const markdown = [
|
||||
'```ts',
|
||||
'const graph = "mermaid"',
|
||||
'```',
|
||||
'',
|
||||
'```mermaid',
|
||||
'flowchart LR',
|
||||
].join('\n')
|
||||
|
||||
expect(preProcessMermaidMarkdown({ markdown })).toBe(markdown)
|
||||
})
|
||||
|
||||
it('serializes fallback source for Mermaid blocks created without original fence text', () => {
|
||||
const editor = { blocksToMarkdownLossy: vi.fn(() => '') }
|
||||
const blocks = [{
|
||||
type: MERMAID_BLOCK_TYPE,
|
||||
props: { source: '', diagram: 'flowchart LR\nA --> B' },
|
||||
children: [],
|
||||
}]
|
||||
|
||||
expect(serializeMermaidAwareBlocks(editor, blocks)).toBe(
|
||||
'```mermaid\nflowchart LR\nA --> B\n```',
|
||||
)
|
||||
})
|
||||
})
|
||||
241
src/utils/mermaidMarkdown.ts
Normal file
241
src/utils/mermaidMarkdown.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { serializeMathAwareBlocks } from './mathMarkdown'
|
||||
|
||||
export const MERMAID_BLOCK_TYPE = 'mermaidBlock'
|
||||
|
||||
const TOKEN_PREFIX = '@@TOLARIA_MERMAID_BLOCK:'
|
||||
const TOKEN_SUFFIX = '@@'
|
||||
|
||||
interface InlineItem {
|
||||
type: string
|
||||
text?: string
|
||||
props?: Record<string, string>
|
||||
content?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface BlockLike {
|
||||
type?: string
|
||||
content?: InlineItem[]
|
||||
props?: Record<string, string>
|
||||
children?: BlockLike[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface MarkdownSerializer {
|
||||
blocksToMarkdownLossy: (blocks: unknown[]) => string
|
||||
}
|
||||
|
||||
interface MermaidPayload {
|
||||
source: string
|
||||
diagram: string
|
||||
}
|
||||
|
||||
interface MermaidFenceStart {
|
||||
character: '`' | '~'
|
||||
length: number
|
||||
}
|
||||
|
||||
interface MarkdownLine {
|
||||
line: string
|
||||
}
|
||||
|
||||
interface EncodedPayload {
|
||||
encoded: string
|
||||
}
|
||||
|
||||
interface TokenText {
|
||||
text: string
|
||||
}
|
||||
|
||||
interface FenceSearch {
|
||||
lines: string[]
|
||||
start: number
|
||||
opening: MermaidFenceStart
|
||||
}
|
||||
|
||||
interface FenceRange {
|
||||
lines: string[]
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface DiagramSource {
|
||||
diagram: string
|
||||
}
|
||||
|
||||
function lineEnding({ line }: MarkdownLine): string {
|
||||
if (line.endsWith('\r\n')) return '\r\n'
|
||||
return line.endsWith('\n') ? '\n' : ''
|
||||
}
|
||||
|
||||
function lineText({ line }: MarkdownLine): string {
|
||||
const ending = lineEnding({ line })
|
||||
return ending ? line.slice(0, -ending.length) : line
|
||||
}
|
||||
|
||||
function splitMarkdownLines(markdown: string): string[] {
|
||||
const lines = markdown.match(/[^\n]*(?:\n|$)/g) ?? []
|
||||
return lines.filter((line, index) => line !== '' || index < lines.length - 1)
|
||||
}
|
||||
|
||||
function encodePayload(payload: MermaidPayload): string {
|
||||
return encodeURIComponent(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function decodePayload({ encoded }: EncodedPayload): MermaidPayload | null {
|
||||
try {
|
||||
const payload = JSON.parse(decodeURIComponent(encoded)) as Partial<MermaidPayload>
|
||||
if (typeof payload.source !== 'string') return null
|
||||
if (typeof payload.diagram !== 'string') return null
|
||||
return { source: payload.source, diagram: payload.diagram }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mermaidToken(payload: MermaidPayload): string {
|
||||
return `${TOKEN_PREFIX}${encodePayload(payload)}${TOKEN_SUFFIX}`
|
||||
}
|
||||
|
||||
function readMermaidToken({ text }: TokenText): MermaidPayload | null {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed.startsWith(TOKEN_PREFIX) || !trimmed.endsWith(TOKEN_SUFFIX)) return null
|
||||
return decodePayload({ encoded: trimmed.slice(TOKEN_PREFIX.length, -TOKEN_SUFFIX.length) })
|
||||
}
|
||||
|
||||
function readMermaidFenceStart({ line }: MarkdownLine): MermaidFenceStart | null {
|
||||
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*(.*)$/.exec(line)
|
||||
if (!match) return null
|
||||
|
||||
const fence = match[2]
|
||||
const language = match[3].trim().split(/\s+/)[0]?.toLowerCase()
|
||||
if (language !== 'mermaid') return null
|
||||
|
||||
return {
|
||||
character: fence[0] as '`' | '~',
|
||||
length: fence.length,
|
||||
}
|
||||
}
|
||||
|
||||
function isClosingFence({ line, opening }: MarkdownLine & { opening: MermaidFenceStart }): boolean {
|
||||
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line)
|
||||
if (!match) return false
|
||||
|
||||
const fence = match[2]
|
||||
return fence[0] === opening.character && fence.length >= opening.length
|
||||
}
|
||||
|
||||
function findClosingFence({ lines, start, opening }: FenceSearch): number {
|
||||
for (let index = start + 1; index < lines.length; index++) {
|
||||
if (isClosingFence({ line: lineText({ line: lines[index] }), opening })) return index
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function buildPayload({ lines, start, end }: FenceRange): MermaidPayload {
|
||||
return {
|
||||
source: lines.slice(start, end + 1).join(''),
|
||||
diagram: lines.slice(start + 1, end).join(''),
|
||||
}
|
||||
}
|
||||
|
||||
export function preProcessMermaidMarkdown({ markdown }: { markdown: string }): string {
|
||||
const lines = splitMarkdownLines(markdown)
|
||||
const result: string[] = []
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const opening = readMermaidFenceStart({ line: lineText({ line: lines[index] }) })
|
||||
if (!opening) {
|
||||
result.push(lines[index])
|
||||
continue
|
||||
}
|
||||
|
||||
const closingIndex = findClosingFence({ lines, start: index, opening })
|
||||
if (closingIndex === -1) {
|
||||
result.push(lines[index])
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = buildPayload({ lines, start: index, end: closingIndex })
|
||||
result.push(`${mermaidToken(payload)}${lineEnding({ line: lines[closingIndex] })}`)
|
||||
index = closingIndex
|
||||
}
|
||||
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
function readMermaidPayload(content: InlineItem[] | undefined): MermaidPayload | null {
|
||||
const onlyItem = content?.length === 1 ? content[0] : null
|
||||
if (onlyItem?.type !== 'text' || typeof onlyItem.text !== 'string') return null
|
||||
return readMermaidToken({ text: onlyItem.text })
|
||||
}
|
||||
|
||||
function buildMermaidBlock({ block, payload }: { block: BlockLike; payload: MermaidPayload }): BlockLike {
|
||||
return {
|
||||
...block,
|
||||
type: MERMAID_BLOCK_TYPE,
|
||||
props: {
|
||||
...(block.props ?? {}),
|
||||
source: payload.source,
|
||||
diagram: payload.diagram,
|
||||
},
|
||||
content: undefined,
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
function injectMermaidInBlock(block: BlockLike): BlockLike {
|
||||
const payload = readMermaidPayload(block.content)
|
||||
if (payload) return buildMermaidBlock({ block, payload })
|
||||
|
||||
const children = Array.isArray(block.children) ? block.children.map(injectMermaidInBlock) : block.children
|
||||
return { ...block, children }
|
||||
}
|
||||
|
||||
function isMermaidBlock(block: BlockLike): boolean {
|
||||
return block.type === MERMAID_BLOCK_TYPE
|
||||
&& typeof block.props?.source === 'string'
|
||||
&& typeof block.props?.diagram === 'string'
|
||||
}
|
||||
|
||||
function fallbackMermaidSource({ diagram }: DiagramSource): string {
|
||||
const body = diagram.endsWith('\n') ? diagram : `${diagram}\n`
|
||||
return `\`\`\`mermaid\n${body}\`\`\``
|
||||
}
|
||||
|
||||
function mermaidMarkdown(block: BlockLike): string {
|
||||
const source = block.props?.source
|
||||
if (source) return source
|
||||
|
||||
return fallbackMermaidSource({ diagram: block.props?.diagram ?? '' })
|
||||
}
|
||||
|
||||
export function injectMermaidInBlocks(blocks: unknown[]): unknown[] {
|
||||
return (blocks as BlockLike[]).map(injectMermaidInBlock)
|
||||
}
|
||||
|
||||
export function serializeMermaidAwareBlocks(editor: MarkdownSerializer, blocks: unknown[]): string {
|
||||
const chunks: string[] = []
|
||||
let pending: unknown[] = []
|
||||
|
||||
const flushPending = () => {
|
||||
if (pending.length === 0) return
|
||||
|
||||
const markdown = serializeMathAwareBlocks(editor, pending).trimEnd()
|
||||
if (markdown) chunks.push(markdown)
|
||||
pending = []
|
||||
}
|
||||
|
||||
for (const block of blocks as BlockLike[]) {
|
||||
if (isMermaidBlock(block)) {
|
||||
flushPending()
|
||||
chunks.push(mermaidMarkdown(block))
|
||||
} else {
|
||||
pending.push(block)
|
||||
}
|
||||
}
|
||||
|
||||
flushPending()
|
||||
return chunks.join('\n\n')
|
||||
}
|
||||
Reference in New Issue
Block a user