import type { Root } from '@incremark/core'; type KatexRenderer = typeof import('katex')['default']; interface NodeLike { type?: unknown; children?: unknown; [key: string]: unknown; } const SAFE_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']); const HTTP_URL_RE = /^https?:\/\//i; const CLASS_TOKEN_INVALID_CHARS_RE = /[^a-z0-9_-]+/g; const EDGE_HYPHENS_RE = /^-+|-+$/g; const INCREMARK_BLOCK_MATH_PLACEHOLDER_RE = /
[\s\S]*?<\/code><\/pre><\/div>/g;
const INCREMARK_INLINE_MATH_PLACEHOLDER_RE = /[\s\S]*?<\/code>/g;
const HTML_ENTITY_RE = /&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g;
const SAFE_HTML_TAGS = new Set([
'a',
'article',
'b',
'blockquote',
'br',
'code',
'del',
'details',
'div',
'em',
'figcaption',
'figure',
'footer',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'i',
'img',
'kbd',
'li',
'ol',
'p',
'pre',
's',
'section',
'span',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'u',
'ul'
]);
const BLOCK_HTML_TAGS = new Set([
'article',
'blockquote',
'details',
'div',
'figcaption',
'figure',
'footer',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'li',
'ol',
'p',
'pre',
'section',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'ul'
]);
const VOID_HTML_TAGS = new Set(['br', 'hr', 'img']);
const SAFE_HTML_ATTRIBUTES = new Set([
'align',
'alt',
'class',
'colspan',
'height',
'href',
'id',
'open',
'rel',
'rowspan',
'src',
'target',
'title',
'width'
]);
const HEADING_SIZES: Record = {
1: '1.5rem',
2: '1.2rem',
3: '1.1rem',
4: '1.05rem',
5: '1rem',
6: '0.94rem'
};
const NAMED_HTML_ENTITIES: Record = {
amp: '&',
apos: "'",
gt: '>',
lt: '<',
quot: '"'
};
const CODE_COPY_BUTTON_HTML =
'';
export function renderIncremarkAst(root: Root): string {
return root.children.map((child) => renderBlock(child as NodeLike)).join('');
}
export async function renderKatexIncremarkHtml(html: string): Promise {
if (!html.includes('data-incremark-math=')) {
return html;
}
try {
const { default: katex } = await import('katex');
const withRenderedBlockMath = html.replace(
INCREMARK_BLOCK_MATH_PLACEHOLDER_RE,
(_match, encodedFormula: string) => renderKatexFormulaHtml(katex, decodeHtmlEntities(encodedFormula), true)
);
return withRenderedBlockMath.replace(
INCREMARK_INLINE_MATH_PLACEHOLDER_RE,
(_match, encodedFormula: string) => renderKatexFormulaHtml(katex, decodeHtmlEntities(encodedFormula), false)
);
} catch {
return html;
}
}
function renderBlock(node: NodeLike): string {
switch (getType(node)) {
case 'paragraph':
return wrapBlock('p', 'incremark-paragraph', renderInlineChildren(getChildren(node)));
case 'heading': {
const depth = clampHeadingDepth(getNumber(node, 'depth'));
const size = HEADING_SIZES[depth];
return `${renderInlineChildren(getChildren(node))} `;
}
case 'code':
return renderCodeBlock(node);
case 'blockquote':
return wrapBlock('blockquote', 'incremark-blockquote', renderBlockChildren(getChildren(node)));
case 'list':
return renderList(node);
case 'listItem':
return renderListItem(node);
case 'thematicBreak':
return '
';
case 'table':
return renderTable(node);
case 'math':
return renderMathBlock(node);
case 'htmlElement':
return renderHtmlElement(node);
case 'containerDirective':
case 'leafDirective':
return renderDirectiveBlock(node);
case 'footnoteDefinition':
return renderFootnoteDefinition(node);
case 'definition':
return '';
case 'html':
return renderCodeShell(`${escapeHtml(getString(node, 'value'))}
`);
default: {
const children = getChildren(node);
if (children.length > 0) {
return renderBlockChildren(children);
}
const value = getString(node, 'value');
return value ? wrapBlock('p', 'incremark-paragraph', escapeHtml(value)) : '';
}
}
}
function renderInline(node: NodeLike): string {
switch (getType(node)) {
case 'text':
return escapeHtml(getString(node, 'value'));
case 'strong':
return `${renderInlineChildren(getChildren(node))}`;
case 'emphasis':
return `${renderInlineChildren(getChildren(node))}`;
case 'delete':
return `${renderInlineChildren(getChildren(node))}`;
case 'inlineCode':
return `${escapeHtml(getString(node, 'value'))}`;
case 'link':
return renderLink(node);
case 'linkReference':
return renderInlineChildren(getChildren(node)) || escapeHtml(getString(node, 'label') || getString(node, 'identifier'));
case 'image':
return renderImage(node);
case 'imageReference':
return escapeHtml(getString(node, 'alt'));
case 'break':
return '
';
case 'inlineMath':
return renderInlineMath(node);
case 'footnoteReference':
return `[${escapeHtml(getString(node, 'identifier'))}]`;
case 'textDirective':
return renderDirectiveInline(node);
case 'htmlElement':
return renderHtmlElement(node);
case 'html':
return escapeHtml(getString(node, 'value'));
default: {
const children = getChildren(node);
if (children.length > 0) {
return renderInlineChildren(children);
}
return escapeHtml(getString(node, 'value'));
}
}
}
function renderBlockChildren(children: NodeLike[]): string {
return children.map((child) => renderBlock(child)).join('');
}
function renderInlineChildren(children: NodeLike[]): string {
return children.map((child) => renderInline(child)).join('');
}
function renderCodeShell(preHtml: string, headerHtml = ''): string {
return `${preHtml}`;
}
function renderCodeBlock(node: NodeLike): string {
const lang = sanitizeClassToken(getString(node, 'lang'));
const langLabel = lang ? `${escapeHtml(lang)}` : '';
const languageClass = lang ? ` class="language-${escapeAttribute(lang)}"` : '';
const code = `${escapeHtml(getString(node, 'value'))}
`;
return renderCodeShell(code, langLabel);
}
function renderMathBlock(node: NodeLike): string {
return renderKatexPlaceholder(getString(node, 'value'), true);
}
function renderInlineMath(node: NodeLike): string {
return renderKatexPlaceholder(getString(node, 'value'), false);
}
function renderList(node: NodeLike): string {
const ordered = getBoolean(node, 'ordered') === true;
const tag = ordered ? 'ol' : 'ul';
const classes = `incremark-block incremark-list incremark-list--${ordered ? 'ordered' : 'unordered'}`;
const start = ordered ? getNumber(node, 'start') : undefined;
const startAttr = ordered && typeof start === 'number' && start > 1 ? ` start="${start}"` : '';
return `<${tag} class="${classes}"${startAttr}>${getChildren(node).map((child) => renderListItem(child)).join('')}${tag}>`;
}
function renderListItem(node: NodeLike): string {
const children = getChildren(node);
const checked = getBoolean(node, 'checked');
if (typeof checked === 'boolean' && children[0] && getType(children[0]) === 'paragraph') {
const [first, ...rest] = children;
const firstContent = renderInlineChildren(getChildren(first));
const restContent = rest.map((child) => renderBlock(child)).join('');
return `${restContent} `;
}
const checkbox = typeof checked === 'boolean'
? ``
: '';
return `${checkbox}${children.map((child) => renderBlock(child)).join('')} `;
}
function renderTable(node: NodeLike): string {
const rows = getChildren(node);
if (rows.length === 0) {
return '';
}
const align = Array.isArray(node.align) ? node.align : [];
const [headerRow, ...bodyRows] = rows;
const thead = `${renderTableRow(headerRow, true, align)}`;
const tbody = bodyRows.length > 0
? `${bodyRows.map((row) => renderTableRow(row, false, align)).join('')}`
: '';
return `${thead}${tbody}
`;
}
function renderTableRow(row: NodeLike, header: boolean, align: unknown[]): string {
const cells = getChildren(row);
const tag = header ? 'th' : 'td';
const className = header ? 'incremark-table-header' : 'incremark-table-cell';
const renderedCells = cells.map((cell, index) => {
const alignment = typeof align[index] === 'string' ? String(align[index]) : '';
const style = alignment ? ` style="text-align: ${escapeAttribute(alignment)};"` : '';
return `<${tag} class="${className}"${style}>${renderInlineChildren(getChildren(cell))}${tag}>`;
}).join('');
return `${renderedCells} `;
}
function renderLink(node: NodeLike): string {
const href = sanitizeUrl(getString(node, 'url'));
const content = renderInlineChildren(getChildren(node)) || escapeHtml(getString(node, 'url'));
if (!href) {
return content;
}
const title = getString(node, 'title');
const titleAttr = title ? ` title="${escapeAttribute(title)}"` : '';
const relAttr = isExternalHttpUrl(href) ? ' rel="noreferrer"' : '';
return `${content}`;
}
function renderImage(node: NodeLike): string {
const src = sanitizeUrl(getString(node, 'url'));
if (!src) {
return escapeHtml(getString(node, 'alt'));
}
const alt = escapeAttribute(getString(node, 'alt'));
const title = getString(node, 'title');
const titleAttr = title ? ` title="${escapeAttribute(title)}"` : '';
return `
`;
}
function renderDirectiveBlock(node: NodeLike): string {
const name = sanitizeClassToken(getString(node, 'name')) || 'directive';
const content = renderBlockChildren(getChildren(node));
const attrs = renderHtmlAttributes(getAttributes(node, 'attributes'), [
'incremark-block',
'incremark-container',
`incremark-container--${name}`
]);
return `${content} `;
}
function renderDirectiveInline(node: NodeLike): string {
const name = sanitizeClassToken(getString(node, 'name')) || 'directive';
const content = renderInlineChildren(getChildren(node));
const attrs = renderHtmlAttributes(getAttributes(node, 'attributes'), [
'incremark-inline-directive',
`incremark-inline-directive--${name}`
]);
return `${content}`;
}
function renderHtmlElement(node: NodeLike): string {
const tagName = getString(node, 'tagName').toLowerCase();
if (!SAFE_HTML_TAGS.has(tagName)) {
return renderBlockChildren(getChildren(node));
}
const extraClasses = [
'incremark-html-element',
`incremark-html-element--${sanitizeClassToken(tagName)}`
];
if (tagName === 'img') {
extraClasses.push('incremark-image');
}
if (BLOCK_HTML_TAGS.has(tagName)) {
extraClasses.unshift('incremark-block');
}
const attrs = renderHtmlAttributes(getAttributes(node, 'attrs'), extraClasses);
if (VOID_HTML_TAGS.has(tagName)) {
return `<${tagName}${attrs} />`;
}
const content = BLOCK_HTML_TAGS.has(tagName)
? renderMixedChildren(getChildren(node))
: renderInlineChildren(getChildren(node));
return `<${tagName}${attrs}>${content}${tagName}>`;
}
function renderFootnoteDefinition(node: NodeLike): string {
const identifier = sanitizeClassToken(getString(node, 'identifier'));
const idAttr = identifier ? ` id="footnote-${escapeAttribute(identifier)}"` : '';
return `${renderBlockChildren(getChildren(node))}`;
}
function renderMixedChildren(children: NodeLike[]): string {
return children.map((child) => renderAny(child)).join('');
}
function renderAny(node: NodeLike): string {
const type = getType(node);
if (type === 'textDirective') {
return renderDirectiveInline(node);
}
if (type === 'htmlElement') {
const tagName = getString(node, 'tagName').toLowerCase();
return BLOCK_HTML_TAGS.has(tagName) ? renderBlock(node) : renderInline(node);
}
if (isInlineType(type)) {
return renderInline(node);
}
return renderBlock(node);
}
function wrapBlock(tag: string, className: string, content: string): string {
return `<${tag} class="incremark-block ${className}">${content}${tag}>`;
}
function renderKatexPlaceholder(formula: string, displayMode: boolean): string {
const trimmedFormula = formula.trim();
if (!trimmedFormula) {
return displayMode ? '' : '';
}
if (displayMode) {
return `${escapeHtml(trimmedFormula)}
`;
}
return `${escapeHtml(trimmedFormula)}`;
}
function renderKatexFormulaHtml(katex: KatexRenderer, formula: string, displayMode: boolean): string {
const trimmedFormula = formula.trim();
if (!trimmedFormula) {
return displayMode ? '' : '';
}
try {
const renderedFormula = katex.renderToString(trimmedFormula, {
displayMode,
output: 'htmlAndMathml',
throwOnError: false,
trust: false
});
if (displayMode) {
return `${renderedFormula}`;
}
return `${renderedFormula}`;
} catch {
return renderKatexFallbackHtml(trimmedFormula, displayMode);
}
}
function renderKatexFallbackHtml(formula: string, displayMode: boolean): string {
if (displayMode) {
return `${escapeHtml(formula)}
`;
}
return `${escapeHtml(formula)}`;
}
function renderHtmlAttributes(attributes: Record, extraClasses: string[]): string {
const rendered: string[] = [];
const classNames = [...extraClasses];
for (const [name, rawValue] of Object.entries(attributes)) {
const normalizedName = name.toLowerCase();
if (normalizedName.startsWith('on')) {
continue;
}
if (normalizedName === 'class') {
if (rawValue.trim()) {
classNames.push(rawValue.trim());
}
continue;
}
if (normalizedName === 'style') {
continue;
}
if (!SAFE_HTML_ATTRIBUTES.has(normalizedName) && !normalizedName.startsWith('aria-') && !normalizedName.startsWith('data-')) {
continue;
}
if (normalizedName === 'href' || normalizedName === 'src') {
const safeUrl = sanitizeUrl(rawValue);
if (!safeUrl) {
continue;
}
rendered.push(` ${normalizedName}="${escapeAttribute(safeUrl)}"`);
continue;
}
if (normalizedName === 'open') {
if (rawValue !== 'false') {
rendered.push(' open');
}
continue;
}
rendered.push(` ${normalizedName}="${escapeAttribute(rawValue)}"`);
}
if (classNames.length > 0) {
rendered.unshift(` class="${escapeAttribute(classNames.join(' '))}"`);
}
return rendered.join('');
}
function getAttributes(node: NodeLike, key: string): Record {
const value = node[key];
if (!value || typeof value !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(value as Record).flatMap(([name, attrValue]) => {
if (typeof attrValue !== 'string') {
return [];
}
return [[name, attrValue]];
})
);
}
function getChildren(node: NodeLike): NodeLike[] {
return Array.isArray(node.children) ? (node.children as NodeLike[]) : [];
}
function getType(node: NodeLike): string {
return typeof node.type === 'string' ? node.type : '';
}
function getString(node: NodeLike, key: string): string {
const value = node[key];
return typeof value === 'string' ? value : '';
}
function getBoolean(node: NodeLike, key: string): boolean | undefined {
const value = node[key];
return typeof value === 'boolean' ? value : undefined;
}
function getNumber(node: NodeLike, key: string): number | undefined {
const value = node[key];
return typeof value === 'number' ? value : undefined;
}
function clampHeadingDepth(depth: number | undefined): 1 | 2 | 3 | 4 | 5 | 6 {
if (!depth || depth < 1) {
return 1;
}
if (depth > 6) {
return 6;
}
return depth as 1 | 2 | 3 | 4 | 5 | 6;
}
function sanitizeUrl(url: string): string | null {
const trimmed = url.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('#') || trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
return trimmed;
}
try {
const parsed = new URL(trimmed);
return SAFE_URL_PROTOCOLS.has(parsed.protocol) ? trimmed : null;
} catch {
return null;
}
}
function isExternalHttpUrl(url: string): boolean {
return HTTP_URL_RE.test(url);
}
function isInlineType(type: string): boolean {
return type === 'break'
|| type === 'delete'
|| type === 'emphasis'
|| type === 'footnoteReference'
|| type === 'html'
|| type === 'image'
|| type === 'imageReference'
|| type === 'inlineCode'
|| type === 'inlineMath'
|| type === 'link'
|| type === 'linkReference'
|| type === 'strong'
|| type === 'text';
}
function sanitizeClassToken(value: string): string {
return value
.trim()
.toLowerCase()
.replace(CLASS_TOKEN_INVALID_CHARS_RE, '-')
.replace(EDGE_HYPHENS_RE, '');
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function escapeAttribute(value: string): string {
return escapeHtml(value);
}
function decodeHtmlEntities(value: string): string {
return value.replace(HTML_ENTITY_RE, (match, entity) => {
if (entity.startsWith('#x') || entity.startsWith('#X')) {
const codePoint = Number.parseInt(entity.slice(2), 16);
return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint);
}
if (entity.startsWith('#')) {
const codePoint = Number.parseInt(entity.slice(1), 10);
return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint);
}
return NAMED_HTML_ENTITIES[entity] ?? match;
});
}