/** * @packageDocumentation * @module @knapsack/rendering-utils/blocks-to-html * @description Convert Knapsack Blocks to html */ import { type BlockConfig } from '@knapsack/types'; import { convertTipTapJsonToHtml, type JSONContent, } from '@knapsack/tiptap-utils'; import os from 'node:os'; import domPurify from 'isomorphic-dompurify'; const EOL = os.EOL || '\n'; async function mdToHtml(md: string): Promise { const { marked } = await import('marked'); return marked.parse(md); } // Utility to escape HTML attribute values function escapeAttr(val = '') { return val .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); } // TipTap's generateHTML throws (e.g. RangeError: Unknown node type) when // stored prose JSON references a node type not registered in the schema. // That's schema drift, not a programming bug — treat it like the video // guard below and degrade to empty content so the rest of the document // still renders. function tiptapToHtmlSafe( content: JSONContent, blockId: string, blockType: string, ): string { try { return convertTipTapJsonToHtml(content); } catch (err) { console.error( `[blocks-to-html] TipTap render failed for block "${blockId}" (${blockType}):`, err, ); return ''; } } const GUIDELINE_LABELS = { do: 'Do', dont: "Don't", caution: 'Caution', } as const; async function blockToHtml(b: BlockConfig): Promise { if (!b.data) return ''; switch (b.blockType) { case 'code-snippet': case 'table': case 'text-editor': return tiptapToHtmlSafe(b.data.content, b.id, b.blockType); case 'guidelines': { const guidelines = b.data.guidelines ?? []; if (guidelines.length === 0) return ''; const sections = guidelines .map((g) => { const label = GUIDELINE_LABELS[g.type] ?? g.type; const parts: string[] = [`

${escapeAttr(label)}

`]; if (g.image?.src) { const alt = g.image.alt ?? g.image.title ?? label; parts.push( `${escapeAttr(alt)}`, ); } if (g.content) { const inner = tiptapToHtmlSafe( g.content, `${b.id}--${g.id}`, b.blockType, ); if (inner) parts.push(inner); } return `
${parts.join('')}
`; }) .join(EOL); return sections; } case 'markdown-block': return mdToHtml( Array.isArray(b.data.md) ? b.data.md.join(EOL) : (b.data.md ?? ''), ); case 'divider-block': return '
'; case 'callout': { const type = `${escapeAttr(b.data.type)}`; const content = tiptapToHtmlSafe(b.data.content, b.id, b.blockType); return `
${type}: ${content}
`; } case 'image-block': return ( b.data.images ?.map( (i) => `${escapeAttr(
                i.caption ?? '',
              )}`, ) .join(EOL + EOL) ?? '' ); case 'video': { const url = b.data.video?.url; return url ? `