@tanstack/markdown
Version:
A tiny, fast, deterministic Markdown renderer for blogs and documentation.
478 lines (477 loc) • 18.1 kB
JavaScript
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) };
}