= {
':fa-gear:': '\u2699\ufe0f',
':fa-star:': '\u2b50',
':smiley:': '\ud83d\ude03',
':star:': '\u2b50',
}
const allowedAttributes = [
'checked',
'class',
'd',
'data-diagram',
'disabled',
'fill',
'height',
'id',
'marker-end',
'markerheight',
'markerHeight',
'markerUnits',
'markerunits',
'markerWidth',
'markerwidth',
'offset',
'orient',
'points',
'preserveAspectRatio',
'preserveaspectratio',
'r',
'refX',
'refx',
'refY',
'refy',
'rx',
'ry',
'stroke',
'stroke-dasharray',
'stroke-width',
'style',
'target',
'text-anchor',
'dominant-baseline',
'dy',
'viewBox',
'viewbox',
'width',
'x',
'x1',
'x2',
'y',
'y1',
'y2',
]
const allowedTags = [
'annotation',
'circle',
'defs',
'ellipse',
'line',
'math',
'marker',
'mfrac',
'mi',
'mn',
'mo',
'mover',
'mpadded',
'mrow',
'mspace',
'msqrt',
'mstyle',
'msub',
'msubsup',
'msup',
'mtable',
'mtd',
'mtext',
'mtr',
'path',
'polygon',
'rect',
'semantics',
'stop',
'svg',
'text',
'tspan',
]
const sanitizeOptions = {
ADD_ATTR: allowedAttributes,
ADD_TAGS: allowedTags,
} as const
type FlowNode = {
id: string
label: string
type: string
}
type FlowEdge = {
from: string
label?: string
to: string
}
type FlowNodeLayout = {
height: number
labelLines: string[]
node: FlowNode
width: number
x: number
y: number
}
type SequenceMessage = {
from?: string
isNote?: boolean
label: string
lineStyle?: 'solid' | 'dashed'
noteSide?: 'left' | 'right'
target: string
to?: string
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
}
function normalizeMathSource(source: string): string {
return source
.trim()
.replace(/^\\\(/, '')
.replace(/\\\)$/, '')
.replace(/^\\\[/, '')
.replace(/\\\]$/, '')
}
function renderMath(source: string, displayMode: boolean): string {
return katex.renderToString(normalizeMathSource(source), {
displayMode,
output: 'htmlAndMathml',
throwOnError: false,
})
}
function replaceEmojiShortcodes(value: string): string {
return value.replaceAll(/:(?:smiley|star|fa-star|fa-gear):/g, (shortcode) => {
return emojiShortcodes[shortcode] ?? shortcode
})
}
function getTextUnits(value: string): number {
return [...value].reduce((total, character) => {
if (/\s/.test(character)) {
return total + 0.5
}
if (/[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/.test(character)) {
return total + 2
}
return total + 1
}, 0)
}
function splitFlowLabel(label: string, maxUnits: number): string[] {
const words = label.trim().split(/(\s+)/).filter(Boolean)
const lines: string[] = []
let currentLine = ''
words.forEach((word) => {
const candidate = `${currentLine}${word}`
if (currentLine && getTextUnits(candidate) > maxUnits) {
lines.push(currentLine.trim())
currentLine = word.trimStart()
return
}
currentLine = candidate
})
if (currentLine.trim()) {
lines.push(currentLine.trim())
}
return lines.length > 0 ? lines : [label]
}
function renderFlowText(layout: FlowNodeLayout): string {
const lineHeight = 18
const firstLineY =
layout.y - ((layout.labelLines.length - 1) * lineHeight) / 2 + 5
return layout.labelLines
.map((line, index) => {
return `${escapeHtml(line)}`
})
.join('')
}
function getFlowNodeLayout(
node: FlowNode,
index: number,
centerX: number
): FlowNodeLayout {
const isCondition = node.type === 'condition'
const labelLines = splitFlowLabel(node.label, isCondition ? 14 : 18)
const labelWidth = Math.max(
...labelLines.map((line) => getTextUnits(line) * 7.2)
)
const textHeight = labelLines.length * 18
if (isCondition) {
return {
height: Math.max(112, textHeight + 76),
labelLines,
node,
width: Math.max(190, labelWidth + 92),
x: centerX,
y: 64 + index * 132,
}
}
if (node.type === 'start' || node.type === 'end') {
return {
height: 38,
labelLines,
node,
width: Math.max(124, labelWidth + 44),
x: centerX,
y: 64 + index * 132,
}
}
return {
height: Math.max(54, textHeight + 28),
labelLines,
node,
width: Math.max(166, labelWidth + 52),
x: centerX,
y: 64 + index * 132,
}
}
function getFlowAnchor(
layout: FlowNodeLayout,
side: 'bottom' | 'left' | 'right' | 'top'
): {
x: number
y: number
} {
if (side === 'top') {
return { x: layout.x, y: layout.y - layout.height / 2 }
}
if (side === 'bottom') {
return { x: layout.x, y: layout.y + layout.height / 2 }
}
if (side === 'left') {
return { x: layout.x - layout.width / 2, y: layout.y }
}
return { x: layout.x + layout.width / 2, y: layout.y }
}
function renderFlowShape(layout: FlowNodeLayout): string {
const halfWidth = layout.width / 2
const halfHeight = layout.height / 2
const label = renderFlowText(layout)
if (layout.node.type === 'condition') {
return `
${label}
`
}
if (layout.node.type === 'start' || layout.node.type === 'end') {
return `
${label}
`
}
return `
${label}
`
}
function parseFlowDiagram(source: string): {
edges: FlowEdge[]
nodes: FlowNode[]
} {
const lines = source
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const nodes: FlowNode[] = []
const edges: FlowEdge[] = []
lines.forEach((line) => {
const nodeMatch = /^([A-Za-z][\w-]*)=>([A-Za-z]+):\s*(.+)$/.exec(line)
if (nodeMatch) {
const [, id, type, label] = nodeMatch
nodes.push({ id, label, type: type.toLowerCase() })
return
}
const edgeParts = line.split('->')
if (edgeParts.length < 2) {
return
}
for (let index = 0; index < edgeParts.length - 1; index += 1) {
const fromMatch = /^([A-Za-z][\w-]*)(?:\(([^)]+)\))?$/.exec(
edgeParts[index]
)
const toMatch = /^([A-Za-z][\w-]*)(?:\(([^)]+)\))?$/.exec(
edgeParts[index + 1]
)
if (!fromMatch || !toMatch) {
continue
}
const from = fromMatch[1]
const to = toMatch[1]
const edgeLabel = fromMatch[2]
edges.push({ from, label: edgeLabel, to })
}
})
return { edges, nodes }
}
function renderFlowDiagram(source: string): string {
const { edges, nodes } = parseFlowDiagram(source)
const width = 660
const centerX = 300
const loopX = 520
const nodeIndex = new Map(nodes.map((node, index) => [node.id, index]))
const nodePositions = new Map(
nodes.map((node, index) => [
node.id,
getFlowNodeLayout(node, index, centerX),
])
)
const lastNode =
nodes.length > 0 ? nodePositions.get(nodes.at(-1)?.id ?? '') : undefined
const height = Math.max(
180,
(lastNode?.y ?? 64) + (lastNode?.height ?? 40) / 2 + 54
)
const renderedEdges = edges
.map((edge) => {
const from = nodePositions.get(edge.from)
const to = nodePositions.get(edge.to)
if (!from || !to) {
return ''
}
const isBackward =
(nodeIndex.get(edge.to) ?? 0) <= (nodeIndex.get(edge.from) ?? 0)
if (isBackward) {
const fromAnchor = getFlowAnchor(from, 'right')
const toAnchor = getFlowAnchor(to, 'right')
const d = `M ${fromAnchor.x} ${fromAnchor.y} C ${loopX} ${fromAnchor.y}, ${loopX} ${toAnchor.y}, ${toAnchor.x} ${toAnchor.y}`
const label = edge.label
? `${escapeHtml(edge.label)}`
: ''
return `${label}`
}
const fromAnchor = getFlowAnchor(from, 'bottom')
const toAnchor = getFlowAnchor(to, 'top')
const label = edge.label
? `${escapeHtml(edge.label)}`
: ''
return `
${label}
`
})
.join('')
const renderedNodes = nodes
.map((node) => {
const position = nodePositions.get(node.id)
if (!position) {
return ''
}
return renderFlowShape(position)
})
.join('')
return `
`
}
function parseSequenceDiagram(source: string): {
messages: SequenceMessage[]
participants: string[]
} {
const lines = source
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const participants: string[] = []
const messages: SequenceMessage[] = []
function addParticipant(name: string): void {
if (!participants.includes(name)) {
participants.push(name)
}
}
lines.forEach((line) => {
const noteMatch = /^Note\s+(left|right)\s+of\s+([^:]+):\s*(.+)$/.exec(line)
if (noteMatch) {
const [, side, target, label] = noteMatch
const participant = target.trim()
addParticipant(participant)
messages.push({
isNote: true,
label: label.replaceAll('\\n', '\n'),
noteSide: side as 'left' | 'right',
target: participant,
})
return
}
const messageMatch = /^([^-\s]+)\s*(-{1,2}>>?|-->)\s*([^:]+):\s*(.+)$/.exec(
line
)
if (!messageMatch) {
return
}
const [, from, arrow, to, label] = messageMatch
const fromName = from.trim()
const toName = to.trim()
addParticipant(fromName)
addParticipant(toName)
messages.push({
from: fromName,
label,
lineStyle: arrow.startsWith('--') ? 'dashed' : 'solid',
target: toName,
to: toName,
})
})
return { messages, participants }
}
function renderSequenceDiagram(source: string): string {
const { messages, participants } = parseSequenceDiagram(source)
const laneGap = 190
const marginX = 80
const top = 42
const rowGap = 72
const width = Math.max(
360,
marginX * 2 + Math.max(0, participants.length - 1) * laneGap
)
const height = Math.max(180, 126 + messages.length * rowGap)
const positions = new Map(
participants.map((participant, index) => [
participant,
marginX + index * laneGap,
])
)
const participantBoxes = participants
.map((participant) => {
const x = positions.get(participant) ?? marginX
const label = escapeHtml(participant)
return `
${label}
${label}
`
})
.join('')
const renderedMessages = messages
.map((message, index) => {
const y = top + 78 + index * rowGap
if (message.isNote) {
const targetX = positions.get(message.target) ?? marginX
const noteX = message.noteSide === 'left' ? targetX - 154 : targetX + 24
const lines = message.label.split('\n')
const noteHeight = 28 + Math.max(0, lines.length - 1) * 16
const textLines = lines
.map((line, lineIndex) => {
return `${escapeHtml(line)}`
})
.join('')
return `
${textLines}
`
}
const fromX = positions.get(message.from ?? '') ?? marginX
const toX = positions.get(message.to ?? '') ?? marginX
const labelX = (fromX + toX) / 2
const label = escapeHtml(message.label)
const dash =
message.lineStyle === 'dashed' ? ' stroke-dasharray="4 4"' : ''
return `
${label}
`
})
.join('')
return `
`
}
const markdownRenderer = new Renderer()
const renderDefaultCode = markdownRenderer.code.bind(markdownRenderer)
markdownRenderer.code = (token: Tokens.Code): string => {
const language = token.lang?.toLowerCase()
if (language === 'math' || language === 'katex' || language === 'latex') {
return renderMath(token.text, true)
}
if (language === 'flow') {
return renderFlowDiagram(token.text)
}
if (language === 'seq') {
return renderSequenceDiagram(token.text)
}
return renderDefaultCode(token)
}
const markdownExtensions: MarkedExtension[] = [
{
walkTokens(token) {
if (token.type !== 'text') {
return
}
token.text = replaceEmojiShortcodes(token.text)
},
extensions: [
{
level: 'block',
name: 'pageBreak',
renderer() {
return '
'
},
start(source: string) {
return source.match(/^\[========\]/m)?.index
},
tokenizer(source: string) {
const match = /^\[========\](?:\n|$)/.exec(source)
if (!match) {
return undefined
}
return {
raw: match[0],
type: 'pageBreak',
}
},
},
{
level: 'block',
name: 'blockMath',
renderer(token) {
return renderMath(String(token.text), true)
},
start(source: string) {
return source.match(/^\$\$/m)?.index
},
tokenizer(source: string) {
const match = /^\$\$\n?([\s\S]+?)\n?\$\$(?:\n|$)/.exec(source)
if (!match) {
return undefined
}
return {
raw: match[0],
text: match[1],
type: 'blockMath',
}
},
},
{
level: 'inline',
name: 'inlineMath',
renderer(token) {
return renderMath(String(token.text), false)
},
start(source: string) {
const index = source.indexOf('$$')
if (index === -1) {
return undefined
}
return index
},
tokenizer(source: string) {
const match = /^\$\$([^\n$]+?)\$\$/.exec(source)
if (!match) {
return undefined
}
return {
raw: match[0],
text: match[1],
type: 'inlineMath',
}
},
},
],
},
]
const markdownParser = new Marked({
...markdownOptions,
renderer: markdownRenderer,
})
markdownParser.use(...markdownExtensions)
function addExternalLinkAttributes(html: string): string {
if (typeof window === 'undefined') {
return html
}
const template = document.createElement('template')
template.innerHTML = html
template.content.querySelectorAll('a[href]').forEach((link) => {
link.setAttribute('target', '_blank')
link.setAttribute('rel', 'noopener noreferrer')
})
return template.innerHTML
}
function renderMarkdown(markdown: string, breaks = false): string {
const parsedHtml = markdownParser.parse(markdown, {
...markdownOptions,
breaks,
})
const html = DOMPurify.sanitize(parsedHtml, sanitizeOptions)
return addExternalLinkAttributes(html)
}
export function Markdown(props: MarkdownProps) {
const html = useMemo(
() => renderMarkdown(props.children, props.breaks),
[props.breaks, props.children]
)
return (
*:first-child]:mt-0 [&>*:last-child]:mb-0',
'[overflow-wrap:anywhere]',
props.className
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}