/**
* Minimal dependency-free Markdown renderer for canvas file previews.
*
* Everything parses into React elements — there is no `dangerouslySetInnerHTML`
* anywhere — so a hostile document cannot inject markup: text arrives in React
* text nodes, links are restricted to safe protocols, and block structure is
* deliberately simple (headings, paragraphs, fenced code, quotes, lists, GFM
* tables, images, emphasis). The host already caps the source, so every loop
* below is bounded by the 120k-character preview cap.
*/
import { Fragment, type ReactNode } from 'react'
/** Protocols an `` may point at; anything else stays plain text. */
const SAFE_LINK = /^(?:https?:\/\/|mailto:)/i
/** Protocols an `` may load; image data URLs are inert by construction. */
const SAFE_IMAGE = /^(?:https?:\/\/|data:image\/)/i
/**
* One inline pass: code spans, links, autolinks (bracketed and bare), images,
* bold, italic and strikethrough. Built per call because block parsing
* recurses (quotes) and a shared /g regex would tangle its lastIndex state.
*/
const INLINE_SOURCE = [
'(`+)([\\s\\S]*?)\\1', // 1-2 code span
'!\\[([^\\]]*)\\]\\(\\s*([^)\\s]*)(?:\\s+"[^"]*")?\\s*\\)', // 3-4 image
'\\[([^\\]]*)\\]\\(\\s*([^)\\s]*)(?:\\s+"[^"]*")?\\s*\\)', // 5-6 link
'<(https?:\\/\\/[^>\\s]+|mailto:[^>\\s]+)>', // 7 autolink
'(https?:\\/\\/[^\\s<>()\\[\\]{}"\'\u201c\u201d\u2018\u2019\uff0c\u3002\uff1b\uff1a\uff01\uff1f\uff08\uff09\u3010\u3011\u300a\u300b]+)', // 8 bare URL
'\\*\\*([\\s\\S]+?)\\*\\*', // 9 **bold**
'__([\\s\\S]+?)__', // 10 __bold__
'~~([\\s\\S]+?)~~', // 11 ~~strike~~
'\\*([^\\s*](?:[^*\\n]*[^\\s*])?)\\*', // 12 *italic*
].join('|')
function renderInline(source: string, keyPrefix: string): ReactNode[] {
const pattern = new RegExp(INLINE_SOURCE, 'g')
const nodes: ReactNode[] = []
let last = 0
let serial = 0
for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) {
if (match.index > last) nodes.push(source.slice(last, match.index))
const key = `${keyPrefix}i${serial++}`
if (match[2] !== undefined) {
nodes.push(
{match[2].replace(/^\n|\n$/g, '')})
} else if (match[4] !== undefined) {
const alt = match[3] ?? ''
nodes.push(SAFE_IMAGE.test(match[4])
?
: alt)
} else if (match[6] !== undefined) {
const label = match[5] ?? ''
nodes.push(SAFE_LINK.test(match[6])
? {renderInline(label, key)}
: label)
} else if (match[7] !== undefined) {
nodes.push({match[7]})
} else if (match[8] !== undefined) {
nodes.push({match[8]})
} else if (match[9] !== undefined || match[10] !== undefined) {
nodes.push({renderInline(match[9] ?? match[10] ?? '', key)})
} else if (match[11] !== undefined) {
nodes.push(
{renderInline(match[11]!, key)})
} else if (match[12] !== undefined) {
nodes.push({renderInline(match[12]!, key)})
}
last = pattern.lastIndex
}
if (last < source.length) nodes.push(source.slice(last))
return nodes
}
/** A line that ends the current paragraph and starts one of the block forms. */
const BLOCK_START = /^\s*(?:#{1,6}\s|`{3,}|~{3,}|>|(?:[-*+]|\d{1,9}[.)])\s)/
function splitTableRow(row: string): string[] {
let value = row.trim()
if (value.startsWith('|')) value = value.slice(1)
if (value.endsWith('|')) value = value.slice(0, -1)
return value.split('|').map(cell => cell.trim())
}
function renderBlocks(lines: string[], keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = []
let serial = 0
const key = (): string => `${keyPrefix}b${serial++}`
let cursor = 0
while (cursor < lines.length) {
const line = lines[cursor]!
if (line.trim() === '') { cursor += 1; continue }
// fenced code ``` / ~~~
const fence = /^(`{3,}|~{3,})/.exec(line)
if (fence !== null) {
const marker = fence[1]!
const body: string[] = []
cursor += 1
while (cursor < lines.length && !lines[cursor]!.startsWith(marker)) { body.push(lines[cursor]!); cursor += 1 }
if (cursor < lines.length) cursor += 1
nodes.push(
{body.join('\n')})
continue
}
// heading
const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)
if (heading !== null) {
const level = Math.min(heading[1]!.length, 6)
const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
nodes.push({renderBlocks(quoted, key())}) continue } // GFM table: header row, then a delimiter row of dashes if (line.includes('|') && cursor + 1 < lines.length && /^\s*\|?\s*:?-{2,}[\s:|-]*$/.test(lines[cursor + 1]!) && lines[cursor + 1]!.includes('|')) { const header = splitTableRow(line) cursor += 2 const rows: string[][] = [] while (cursor < lines.length && lines[cursor]!.trim() !== '' && lines[cursor]!.includes('|') && rows.length < 200) { rows.push(splitTableRow(lines[cursor]!)) cursor += 1 } nodes.push(
| {renderInline(cell, key())} | )}
|---|
| {renderInline(row[cellIndex] ?? '', key())} | )}
{renderInline(paragraph.join('\n'), key())}
) } return nodes } /** Render one Markdown document into React block elements. */ export function renderMarkdown(source: string): ReactNode[] { return renderBlocks(source.replace(/\r\n?/g, '\n').split('\n'), 'md') }