|
| 1 | +/** |
| 2 | + * Lightweight JSON-to-HTML converter for Tiptap content. |
| 3 | + * |
| 4 | + * This replaces the heavy `generateHTML()` from @tiptap/html which pulls in |
| 5 | + * StarterKit (~500KB). The feed viewer only needs to render paragraphs, text |
| 6 | + * with marks, mentions, lists, blockquotes, code blocks, and hard breaks. |
| 7 | + */ |
| 8 | + |
| 9 | +type JSONContent = { |
| 10 | + type?: string; |
| 11 | + attrs?: Record<string, unknown>; |
| 12 | + content?: JSONContent[]; |
| 13 | + text?: string; |
| 14 | + marks?: Array<{ type: string; attrs?: Record<string, unknown> }>; |
| 15 | +}; |
| 16 | + |
| 17 | +function escapeHtml(value: string): string { |
| 18 | + return value |
| 19 | + .replace(/&/g, "&") |
| 20 | + .replace(/</g, "<") |
| 21 | + .replace(/>/g, ">") |
| 22 | + .replace(/"/g, """) |
| 23 | + .replace(/'/g, "'"); |
| 24 | +} |
| 25 | + |
| 26 | +function renderMarks(text: string, marks?: JSONContent["marks"]): string { |
| 27 | + if (!marks || marks.length === 0) return escapeHtml(text); |
| 28 | + |
| 29 | + let html = escapeHtml(text); |
| 30 | + for (const mark of marks) { |
| 31 | + switch (mark.type) { |
| 32 | + case "bold": |
| 33 | + html = `<strong>${html}</strong>`; |
| 34 | + break; |
| 35 | + case "italic": |
| 36 | + html = `<em>${html}</em>`; |
| 37 | + break; |
| 38 | + case "strike": |
| 39 | + html = `<s>${html}</s>`; |
| 40 | + break; |
| 41 | + case "code": |
| 42 | + html = `<code>${html}</code>`; |
| 43 | + break; |
| 44 | + case "link": { |
| 45 | + const href = escapeHtml(String(mark.attrs?.href ?? "")); |
| 46 | + html = `<a href="${href}" rel="noopener noreferrer nofollow">${html}</a>`; |
| 47 | + break; |
| 48 | + } |
| 49 | + } |
| 50 | + } |
| 51 | + return html; |
| 52 | +} |
| 53 | + |
| 54 | +function renderNode(node: JSONContent): string { |
| 55 | + if (node.type === "text" && typeof node.text === "string") { |
| 56 | + return renderMarks(node.text, node.marks); |
| 57 | + } |
| 58 | + |
| 59 | + const children = node.content?.map(renderNode).join("") ?? ""; |
| 60 | + |
| 61 | + switch (node.type) { |
| 62 | + case "doc": |
| 63 | + return children; |
| 64 | + case "paragraph": |
| 65 | + return `<p>${children || "<br>"}</p>`; |
| 66 | + case "heading": { |
| 67 | + const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6); |
| 68 | + return `<h${level}>${children}</h${level}>`; |
| 69 | + } |
| 70 | + case "blockquote": |
| 71 | + return `<blockquote>${children}</blockquote>`; |
| 72 | + case "bulletList": |
| 73 | + return `<ul>${children}</ul>`; |
| 74 | + case "orderedList": { |
| 75 | + const start = node.attrs?.start; |
| 76 | + const attr = start && start !== 1 ? ` start="${start}"` : ""; |
| 77 | + return `<ol${attr}>${children}</ol>`; |
| 78 | + } |
| 79 | + case "listItem": |
| 80 | + return `<li>${children}</li>`; |
| 81 | + case "codeBlock": |
| 82 | + return `<pre><code>${children}</code></pre>`; |
| 83 | + case "horizontalRule": |
| 84 | + return "<hr>"; |
| 85 | + case "hardBreak": |
| 86 | + return "<br>"; |
| 87 | + case "mention": { |
| 88 | + const username = |
| 89 | + typeof node.attrs?.username === "string" |
| 90 | + ? node.attrs.username |
| 91 | + : typeof node.attrs?.label === "string" |
| 92 | + ? String(node.attrs.label).replace(/^@/, "") |
| 93 | + : String(node.attrs?.id ?? ""); |
| 94 | + return `<span class="mention-token">@${escapeHtml(username)}</span>`; |
| 95 | + } |
| 96 | + default: |
| 97 | + return children; |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +/** |
| 102 | + * Convert Tiptap JSON content to HTML without importing @tiptap/html or StarterKit. |
| 103 | + * Supports paragraphs, headings, lists, blockquotes, code blocks, mentions, |
| 104 | + * and inline marks (bold, italic, strike, code, link). |
| 105 | + */ |
| 106 | +export function convertContentToHtmlLite(doc: JSONContent): string { |
| 107 | + return renderNode(doc); |
| 108 | +} |
0 commit comments