UNPKG

@tanstack/markdown

Version:

A tiny, fast, deterministic Markdown renderer for blogs and documentation.

478 lines (477 loc) 18.1 kB
import { parseInline } from './inline.js'; import { createSlugger, footnoteId, isBlank, normalizeInput, normalizeReferenceLabel, parseDestination, plainText, stripIndent } from './utils.js'; const maxBlockDepth = 64; export function parseMarkdown(markdown, options = {}) { const normalized = normalizeInput(markdown); const frontmatterEnabled = options.frontmatter !== false; let lines = normalized.split('\n'); let frontmatter; if (frontmatterEnabled && lines[0] === '---') { const end = lines.findIndex((line, index) => index > 0 && line === '---'); if (end > 0) { frontmatter = lines.slice(1, end).join('\n'); lines = lines.slice(end + 1); } } const definitions = extractDefinitions(lines); lines = definitions.lines; const footnoteOrder = []; const footnoteCounts = Object.create(null); const hasReferences = Object.keys(definitions.references).length > 0; const hasFootnotes = Object.keys(definitions.footnotes).length > 0; const parseOptions = hasReferences || hasFootnotes ? { ...options, ...(hasReferences && { references: definitions.references }), ...(hasFootnotes && { footnotes: definitions.footnotes, footnoteOrder, footnoteCounts }), } : options; const slugger = createSlugger(); const parser = createBlockParser(lines, parseOptions, slugger); const children = parser.parse(); if (hasFootnotes && footnoteOrder.length > 0) { children.push(createFootnotesBlock(definitions.footnotes, footnoteOrder, parseOptions, slugger)); } let document = frontmatter === undefined ? { type: 'root', children } : { type: 'root', frontmatter, children }; for (const extension of parseOptions.extensions ?? []) { document = extension.transformDocument?.(document, { options: parseOptions }) ?? document; } return document; } function createBlockParser(inputLines, options, slugger, budget = { depth: 0 }) { let cursor = 0; let looseBlocks = false; return { parse, get loose() { return looseBlocks; }, }; function parse() { if (budget.depth >= maxBlockDepth) { const value = inputLines.slice(cursor).join('\n'); cursor = inputLines.length; return value ? [{ type: 'paragraph', children: parseInline(value, options) }] : []; } budget.depth++; const nodes = []; while (cursor < inputLines.length) { if (isBlank(current())) { if (nodes.length) looseBlocks = true; cursor++; continue; } const extensionNode = parseExtensionBlock(); if (extensionNode) { nodes.push(extensionNode); continue; } // Letter-led text cannot open a fence, heading, rule, quote, or list. // It can still be a table header, so table detection remains below. const node = (/^[a-z]/i.test(current()) ? undefined : (parseFence() ?? parseHeading() ?? parseThematicBreak() ?? parseBlockquote() ?? parseList())) ?? parseTable() ?? parseHtmlBlock() ?? parseParagraph(); nodes.push(node); } budget.depth--; return nodes; } function parseExtensionBlock() { for (const extension of options.extensions ?? []) { let consumed = 0; const node = extension.parseBlock?.({ lines: inputLines, index: cursor, options: options, parseInline: value => parseInline(value, options), parseBlocks: value => createBlockParser(normalizeInput(value).split('\n'), options, slugger, budget).parse(), consume: lines => { consumed = lines; }, }); if (node) { cursor += Math.max(consumed, 1); return node; } } return undefined; } function parseFence() { const match = current().match(/^( {0,3})(`{3,}|~{3,})(.*)$/); if (!match) return undefined; const fence = match[2]; const info = match[3].trim(); const code = []; cursor++; while (cursor < inputLines.length) { const line = current(); if (line.includes(fence) && /^ {0,3}(?:`+|~+)\s*$/.test(line)) { cursor++; break; } code.push(stripIndent(line, match[1].length)); cursor++; } return { type: 'code', value: code.join('\n'), ...parseCodeInfo(info), }; } function parseHeading() { const match = current().match(/^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$/); if (!match) return undefined; const depth = match[1].length; const rawValue = match[2] ?? ''; const value = (/^#+[ \t]*$/.test(rawValue) ? '' : rawValue.replace(/[ \t]+#+[ \t]*$/, '')).trim(); const children = parseInline(value, options); const id = createHeadingId(children); cursor++; return id ? { type: 'heading', depth, id, children } : { type: 'heading', depth, children }; } function parseThematicBreak() { if (!/^ {0,3}([-*_])(?:\s*\1){2,}\s*$/.test(current())) return undefined; cursor++; return { type: 'thematicBreak' }; } function parseBlockquote() { if (!/^ {0,3}>\s?/.test(current())) return undefined; const quoted = []; while (cursor < inputLines.length) { const line = current(); const match = line.match(/^ {0,3}>\s?(.*)$/); if (!match) { if (!isBlank(line)) break; looseBlocks = true; } quoted.push(match?.[1] ?? ''); cursor++; } return { type: 'blockquote', children: createBlockParser(quoted, options, slugger, budget).parse(), }; } function parseList() { const first = listMarker(current()); if (!first) return undefined; const items = []; const ordered = first.ordered; const baseIndent = first.indent; let loose = false; while (cursor < inputLines.length) { const marker = listMarker(current()); if (!marker || marker.marker !== first.marker || marker.indent !== baseIndent) break; let firstLine = marker.content; const task = firstLine.match(/^\[([ xX])\]\s+(.*)$/); const checked = task ? task[1].toLowerCase() === 'x' : undefined; if (task) firstLine = task[2]; const itemLines = [firstLine]; cursor++; while (cursor < inputLines.length) { const line = current(); const nextMarker = listMarker(line); if (nextMarker && nextMarker.indent === baseIndent) break; if (isBlank(line)) { let nextIndex = cursor; while (nextIndex < inputLines.length && isBlank(inputLines[nextIndex])) nextIndex++; const following = inputLines[nextIndex]; if (following === undefined) break; const followingMarker = listMarker(following); if (followingMarker?.indent === baseIndent) { if (followingMarker.marker !== first.marker) break; loose = true; cursor = nextIndex; break; } if (leadingSpaces(following) < marker.contentIndent) break; while (cursor < nextIndex) itemLines.push(stripIndent(inputLines[cursor++], marker.contentIndent)); continue; } if (leadingSpaces(line) >= marker.contentIndent) { itemLines.push(stripIndent(line, marker.contentIndent)); cursor++; continue; } if (isBlockStart(line, next())) break; itemLines.push(line.trimStart()); cursor++; } const parser = createBlockParser(itemLines, options, slugger, budget); const children = parser.parse(); loose ||= parser.loose; const item = { type: 'listItem', children }; if (checked !== undefined) item.checked = checked; items.push(item); } return { type: 'list', ordered, ...(ordered && { start: first.number }), ...(loose && { loose: true }), items, }; } function parseTable() { const header = current(); const delimiter = next(); if (!delimiter || !looksLikeTableHeader(header, delimiter)) return undefined; const headerCells = splitTableRow(header); const align = splitTableRow(delimiter).map(parseAlign); const columns = headerCells.length; const rows = []; cursor += 2; while (cursor < inputLines.length) { const line = current(); if (isBlank(line) || isBlockStart(line, next())) break; const values = splitTableRow(line); rows.push(Array.from({ length: columns }, (_, index) => cell(values[index] ?? '', options))); cursor++; } return { type: 'table', align, header: headerCells.map(value => cell(value, options)), rows, }; } function parseHtmlBlock() { if (!options.allowHtml || !/^ {0,3}<([A-Za-z][\w:-]*|!--|\/[A-Za-z])/.test(current())) return undefined; const html = []; if (/^ {0,3}<!--/.test(current())) { while (cursor < inputLines.length) { const line = current(); html.push(line); cursor++; if (line.includes('-->')) break; } return { type: 'html', value: html.join('\n') }; } while (cursor < inputLines.length && !isBlank(current())) { html.push(current()); cursor++; } return { type: 'html', value: html.join('\n') }; } function parseParagraph() { const lines = []; while (cursor < inputLines.length) { const line = current(); if (isBlank(line)) break; if (lines.length > 0 && isBlockStart(line, next())) break; lines.push(line.trim()); cursor++; } return { type: 'paragraph', children: parseInline(lines.join('\n'), options), }; } function createHeadingId(children) { if (options.headingIds === false) return undefined; const text = plainText(children); if (typeof options.headingIds === 'function') return options.headingIds(text, cursor); return slugger(text); } function current() { return inputLines[cursor] ?? ''; } function next() { return inputLines[cursor + 1]; } } function parseCodeInfo(info) { const langMatch = info.match(/^([A-Za-z0-9_+.#-]+)/); const lang = langMatch?.[1]; const meta = lang ? info.slice(lang.length).trim() : info; const title = meta.match(/(?:^|\s)(?:title|file)=(?:"([^"]+)"|'([^']+)'|([^\s}]+))/)?.slice(1).find(Boolean); const framework = meta.match(/(?:^|\s)framework=(?:"([^"]+)"|'([^']+)'|([^\s}]+))/)?.slice(1).find(Boolean); const rangeMatch = meta.match(/\{([^}]+)\}|(?:^|\s)lines=([^\s]+)/); const highlightLines = rangeMatch ? parseLineRanges(rangeMatch[1] ?? rangeMatch[2]) : []; return { ...(lang && { lang }), ...(meta && { meta }), ...(title && { title, file: title }), ...(framework && { framework: framework.toLowerCase() }), ...(highlightLines.length && { highlightLines }), }; } function extractDefinitions(lines) { const references = Object.create(null); const footnotes = Object.create(null); const footnoteIds = createSlugger(footnoteId); const remaining = []; let activeFence = ''; let index = 0; while (index < lines.length) { const line = lines[index]; const fence = (!activeFence || line.includes(activeFence)) && line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); if (fence) { if (!activeFence) activeFence = fence[1]; else if (fence[1].startsWith(activeFence) && isBlank(fence[2])) activeFence = ''; } else if (!activeFence) { const footnote = line.match(/^ {0,3}\[\^([^\]\n]+)\]:[ \t]*(.*)$/); if (footnote) { const label = footnote[1]; const content = [footnote[2] ?? '']; index++; while (index < lines.length) { const continuation = lines[index]; if (!/^(?: {4,}|\t)/.test(continuation)) break; content.push(continuation.replace(/^(?: {4}|\t)/, '')); index++; } const key = normalizeReferenceLabel(label); if (!footnotes[key]) { footnotes[key] = { label, content: content.join('\n'), id: footnoteIds(label) }; } continue; } const definition = line.match(/^ {0,3}\[([^\]\n]+)\]:[ \t]*(\S.*)$/); const destination = definition && parseDestination(definition[2].trimEnd()); if (destination) { references[normalizeReferenceLabel(definition[1])] ??= destination; index++; continue; } } remaining.push(line); index++; } return { lines: remaining, references, footnotes }; } function createFootnotesBlock(footnotes, footnoteOrder, options, slugger) { const items = []; for (let index = 0; index < footnoteOrder.length; index++) { const key = footnoteOrder[index]; const definition = footnotes[key]; if (!definition) continue; items.push({ id: definition.id ?? footnoteId(definition.label), number: index + 1, children: createBlockParser(normalizeInput(definition.content).split('\n'), options, slugger).parse(), }); } for (const item of items) { const count = options.footnoteCounts?.[footnoteOrder[item.number - 1]] ?? 1; if (count > 1) item.referenceCount = count; } return { type: 'footnotes', items }; } function parseLineRanges(value) { const lines = new Set(); for (const part of value.split(',')) { const match = part.trim().match(/^(\d+)(?:-(\d+))?$/); if (!match) continue; const start = Number(match[1]); const end = Number(match[2] ?? match[1]); // Larger integers can stop line++ from making progress. if (end > Number.MAX_SAFE_INTEGER) continue; for (let line = start; line <= end && line < start + 1000; line++) lines.add(line); } return [...lines].sort((a, b) => a - b); } function listMarker(line) { const match = line.match(/^(\s{0,8})([-+*]|\d{1,9}[.)])(?:([ \t]+)(.*))?$/); if (!match) return undefined; const marker = match[2]; const ordered = /\d/.test(marker[0]); return { ordered, ...(ordered && { number: Number.parseInt(marker, 10) }), indent: match[1].length, marker: ordered ? marker.at(-1) : marker, contentIndent: match[1].length + marker.length + (match[3]?.length ?? 1), content: match[4] ?? '', }; } function leadingSpaces(line) { return line.match(/^ */)?.[0].length ?? 0; } function isBlockStart(line, next) { const marker = listMarker(line); return (/^ {0,3}(?:`{3,}|~{3,}|#{1,6}(?:\s|$)|([-*_])(?:\s*\1){2,}\s*$|>)/.test(line) || (marker !== undefined && (!marker.ordered || marker.number === 1)) || (!!next && looksLikeTableHeader(line, next))); } function looksLikeTableHeader(header, delimiter) { if (!header.includes('|')) return false; const cells = splitTableRow(delimiter); return cells.length === splitTableRow(header).length && cells.every(cell => /^:?-+:?$/.test(cell.trim())); } function splitTableRow(value) { const row = value.trim(); const cells = []; let current = ''; for (let index = row.startsWith('|') ? 1 : 0; index < row.length; index++) { const char = row[index]; if (char === '\\' && row[index + 1] === '|') { current += '|'; index++; continue; } if (char === '|') { cells.push(current.trim()); current = ''; continue; } current += char; } if (current || !row.endsWith('|') || !cells.length) cells.push(current.trim()); return cells; } function parseAlign(value) { const trimmed = value.trim(); if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center'; if (trimmed.endsWith(':')) return 'right'; if (trimmed.startsWith(':')) return 'left'; return undefined; } function cell(value, options) { return { type: 'tableCell', children: parseInline(value, options) }; }