@tanstack/markdown
Version:
A tiny, fast, deterministic Markdown renderer for blogs and documentation.
506 lines (505 loc) • 19.2 kB
JavaScript
import { parseInline } from './inline.js';
import { createSlugger, footnoteId, isBlank, normalizeInput, normalizeReferenceLabel, 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 parser = new BlockParser(lines, parseOptions, createSlugger());
const children = parser.parse();
if (hasFootnotes && footnoteOrder.length > 0) {
children.push(createFootnotesBlock(definitions.footnotes, footnoteOrder, parseOptions));
}
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;
}
class BlockParser {
lines;
options;
slugger;
budget;
index = 0;
constructor(lines, options, slugger, budget = { depth: 0 }) {
this.lines = lines;
this.options = options;
this.slugger = slugger;
this.budget = budget;
}
parse() {
if (this.budget.depth >= maxBlockDepth) {
const value = this.lines.slice(this.index).join('\n');
this.index = this.lines.length;
return value ? [{ type: 'paragraph', children: parseInline(value, this.options) }] : [];
}
this.budget.depth++;
const nodes = [];
while (this.index < this.lines.length) {
if (isBlank(this.current())) {
this.index++;
continue;
}
const extensionNode = this.parseExtensionBlock();
if (extensionNode) {
nodes.push(extensionNode);
continue;
}
const node = this.parseFence() ??
this.parseHeading() ??
this.parseThematicBreak() ??
this.parseBlockquote() ??
this.parseList() ??
this.parseTable() ??
this.parseHtmlBlock() ??
this.parseParagraph();
nodes.push(node);
}
this.budget.depth--;
return nodes;
}
parseExtensionBlock() {
for (const extension of this.options.extensions ?? []) {
let consumed = 0;
const node = extension.parseBlock?.({
lines: this.lines,
index: this.index,
options: this.options,
parseInline: value => parseInline(value, this.options),
parseBlocks: value => new BlockParser(normalizeInput(value).split('\n'), this.options, this.slugger, this.budget).parse(),
consume: lines => {
consumed = lines;
},
});
if (node) {
this.index += Math.max(consumed, 1);
return node;
}
}
return undefined;
}
parseFence() {
const match = this.current().match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (!match)
return undefined;
const fence = match[1];
const marker = fence[0];
const fenceSize = fence.length;
const info = match[2].trim();
const code = [];
this.index++;
while (this.index < this.lines.length) {
const line = this.current();
const close = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/);
if (close && close[1][0] === marker && close[1].length >= fenceSize) {
this.index++;
break;
}
code.push(line);
this.index++;
}
return {
type: 'code',
value: code.join('\n'),
...parseCodeInfo(info),
};
}
parseHeading() {
const match = this.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, this.options);
const id = this.createHeadingId(children);
this.index++;
return id ? { type: 'heading', depth, id, children } : { type: 'heading', depth, children };
}
parseThematicBreak() {
if (!/^ {0,3}([-*_])(?:\s*\1){2,}\s*$/.test(this.current()))
return undefined;
this.index++;
return { type: 'thematicBreak' };
}
parseBlockquote() {
if (!/^ {0,3}>\s?/.test(this.current()))
return undefined;
const quoted = [];
while (this.index < this.lines.length) {
const line = this.current();
if (isBlank(line)) {
quoted.push('');
this.index++;
continue;
}
const match = line.match(/^ {0,3}>\s?(.*)$/);
if (!match)
break;
quoted.push(match[1]);
this.index++;
}
return {
type: 'blockquote',
children: new BlockParser(quoted, this.options, this.slugger, this.budget).parse(),
};
}
parseList() {
const first = listMarker(this.current());
if (!first)
return undefined;
const items = [];
const ordered = first.ordered;
const baseIndent = first.indent;
const start = ordered ? first.number : undefined;
let loose = false;
while (this.index < this.lines.length) {
const marker = listMarker(this.current());
if (!marker || !sameListType(marker, first) || 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];
this.index++;
while (this.index < this.lines.length) {
const line = this.current();
const nextMarker = listMarker(line);
if (nextMarker && nextMarker.indent === baseIndent)
break;
if (isBlank(line)) {
let nextIndex = this.index;
while (nextIndex < this.lines.length && isBlank(this.lines[nextIndex]))
nextIndex++;
const following = this.lines[nextIndex];
if (following === undefined)
break;
const followingMarker = listMarker(following);
if (followingMarker?.indent === baseIndent) {
if (!sameListType(followingMarker, first))
break;
loose = true;
this.index = nextIndex;
break;
}
if (leadingSpaces(following) < marker.contentIndent)
break;
loose = true;
itemLines.push('');
this.index = nextIndex;
continue;
}
if (leadingSpaces(line) >= marker.contentIndent) {
itemLines.push(stripIndent(line, marker.contentIndent));
this.index++;
continue;
}
if (isBlockStart(line, this.next()))
break;
itemLines.push(line.trimStart());
this.index++;
}
const children = new BlockParser(itemLines, this.options, this.slugger, this.budget).parse();
const item = { type: 'listItem', children };
if (checked !== undefined)
item.checked = checked;
items.push(item);
}
return {
type: 'list',
ordered,
...(ordered && start !== undefined ? { start } : {}),
...(loose && { loose: true }),
items,
};
}
parseTable() {
const header = this.current();
const delimiter = this.next();
if (!delimiter || !looksLikeTableHeader(header, delimiter))
return undefined;
const headerCells = splitTableRow(header);
const align = splitTableRow(delimiter).map(parseAlign);
const columns = headerCells.length;
const rows = [];
this.index += 2;
while (this.index < this.lines.length) {
const line = this.current();
if (isBlank(line) || isBlockStart(line, this.next()))
break;
const values = splitTableRow(line);
rows.push(Array.from({ length: columns }, (_, index) => cell(values[index] ?? '', this.options)));
this.index++;
}
return {
type: 'table',
align,
header: headerCells.map(value => cell(value, this.options)),
rows,
};
}
parseHtmlBlock() {
if (!this.options.allowHtml || !/^ {0,3}<([A-Za-z][\w:-]*|!--|\/[A-Za-z])/.test(this.current()))
return undefined;
const html = [];
if (/^ {0,3}<!--/.test(this.current())) {
while (this.index < this.lines.length) {
const line = this.current();
html.push(line);
this.index++;
if (line.includes('-->'))
break;
}
return { type: 'html', value: html.join('\n') };
}
while (this.index < this.lines.length && !isBlank(this.current())) {
html.push(this.current());
this.index++;
}
return { type: 'html', value: html.join('\n') };
}
parseParagraph() {
const lines = [];
while (this.index < this.lines.length) {
const line = this.current();
if (isBlank(line))
break;
if (lines.length > 0 && isBlockStart(line, this.next()))
break;
lines.push(line.trim());
this.index++;
}
return {
type: 'paragraph',
children: parseInline(lines.join('\n'), this.options),
};
}
createHeadingId(children) {
if (this.options.headingIds === false)
return undefined;
const text = plainText(children);
if (typeof this.options.headingIds === 'function')
return this.options.headingIds(text, this.index);
return this.slugger(text);
}
current() {
return this.lines[this.index] ?? '';
}
next() {
return this.lines[this.index + 1];
}
}
function parseCodeInfo(info) {
if (!info)
return {};
const langMatch = info.match(/^([A-Za-z0-9_+.#-]+)/);
const lang = langMatch?.[1];
const meta = lang ? info.slice(lang.length).trim() : info;
const titleMatch = meta.match(/(?:^|\s)(?:title|file)=(?:"([^"]+)"|'([^']+)'|([^\s}]+))/);
const frameworkMatch = meta.match(/(?:^|\s)framework=(?:"([^"]+)"|'([^']+)'|([^\s}]+))/);
const rangeMatch = meta.match(/\{([^}]+)\}|(?:^|\s)lines=([^\s]+)/);
const highlightLines = parseLineRanges(rangeMatch?.[1] ?? rangeMatch?.[2] ?? '');
const title = titleMatch ? titleMatch[1] ?? titleMatch[2] ?? titleMatch[3] : undefined;
const framework = frameworkMatch ? frameworkMatch[1] ?? frameworkMatch[2] ?? frameworkMatch[3] : undefined;
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 = new Map();
const remaining = [];
let fenceMarker;
let fenceSize = 0;
let index = 0;
while (index < lines.length) {
const line = lines[index];
const fence = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (fence) {
const marker = fence[1][0];
if (!fenceMarker) {
fenceMarker = marker;
fenceSize = fence[1].length;
}
else if (marker === fenceMarker && fence[1].length >= fenceSize) {
fenceMarker = undefined;
fenceSize = 0;
}
remaining.push(line);
index++;
continue;
}
if (!fenceMarker) {
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]) {
const baseId = footnoteId(label) || 'footnote';
const count = (footnoteIds.get(baseId) ?? 0) + 1;
footnoteIds.set(baseId, count);
footnotes[key] = { label, content: content.join('\n'), id: count === 1 ? baseId : `${baseId}-${count}` };
}
continue;
}
const definition = line.match(/^ {0,3}\[([^\]\n]+)\]:[ \t]*(\S+)(?:[ \t]+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\)))?[ \t]*$/);
if (definition) {
const title = definition[3] ?? definition[4] ?? definition[5];
references[normalizeReferenceLabel(definition[1])] = {
href: definition[2].replace(/^<|>$/g, ''),
...(title !== undefined ? { title } : {}),
};
index++;
continue;
}
}
remaining.push(line);
index++;
}
return { lines: remaining, references, footnotes };
}
function createFootnotesBlock(footnotes, footnoteOrder, options) {
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) || 'footnote'),
number: index + 1,
children: new BlockParser(normalizeInput(definition.content).split('\n'), options, createSlugger()).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]);
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 sameListType(left, right) {
return left.ordered === right.ordered && left.marker === right.marker;
}
function leadingSpaces(line) {
return line.match(/^ */)?.[0].length ?? 0;
}
function isBlockStart(line, next) {
const marker = listMarker(line);
return (/^ {0,3}(`{3,}|~{3,})/.test(line) ||
/^ {0,3}#{1,6}(?:\s+|$)/.test(line) ||
/^ {0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line) ||
/^ {0,3}>\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) {
let row = value.trim();
if (row.startsWith('|'))
row = row.slice(1);
if (row.endsWith('|'))
row = row.slice(0, -1);
const cells = [];
let current = '';
for (let index = 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;
}
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) };
}