@tanstack/markdown
Version:
A tiny, fast, deterministic Markdown renderer for blogs and documentation.
191 lines (190 loc) • 9.58 kB
JavaScript
import { parseMarkdown } from './parser.js';
import { escapeAttr, escapeHtml } from './utils.js';
export function renderHtml(input, options = {}) {
const document = typeof input === 'string' ? parseMarkdown(input, options) : input;
return document.children.map(node => renderBlock(node, options)).join('\n');
}
export function renderBlock(node, options = {}) {
const extension = renderExtension(node, options);
if (extension !== undefined)
return extension;
switch (node.type) {
case 'heading': {
const id = node.id ? ` id="${escapeAttr(node.id)}"` : '';
const framework = node.framework ? ` data-framework="${escapeAttr(node.framework)}"` : '';
return `<h${node.depth}${id}${framework}>${renderInlines(node.children, options)}${renderHeadingAnchor(node.id, options)}</h${node.depth}>`;
}
case 'paragraph':
return `<p>${renderInlines(node.children, options)}</p>`;
case 'code':
return renderCodeBlock(node, options);
case 'list': {
const tag = node.ordered ? 'ol' : 'ul';
const start = node.ordered && node.start && node.start !== 1 ? ` start="${node.start}"` : '';
const items = node.items.map(item => `<li>${renderListItemChildren(item.children, item.checked, node.loose, options)}</li>`).join('\n');
return `<${tag}${start}>\n${items}\n</${tag}>`;
}
case 'blockquote':
return `<blockquote>\n${node.children.map(child => renderBlock(child, options)).join('\n')}\n</blockquote>`;
case 'table': {
const header = `<thead><tr>${node.header.map((cell, index) => renderTableCell('th', cell, node.align[index], options)).join('')}</tr></thead>`;
const body = node.rows.length
? `<tbody>${node.rows
.map(row => `<tr>${row.map((cell, index) => renderTableCell('td', cell, node.align[index], options)).join('')}</tr>`)
.join('')}</tbody>`
: '';
return `<table>${header}${body}</table>`;
}
case 'footnotes':
return renderFootnotes(node.items, options);
case 'thematicBreak':
return '<hr>';
case 'html':
return options.allowHtml ? node.value : escapeHtml(node.value);
case 'callout':
return renderCallout(node, options);
case 'component':
return renderComponent(node, options);
}
}
export function renderInline(node, options = {}) {
const extension = renderExtension(node, options);
if (extension !== undefined)
return extension;
switch (node.type) {
case 'text':
return escapeHtml(node.value);
case 'inlineCode':
return `<code>${escapeHtml(node.value)}</code>`;
case 'strong':
return `<strong>${renderInlines(node.children, options)}</strong>`;
case 'emphasis':
return `<em>${renderInlines(node.children, options)}</em>`;
case 'strike':
return `<del>${renderInlines(node.children, options)}</del>`;
case 'footnoteReference':
return `<sup><a id="user-content-fnref-${escapeAttr(footnoteReferenceId(node))}" data-footnote-ref="" aria-describedby="footnote-label" href="#user-content-fn-${escapeAttr(node.id)}">${node.number}</a></sup>`;
case 'link':
return `<a href="${escapeAttr(node.href)}"${node.title ? ` title="${escapeAttr(node.title)}"` : ''}>${renderInlines(node.children, options)}</a>`;
case 'image':
return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt)}"${node.title ? ` title="${escapeAttr(node.title)}"` : ''}>`;
case 'break':
return '<br>';
case 'inlineHtml':
return options.allowHtml ? node.value : escapeHtml(node.value);
}
}
export function renderDocument(document, options = {}) {
return renderHtml(document, options);
}
function renderInlines(nodes, options) {
return nodes.map(node => renderInline(node, options)).join('');
}
function renderCodeBlock(node, options) {
const lang = node.lang ?? 'plaintext';
const preAttrs = [
`class="tm-code${options.codeLineNumbers ? ' tm-code--line-numbers' : ''}"`,
`data-lang="${escapeAttr(lang)}"`,
node.title ? `data-code-title="${escapeAttr(node.title)}"` : '',
node.file ? `data-filename="${escapeAttr(node.file)}"` : '',
node.framework ? `data-framework="${escapeAttr(node.framework)}"` : '',
]
.filter(Boolean)
.join(' ');
const html = options.highlighter?.(node.value, lang, {
...(node.highlightLines && { highlightLines: node.highlightLines }),
...(options.codeLineNumbers !== undefined && { lineNumbers: options.codeLineNumbers }),
}) ?? escapeHtml(node.value);
const pre = `<pre ${preAttrs}><code class="language-${escapeAttr(lang)}">${html}</code></pre>`;
if (!node.title)
return pre;
return `<figure class="tm-code-frame" data-lang="${escapeAttr(lang)}"><figcaption>${escapeHtml(node.title)}</figcaption>${pre}</figure>`;
}
function renderListItemChildren(children, checked, loose, options) {
const first = children[0];
const task = checked === undefined ? '' : `<input type="checkbox" disabled${checked ? ' checked' : ''}> `;
if (first?.type === 'paragraph') {
const content = `${task}${renderInlines(first.children, options)}`;
const rest = children.length > 1 ? `\n${children.slice(1).map(child => renderBlock(child, options)).join('\n')}` : '';
return `${loose ? `<p>${content}</p>` : content}${rest}`;
}
return `${task}${children.map(child => renderBlock(child, options)).join('\n')}`;
}
function renderCallout(node, options) {
const kind = node.kind.toLowerCase();
const children = node.children.map(child => renderBlock(child, options)).join('\n');
return `<div class="markdown-alert markdown-alert-${escapeAttr(kind)}"><p class="markdown-alert-title">${escapeHtml(node.title)}</p><div class="markdown-alert-content">${children}</div></div>`;
}
function renderFootnotes(items, options) {
const renderedItems = items.map(item => `<li id="user-content-fn-${escapeAttr(item.id)}">\n${renderFootnoteItem(item, options)}\n</li>`).join('\n');
return `<section data-footnotes="" class="footnotes"><h2 id="footnote-label" class="sr-only">Footnotes${renderHeadingAnchor('footnote-label', options)}</h2>\n<ol>\n${renderedItems}\n</ol>\n</section>`;
}
function renderFootnoteItem(item, options) {
const backref = renderFootnoteBackrefs(item);
const lastIndex = item.children.length - 1;
if (lastIndex < 0)
return `<p>${backref.trimStart()}</p>`;
return item.children
.map((child, index) => {
if (index === lastIndex && child.type === 'paragraph') {
return `<p>${renderInlines(child.children, options)}${backref}</p>`;
}
return renderBlock(child, options);
})
.join('\n');
}
function renderFootnoteBackrefs(item) {
let result = '';
for (let index = 1; index <= (item.referenceCount ?? 1); index++) {
const referenceId = index === 1 ? item.id : `${item.id}-${index}`;
const label = index === 1 ? `${item.number}` : `${item.number}-${index}`;
result += ` <a data-footnote-backref="" aria-label="Back to reference ${label}" class="data-footnote-backref" href="#user-content-fnref-${escapeAttr(referenceId)}">↩</a>`;
}
return result;
}
function footnoteReferenceId(node) {
return node.referenceIndex && node.referenceIndex > 1 ? `${node.id}-${node.referenceIndex}` : node.id;
}
function renderComponent(node, options) {
const tag = node.tagName ?? 'md-comment-component';
const attrs = renderComponentAttrs(node);
const children = node.children.map(child => renderBlock(child, options)).join('\n');
return `<${tag}${attrs}>${children}</${tag}>`;
}
function renderTableCell(tag, cell, align, options) {
const style = align ? ` style="text-align:${align}"` : '';
return `<${tag}${style}>${renderInlines(cell.children, options)}</${tag}>`;
}
function renderExtension(node, options) {
for (const extension of options.extensions ?? []) {
const rendered = extension.renderHtml?.(node, {
options,
renderBlock: block => renderBlock(block, options),
renderInline: inline => renderInline(inline, options),
});
if (rendered !== undefined)
return rendered;
}
return undefined;
}
function renderHeadingAnchor(id, options) {
const headingAnchors = options.headingAnchors;
if (!id || !headingAnchors)
return '';
const anchorOptions = typeof headingAnchors === 'object' ? headingAnchors : {};
return `<a href="#${escapeAttr(id)}" aria-hidden="${anchorOptions.ariaHidden ?? true}" class="${escapeAttr(anchorOptions.className ?? 'anchor-heading anchor-heading-link')}" tabindex="${anchorOptions.tabIndex ?? -1}">${escapeHtml(anchorOptions.content ?? '#')}</a>`;
}
function renderComponentAttrs(node) {
const props = {
...node.properties,
};
if (!node.tagName) {
props['data-component'] = node.name;
if (!props['data-attributes'])
props['data-attributes'] = JSON.stringify(node.attributes);
}
const entries = Object.entries(props);
if (!entries.length)
return '';
return ` ${entries.map(([key, value]) => `${key}="${escapeAttr(value)}"`).join(' ')}`;
}