UNPKG

@tanstack/markdown

Version:

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

300 lines (299 loc) 11.8 kB
import { footnoteId, normalizeReferenceLabel, parseDestination, plainText, sanitizeUrl } from './utils.js'; export function parseInline(value, options = {}) { let result = parseInlineRaw(value, options); for (const extension of options.extensions ?? []) { result = extension.transformInline?.(result, { options }) ?? result; } return result; } const maxInlineDepth = 32; const scansPerCharacter = 16; const inlineMarker = /[\\`!\[_*~<]/g; function parseInlineRaw(value, options, budget = { scans: Math.max(value.length * scansPerCharacter, 1024), depth: 0, links: 0 }) { if (budget.depth >= maxInlineDepth) return value ? [{ type: 'text', value }] : []; budget.depth++; const nodes = []; let index = 0; let text = ''; const pushText = () => { if (text) { nodes.push({ type: 'text', value: text }); text = ''; } }; while (index < value.length) { const char = value[index]; const next = value[index + 1]; if (char === '\\') { if (next === '\n') { pushText(); nodes.push({ type: 'break' }); index += 2; continue; } if (next && /[!-/:-@\[-`{-~]/.test(next)) { text += next; index += 2; continue; } } if (char === '`') { const tickCount = countRun(value, index, '`'); const close = findClosingRun(value, index + tickCount, '`', tickCount, budget); if (close !== -1) { pushText(); nodes.push({ type: 'inlineCode', // Strip one padding space at each end, except in an all-space span. value: value.slice(index + tickCount, close).replace(/\n/g, ' ').replace(/^ (?! *$)(.*) $/s, '$1'), }); index = close + tickCount; continue; } text += value.slice(index, index + tickCount); index += tickCount; continue; } if (char === '[' || (char === '!' && next === '[')) { const image = char === '!'; const footnote = next === '^' && parseFootnoteReference(value, index, options, budget); if (footnote) { pushText(); nodes.push(footnote.node); budget.links++; index = footnote.end; continue; } const parsed = parseLinkish(value, index + Number(image), options, budget); if (parsed) { const links = budget.links; const children = parseInlineRaw(parsed.label, image ? (options.references ? { references: options.references } : {}) : options, budget); const nested = !image && budget.links !== links; const defaultUrl = sanitizeUrl(parsed.href); const href = options.urlTransform ? options.urlTransform(parsed.href, image ? 'image' : 'link', defaultUrl) : defaultUrl; if (href !== null && (image || (!nested && (href || !parsed.href)))) { pushText(); nodes.push({ ...(image ? { type: 'image', src: href, alt: plainText(children) } : { type: 'link', href, children }), ...(parsed.title ? { title: parsed.title } : {}), }); } else { if (nested) text += '['; for (const child of children) { if (child.type === 'text') text += child.value; else { pushText(); nodes.push(child); } } } // An inner link disables its outer opener, but not the trailing markup. budget.links = image ? links : budget.links + 1; index = nested ? index + parsed.label.length + 1 : parsed.end; continue; } } if (char === '*' && next === '*' && value[index + 2] === '*') { const close = findDelimiter(value, index + 3, '***', budget); if (close !== -1) { pushText(); nodes.push({ type: 'emphasis', children: parseInlineRaw(value.slice(index + 1, close + 2), options, budget), }); index = close + 3; continue; } } if (char === '~' && next === '~' && value[index + 2] === '~') { const run = countRun(value, index, '~'); text += value.slice(index, index + run); index += run; continue; } if (char === '*' || char === '_' || char === '~') { const size = next === char ? 2 : 1; const close = char === '_' && !canUseUnderscore(value, index, size, true) ? -1 : findDelimiter(value, index + size, char.repeat(size), budget); if (close !== -1) { pushText(); nodes.push({ type: char === '~' ? 'strike' : size === 2 ? 'strong' : 'emphasis', children: parseInlineRaw(value.slice(index + size, close), options, budget), }); index = close + size; } else { text += char.repeat(size); index += size; } continue; } if (char === '<' && options.allowHtml) { const close = findCharacter(value, index + 1, '>', budget); if (close !== -1 && isInlineHtml(value.slice(index, close + 1))) { pushText(); nodes.push({ type: 'inlineHtml', value: value.slice(index, close + 1) }); index = close + 1; continue; } } // Reset after recursive parsing; exhausted budgets still allow escapes. inlineMarker.lastIndex = index + 1; const end = budget.scans > 0 ? inlineMarker.exec(value)?.index ?? value.length : value.indexOf('\\', index + 1); text += value.slice(index, end < 0 ? value.length : end); index = end < 0 ? value.length : end; } pushText(); budget.depth--; return nodes; } function parseFootnoteReference(value, open, options, budget) { const close = findCharacter(value, open + 2, ']', budget); if (close === -1) return undefined; const label = value.slice(open + 2, close); const key = normalizeReferenceLabel(label); const definition = options.footnotes?.[key]; if (!definition || !options.footnoteOrder) return undefined; let number = options.footnoteOrder.indexOf(key) + 1; if (!number) number = options.footnoteOrder.push(key); const referenceIndex = (options.footnoteCounts?.[key] ?? 0) + 1; if (options.footnoteCounts) options.footnoteCounts[key] = referenceIndex; return { node: { type: 'footnoteReference', id: definition.id ?? footnoteId(definition.label), number, ...(referenceIndex > 1 ? { referenceIndex } : {}), }, end: close + 1, }; } function parseLinkish(value, open, options, budget) { const closeBracket = findBalanced(value, open, '[', ']', budget); if (closeBracket === -1) return undefined; const label = value.slice(open + 1, closeBracket); let end = closeBracket + 1; let definition; if (value[end] === '(') { const closeParen = findBalanced(value, end, '(', ')', budget); if (closeParen === -1) return undefined; definition = parseDestination(value.slice(end + 1, closeParen).trim()); end = closeParen + 1; } else if (value[end] === '[') { const closeReference = findBalanced(value, end, '[', ']', budget); if (closeReference === -1) return undefined; definition = options.references?.[normalizeReferenceLabel(value.slice(end + 1, closeReference) || label)]; end = closeReference + 1; } else { definition = options.references?.[normalizeReferenceLabel(label)]; } if (definition) { return { ...definition, label, end, }; } return undefined; } function isInlineHtml(value) { return /^<(?:!--(?:[\s\S]*--|-?)|\?[\s\S]*\?|![A-Z][\s\S]*|!\[CDATA\[[\s\S]*\]\]|\/[A-Za-z][\w:-]*\s*|[A-Za-z][\w:-]*(?:\s+[A-Za-z_:][\w:.-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?)>$/.test(value); } function findBalanced(value, openIndex, open, close, budget) { // Reject missing closers with a native search, charging the same shared budget. if (findCharacter(value, openIndex + 1, close, budget) === -1) return -1; let depth = 0; for (let index = openIndex; index < value.length; index++) { if (--budget.scans < 0) return -1; if (value[index] === '\\') { index++; continue; } if (value[index] === open) depth++; if (value[index] === close) { depth--; if (depth === 0) return index; } } return -1; } function countRun(value, index, char, budget) { let count = 0; while (value[index + count] === char) { if (budget && --budget.scans < 0) break; count++; } return count; } function findClosingRun(value, start, char, count, budget) { let index = start; while ((index = findCharacter(value, index, char, budget)) !== -1) { const size = countRun(value, index, char, budget); if (size === count) return index; index += size; } return -1; } function findDelimiter(value, start, delimiter, budget) { if (delimiter === '~' && /[\s\d]/.test(value[start] ?? '')) return -1; for (let index = start; index < value.length; index++) { if (--budget.scans < 0) return -1; if (value[index] === '\\') { index++; continue; } if (!value.startsWith(delimiter, index)) continue; if (delimiter[0] === '~') { if (value[index - 1] === '~' || value[index + delimiter.length] === '~') continue; if (delimiter === '~' && /\s/.test(value[index - 1] ?? '')) return -1; } else if (value[index + 1] === delimiter) { const size = countRun(value, index, delimiter, budget); const close = findDelimiter(value, index + size, delimiter.repeat(size), budget); if (close > index + size) { index = close + size - 1; continue; } } if (delimiter[0] === '_' && !canUseUnderscore(value, index, delimiter.length, false)) continue; return index; } return -1; } function findCharacter(value, start, character, budget) { if (budget.scans <= 0) return -1; const index = value.indexOf(character, start); budget.scans -= (index < 0 ? value.length : index + 1) - start; return budget.scans < 0 ? -1 : index; } function canUseUnderscore(value, index, size, opening) { const inside = value[opening ? index + size : index - 1]; const outside = value[opening ? index - 1 : index + size]; return inside !== undefined && !/\s/.test(inside) && (outside === undefined || /[^\p{L}\p{N}]/u.test(outside)); }