@tanstack/markdown
Version:
A tiny, fast, deterministic Markdown renderer for blogs and documentation.
193 lines (192 loc) • 10.1 kB
JavaScript
import { Fragment, createElement } from 'react';
import { parseMarkdown } from './parser.js';
export function Markdown({ children, ...options }) {
return createElement(Fragment, null, renderMarkdownReact(children, options));
}
export function renderMarkdownReact(input, options = {}) {
const document = typeof input === 'string' ? parseMarkdown(input, options) : input;
return document.children.map((node, index) => renderBlockReact(node, options, `b:${index}`));
}
export function renderBlockReact(node, options = {}, key) {
switch (node.type) {
case 'heading':
return h(options, `h${node.depth}`, { key, ...(node.id ? { id: node.id } : {}), ...(node.framework ? { 'data-framework': node.framework } : {}) }, renderInlines(node.children, options), renderHeadingAnchorReact(node.id, options));
case 'paragraph':
return h(options, 'p', { key }, renderInlines(node.children, options));
case 'code':
return renderCodeBlockReact(node, options, key);
case 'list': {
const tag = node.ordered ? 'ol' : 'ul';
return h(options, tag, { key, ...(node.ordered && node.start && node.start !== 1 ? { start: node.start } : {}) }, node.items.map((item, index) => h(options, 'li', { key: index }, renderListItemChildrenReact(item.children, item.checked, node.loose, options, `${index}`))));
}
case 'blockquote':
return h(options, 'blockquote', { key }, node.children.map((child, index) => renderBlockReact(child, options, `${key}:${index}`)));
case 'table':
return h(options, 'table', { key }, h(options, 'thead', null, h(options, 'tr', null, node.header.map((cell, index) => renderTableCellReact('th', cell, node.align[index], options, index)))), node.rows.length
? h(options, 'tbody', null, node.rows.map((row, rowIndex) => h(options, 'tr', { key: rowIndex }, row.map((cell, index) => renderTableCellReact('td', cell, node.align[index], options, index)))))
: null);
case 'footnotes':
return renderFootnotesReact(node.items, options, key);
case 'thematicBreak':
return h(options, 'hr', { key });
case 'html':
return options.allowHtml
? h(options, 'div', { key, dangerouslySetInnerHTML: { __html: node.value } })
: h(options, 'p', { key }, node.value);
case 'callout':
return h(options, 'div', { key, className: `markdown-alert markdown-alert-${node.kind.toLowerCase()}` }, h(options, 'p', { className: 'markdown-alert-title' }, node.title), h(options, 'div', { className: 'markdown-alert-content' }, node.children.map((child, index) => renderBlockReact(child, options, `${key}:${index}`))));
case 'component':
return renderComponentReact(node, options, key);
}
}
export function renderInlineReact(node, options = {}, key) {
switch (node.type) {
case 'text':
return node.value;
case 'inlineCode':
return h(options, 'code', { key }, node.value);
case 'strong':
return h(options, 'strong', { key }, renderInlines(node.children, options));
case 'emphasis':
return h(options, 'em', { key }, renderInlines(node.children, options));
case 'strike':
return h(options, 'del', { key }, renderInlines(node.children, options));
case 'footnoteReference':
return h(options, 'sup', { key }, h(options, 'a', {
id: `user-content-fnref-${footnoteReferenceId(node)}`,
'data-footnote-ref': '',
'aria-describedby': 'footnote-label',
href: `#user-content-fn-${node.id}`,
}, node.number));
case 'link':
return h(options, 'a', { key, href: node.href, ...(node.title ? { title: node.title } : {}) }, renderInlines(node.children, options));
case 'image':
return h(options, 'img', { key, src: node.src, alt: node.alt, ...(node.title ? { title: node.title } : {}) });
case 'break':
return h(options, 'br', { key });
case 'inlineHtml':
return options.allowHtml
? h(options, 'span', { key, dangerouslySetInnerHTML: { __html: node.value } })
: node.value;
}
}
function renderInlines(nodes, options) {
return nodes.map((node, index) => renderInlineReact(node, options, `i:${index}`));
}
function renderCodeBlockReact(node, options, key) {
const lang = node.lang ?? 'plaintext';
const highlighter = options.highlighter;
const codeProps = {
className: `language-${lang}`,
};
const content = highlighter ? undefined : node.value;
const highlighted = highlighter
? {
dangerouslySetInnerHTML: {
__html: highlighter(node.value, lang, {
...(node.highlightLines && { highlightLines: node.highlightLines }),
...(options.codeLineNumbers !== undefined && { lineNumbers: options.codeLineNumbers }),
}),
},
}
: undefined;
const pre = h(options, 'pre', {
className: `tm-code${options.codeLineNumbers ? ' tm-code--line-numbers' : ''}`,
'data-lang': lang,
...(node.title ? { 'data-code-title': node.title } : {}),
...(node.file ? { 'data-filename': node.file } : {}),
...(node.framework ? { 'data-framework': node.framework } : {}),
}, h(options, 'code', { ...codeProps, ...highlighted }, content));
if (!node.title)
return h(options, Fragment, { key }, pre);
return h(options, 'figure', { key, className: 'tm-code-frame', 'data-lang': lang }, h(options, 'figcaption', null, node.title), pre);
}
function renderListItemChildrenReact(children, checked, loose, options, key) {
const [first, ...rest] = children;
const task = checked === undefined
? []
: [
h(options, 'input', {
key: `${key}:checkbox`,
type: 'checkbox',
disabled: true,
checked,
readOnly: true,
}),
' ',
];
if (first?.type === 'paragraph') {
const content = [...task, ...renderInlines(first.children, options)];
return [
...(loose ? [h(options, 'p', { key: `${key}:paragraph` }, content)] : content),
...rest.flatMap((child, childIndex) => renderListChildReact(child, loose, options, `${key}:${childIndex + 1}`)),
];
}
return [...task, ...children.flatMap((child, childIndex) => renderListChildReact(child, loose, options, `${key}:${childIndex}`))];
}
function renderListChildReact(child, loose, options, key) {
return !loose && child.type === 'paragraph' ? renderInlines(child.children, options) : [renderBlockReact(child, options, key)];
}
function renderTableCellReact(tag, cell, align, options, key) {
return h(options, tag, { key, ...(align ? { style: { textAlign: align } } : {}) }, renderInlines(cell.children, options));
}
function renderFootnotesReact(items, options, key) {
return h(options, 'section', { key, 'data-footnotes': '', className: 'footnotes' }, h(options, 'h2', { id: 'footnote-label', className: 'sr-only' }, 'Footnotes', renderHeadingAnchorReact('footnote-label', options)), h(options, 'ol', null, items.map(item => h(options, 'li', { key: item.id, id: `user-content-fn-${item.id}` }, renderFootnoteItemReact(item, options)))));
}
function renderFootnoteItemReact(item, options) {
const lastIndex = item.children.length - 1;
const backrefs = renderFootnoteBackrefsReact(item, options);
if (lastIndex < 0)
return [h(options, 'p', { key: 'backref-wrapper' }, backrefs.slice(1))];
return item.children.map((child, index) => {
if (index === lastIndex && child.type === 'paragraph') {
return h(options, 'p', { key: index }, renderInlines(child.children, options), backrefs);
}
return renderBlockReact(child, options, `${index}`);
});
}
function renderFootnoteBackrefsReact(item, options) {
const 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.push(' ', h(options, 'a', {
key: index,
'data-footnote-backref': '',
'aria-label': `Back to reference ${label}`,
className: 'data-footnote-backref',
href: `#user-content-fnref-${referenceId}`,
}, '\u21a9'));
}
return result;
}
function footnoteReferenceId(node) {
return node.referenceIndex && node.referenceIndex > 1 ? `${node.id}-${node.referenceIndex}` : node.id;
}
function h(options, tag, props, ...children) {
const component = typeof tag === 'string' ? options.components?.[tag] ?? tag : tag;
return createElement(component, props, ...children);
}
function renderComponentReact(node, options, key) {
const tag = node.tagName ?? 'md-comment-component';
const props = {
...(node.properties ?? {}),
};
if (!node.tagName) {
props['data-component'] = node.name;
if (!props['data-attributes'])
props['data-attributes'] = JSON.stringify(node.attributes);
}
return h(options, tag, { key, ...props }, node.children.map((child, index) => renderBlockReact(child, options, `${key}:${index}`)));
}
function renderHeadingAnchorReact(id, options) {
if (!id || !options.headingAnchors)
return null;
const anchorOptions = typeof options.headingAnchors === 'object' ? options.headingAnchors : {};
return h(options, 'a', {
href: `#${id}`,
'aria-hidden': anchorOptions.ariaHidden ?? true,
className: anchorOptions.className ?? 'anchor-heading anchor-heading-link',
tabIndex: anchorOptions.tabIndex ?? -1,
}, anchorOptions.content ?? '#');
}