@tanstack/markdown
Version:
A tiny, fast, deterministic Markdown renderer for blogs and documentation.
406 lines (405 loc) • 14.5 kB
JavaScript
import { footnoteId, normalizeReferenceLabel, sanitizeUrl } from './utils.js';
export function parseInline(value, options = {}) {
const nodes = parseInlineRaw(value, options);
let result = mergeText(nodes);
for (const extension of options.extensions ?? []) {
result = extension.transformInline?.(result, { options }) ?? result;
}
return result;
}
const maxInlineDepth = 32;
const scansPerCharacter = 16;
function parseInlineRaw(value, options, budget = { scans: Math.max(value.length * scansPerCharacter, 1024), depth: 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',
value: value.slice(index + tickCount, close).replace(/\s+/g, ' ').trim(),
});
index = close + tickCount;
continue;
}
text += value.slice(index, index + tickCount);
index += tickCount;
continue;
}
if (char === '!' && next === '[') {
const parsed = parseLinkish(value, index + 1, options, budget);
if (parsed) {
pushText();
nodes.push({
type: 'image',
src: sanitizeUrl(parsed.href),
alt: textFromMarkdown(parsed.label, budget),
...(parsed.title ? { title: parsed.title } : {}),
});
index = parsed.end;
continue;
}
}
if (char === '[') {
const footnote = parseFootnoteReference(value, index, options, budget);
if (footnote) {
pushText();
nodes.push(footnote.node);
index = footnote.end;
continue;
}
const parsed = parseLinkish(value, index, options, budget);
if (parsed) {
const href = sanitizeUrl(parsed.href);
pushText();
if (href) {
nodes.push({
type: 'link',
href,
...(parsed.title ? { title: parsed.title } : {}),
children: parseInlineRaw(parsed.label, options, budget),
});
}
else {
nodes.push(...parseInlineRaw(parsed.label, options, budget));
}
index = 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 === '*') || (char === '_' && next === '_')) {
const close = char === '_' && !canUseUnderscore(value, index, 2, true) ? -1 : findDelimiter(value, index + 2, char + char, budget);
if (close !== -1) {
pushText();
nodes.push({
type: 'strong',
children: parseInlineRaw(value.slice(index + 2, close), options, budget),
});
index = close + 2;
continue;
}
text += char + next;
index += 2;
continue;
}
if (char === '~' && next === '~' && value[index + 2] === '~') {
const run = countRun(value, index, '~');
text += value.slice(index, index + run);
index += run;
continue;
}
if (char === '~' && next === '~') {
const close = findDelimiter(value, index + 2, '~~', budget);
if (close !== -1) {
pushText();
nodes.push({
type: 'strike',
children: parseInlineRaw(value.slice(index + 2, close), options, budget),
});
index = close + 2;
continue;
}
text += '~~';
index += 2;
continue;
}
if (char === '~') {
const close = findSingleTildeDelimiter(value, index + 1, budget);
if (close !== -1) {
pushText();
nodes.push({
type: 'strike',
children: parseInlineRaw(value.slice(index + 1, close), options, budget),
});
index = close + 1;
continue;
}
}
if (char === '*' || char === '_') {
const close = char === '_' && !canUseUnderscore(value, index, 1, true) ? -1 : findDelimiter(value, index + 1, char, budget);
if (close !== -1) {
pushText();
nodes.push({
type: 'emphasis',
children: parseInlineRaw(value.slice(index + 1, close), options, budget),
});
index = close + 1;
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;
}
}
text += char;
index++;
}
pushText();
budget.depth--;
return nodes;
}
function parseFootnoteReference(value, open, options, budget) {
if (value[open + 1] !== '^')
return undefined;
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 orderIndex = options.footnoteOrder.indexOf(key);
if (orderIndex === -1) {
options.footnoteOrder.push(key);
orderIndex = options.footnoteOrder.length - 1;
}
const referenceIndex = (options.footnoteCounts?.[key] ?? 0) + 1;
if (options.footnoteCounts)
options.footnoteCounts[key] = referenceIndex;
return {
node: {
type: 'footnoteReference',
id: definition.id ?? (footnoteId(definition.label) || 'footnote'),
number: orderIndex + 1,
...(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);
if (value[closeBracket + 1] === '(') {
const closeParen = findBalanced(value, closeBracket + 1, '(', ')', budget);
if (closeParen === -1)
return undefined;
const destination = value.slice(closeBracket + 2, closeParen).trim();
const parsed = parseDestination(destination);
if (!parsed)
return undefined;
return {
label,
href: parsed.href,
...(parsed.title ? { title: parsed.title } : {}),
end: closeParen + 1,
};
}
if (value[closeBracket + 1] === '[') {
const closeReference = findBalanced(value, closeBracket + 1, '[', ']', budget);
if (closeReference === -1)
return undefined;
const referenceLabel = value.slice(closeBracket + 2, closeReference);
const definition = options.references?.[normalizeReferenceLabel(referenceLabel || label)];
if (!definition)
return undefined;
return {
label,
href: definition.href,
...(definition.title ? { title: definition.title } : {}),
end: closeReference + 1,
};
}
const definition = options.references?.[normalizeReferenceLabel(label)];
if (definition) {
return {
label,
href: definition.href,
...(definition.title ? { title: definition.title } : {}),
end: closeBracket + 1,
};
}
return undefined;
}
function isInlineHtml(value) {
if (/^<!--(?:[\s\S]*--|-?)>$/.test(value))
return true;
if (/^<\?[\s\S]*\?>$/.test(value) || /^<![A-Z][\s\S]*>$/.test(value) || /^<!\[CDATA\[[\s\S]*\]\]>$/.test(value))
return true;
if (/^<\/[A-Za-z][\w:-]*\s*>$/.test(value))
return true;
return /^<[A-Za-z][\w:-]*(?:\s+[A-Za-z_:][\w:.-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>$/.test(value);
}
function parseDestination(value) {
const match = value.match(/^(\S+)(?:\s+["']([^"']*)["'])?$/);
if (!match)
return { href: value };
return {
href: match[1].replace(/^<|>$/g, ''),
...(match[2] ? { title: match[2] } : {}),
};
}
function findBalanced(value, openIndex, open, close, budget) {
let depth = 0;
for (let index = openIndex; index < value.length; index++) {
if (!takeScan(budget))
return -1;
if (value[index - 1] === '\\')
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 && !takeScan(budget))
break;
count++;
}
return count;
}
function findClosingRun(value, start, char, count, budget) {
for (let index = start; index < value.length; index++) {
if (!takeScan(budget))
return -1;
if (value[index] !== char)
continue;
if (countRun(value, index, char, budget) >= count)
return index;
}
return -1;
}
function findDelimiter(value, start, delimiter, budget) {
for (let index = start; index < value.length; index++) {
if (!takeScan(budget))
return -1;
if (value[index - 1] === '\\')
continue;
if (!value.startsWith(delimiter, index))
continue;
if (value[index + 1] === delimiter) {
const close = findDelimiter(value, index + 2, delimiter + delimiter, budget);
if (close > index + 2) {
index = close + 1;
continue;
}
}
if (delimiter === '~~' && (value[index - 1] === '~' || value[index + 2] === '~'))
continue;
if (delimiter[0] === '_' && !canUseUnderscore(value, index, delimiter.length, false))
continue;
return index;
}
return -1;
}
function findSingleTildeDelimiter(value, start, budget) {
if (isWhitespace(value[start] ?? '') || /\d/.test(value[start] ?? ''))
return -1;
for (let index = start; index < value.length; index++) {
if (!takeScan(budget))
return -1;
if (value[index - 1] === '\\')
continue;
if (value[index] !== '~')
continue;
if (value[index - 1] === '~')
continue;
if (value[index + 1] === '~')
continue;
if (isWhitespace(value[index - 1] ?? ''))
return -1;
return index;
}
return -1;
}
function findCharacter(value, start, character, budget) {
for (let index = start; index < value.length; index++) {
if (!takeScan(budget))
return -1;
if (value[index] === character)
return index;
}
return -1;
}
function takeScan(budget) {
if (budget.scans <= 0)
return false;
budget.scans--;
return true;
}
function canUseUnderscore(value, index, size, opening) {
const before = value[index - 1];
const after = value[index + size];
const leftFlanking = !isDelimiterWhitespace(after) && (!isPunctuation(after) || isDelimiterWhitespace(before) || isPunctuation(before));
const rightFlanking = !isDelimiterWhitespace(before) && (!isPunctuation(before) || isDelimiterWhitespace(after) || isPunctuation(after));
return opening ? leftFlanking && (!rightFlanking || isPunctuation(before)) : rightFlanking && (!leftFlanking || isPunctuation(after));
}
function isDelimiterWhitespace(value) {
return value === undefined || /\s/.test(value);
}
function isPunctuation(value) {
return value !== undefined && /[^\p{L}\p{N}\s]/u.test(value);
}
function isWhitespace(value) {
return /\s/.test(value);
}
function textFromMarkdown(value, budget) {
return parseInlineRaw(value, {}, budget).map(node => (node.type === 'text' || node.type === 'inlineCode' ? node.value : '')).join('');
}
function mergeText(nodes) {
const result = [];
for (const node of nodes) {
const previous = result.at(-1);
if (previous?.type === 'text' && node.type === 'text') {
previous.value += node.value;
}
else {
result.push(node);
}
}
return result;
}