@pilotiq/tiptap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/Block.d.ts +47 -0
  4. package/dist/Block.d.ts.map +1 -0
  5. package/dist/Block.js +56 -0
  6. package/dist/Block.js.map +1 -0
  7. package/dist/MentionProvider.d.ts +97 -0
  8. package/dist/MentionProvider.d.ts.map +1 -0
  9. package/dist/MentionProvider.js +104 -0
  10. package/dist/MentionProvider.js.map +1 -0
  11. package/dist/RichTextField.d.ts +286 -0
  12. package/dist/RichTextField.d.ts.map +1 -0
  13. package/dist/RichTextField.js +369 -0
  14. package/dist/RichTextField.js.map +1 -0
  15. package/dist/extensions/BlockNodeExtension.d.ts +41 -0
  16. package/dist/extensions/BlockNodeExtension.d.ts.map +1 -0
  17. package/dist/extensions/BlockNodeExtension.js +103 -0
  18. package/dist/extensions/BlockNodeExtension.js.map +1 -0
  19. package/dist/extensions/DragHandleExtension.d.ts +19 -0
  20. package/dist/extensions/DragHandleExtension.d.ts.map +1 -0
  21. package/dist/extensions/DragHandleExtension.js +166 -0
  22. package/dist/extensions/DragHandleExtension.js.map +1 -0
  23. package/dist/extensions/GridExtension.d.ts +49 -0
  24. package/dist/extensions/GridExtension.d.ts.map +1 -0
  25. package/dist/extensions/GridExtension.js +105 -0
  26. package/dist/extensions/GridExtension.js.map +1 -0
  27. package/dist/extensions/MentionExtension.d.ts +71 -0
  28. package/dist/extensions/MentionExtension.d.ts.map +1 -0
  29. package/dist/extensions/MentionExtension.js +165 -0
  30. package/dist/extensions/MentionExtension.js.map +1 -0
  31. package/dist/extensions/MergeTagExtension.d.ts +24 -0
  32. package/dist/extensions/MergeTagExtension.d.ts.map +1 -0
  33. package/dist/extensions/MergeTagExtension.js +57 -0
  34. package/dist/extensions/MergeTagExtension.js.map +1 -0
  35. package/dist/extensions/SlashCommandExtension.d.ts +71 -0
  36. package/dist/extensions/SlashCommandExtension.d.ts.map +1 -0
  37. package/dist/extensions/SlashCommandExtension.js +244 -0
  38. package/dist/extensions/SlashCommandExtension.js.map +1 -0
  39. package/dist/extensions/TextSizeMarks.d.ts +33 -0
  40. package/dist/extensions/TextSizeMarks.d.ts.map +1 -0
  41. package/dist/extensions/TextSizeMarks.js +47 -0
  42. package/dist/extensions/TextSizeMarks.js.map +1 -0
  43. package/dist/index.d.ts +8 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +8 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/plugin.d.ts +18 -0
  48. package/dist/plugin.d.ts.map +1 -0
  49. package/dist/plugin.js +25 -0
  50. package/dist/plugin.js.map +1 -0
  51. package/dist/react/BlockNodeView.d.ts +19 -0
  52. package/dist/react/BlockNodeView.d.ts.map +1 -0
  53. package/dist/react/BlockNodeView.js +60 -0
  54. package/dist/react/BlockNodeView.js.map +1 -0
  55. package/dist/react/BlockSidePanel.d.ts +105 -0
  56. package/dist/react/BlockSidePanel.d.ts.map +1 -0
  57. package/dist/react/BlockSidePanel.js +339 -0
  58. package/dist/react/BlockSidePanel.js.map +1 -0
  59. package/dist/react/FloatingToolbar.d.ts +13 -0
  60. package/dist/react/FloatingToolbar.d.ts.map +1 -0
  61. package/dist/react/FloatingToolbar.js +113 -0
  62. package/dist/react/FloatingToolbar.js.map +1 -0
  63. package/dist/react/MentionMenu.d.ts +26 -0
  64. package/dist/react/MentionMenu.d.ts.map +1 -0
  65. package/dist/react/MentionMenu.js +64 -0
  66. package/dist/react/MentionMenu.js.map +1 -0
  67. package/dist/react/Palette.d.ts +26 -0
  68. package/dist/react/Palette.d.ts.map +1 -0
  69. package/dist/react/Palette.js +21 -0
  70. package/dist/react/Palette.js.map +1 -0
  71. package/dist/react/SlashMenu.d.ts +24 -0
  72. package/dist/react/SlashMenu.d.ts.map +1 -0
  73. package/dist/react/SlashMenu.js +74 -0
  74. package/dist/react/SlashMenu.js.map +1 -0
  75. package/dist/react/TableFloatingToolbar.d.ts +7 -0
  76. package/dist/react/TableFloatingToolbar.d.ts.map +1 -0
  77. package/dist/react/TableFloatingToolbar.js +108 -0
  78. package/dist/react/TableFloatingToolbar.js.map +1 -0
  79. package/dist/react/TiptapEditor.d.ts +20 -0
  80. package/dist/react/TiptapEditor.d.ts.map +1 -0
  81. package/dist/react/TiptapEditor.js +398 -0
  82. package/dist/react/TiptapEditor.js.map +1 -0
  83. package/dist/react/Toolbar.d.ts +45 -0
  84. package/dist/react/Toolbar.d.ts.map +1 -0
  85. package/dist/react/Toolbar.js +204 -0
  86. package/dist/react/Toolbar.js.map +1 -0
  87. package/dist/react/toolbarButtons.d.ts +36 -0
  88. package/dist/react/toolbarButtons.d.ts.map +1 -0
  89. package/dist/react/toolbarButtons.js +300 -0
  90. package/dist/react/toolbarButtons.js.map +1 -0
  91. package/dist/register.d.ts +20 -0
  92. package/dist/register.d.ts.map +1 -0
  93. package/dist/register.js +27 -0
  94. package/dist/register.js.map +1 -0
  95. package/dist/render.d.ts +89 -0
  96. package/dist/render.d.ts.map +1 -0
  97. package/dist/render.js +439 -0
  98. package/dist/render.js.map +1 -0
  99. package/package.json +92 -0
  100. package/src/Block.ts +75 -0
  101. package/src/MentionProvider.ts +153 -0
  102. package/src/RichTextField.test.ts +447 -0
  103. package/src/RichTextField.ts +508 -0
  104. package/src/extensions/BlockNodeExtension.ts +134 -0
  105. package/src/extensions/DragHandleExtension.ts +184 -0
  106. package/src/extensions/GridExtension.test.ts +31 -0
  107. package/src/extensions/GridExtension.ts +138 -0
  108. package/src/extensions/MentionExtension.ts +248 -0
  109. package/src/extensions/MergeTagExtension.ts +75 -0
  110. package/src/extensions/SlashCommandExtension.test.ts +147 -0
  111. package/src/extensions/SlashCommandExtension.ts +332 -0
  112. package/src/extensions/TextSizeMarks.ts +73 -0
  113. package/src/index.ts +28 -0
  114. package/src/plugin.test.ts +19 -0
  115. package/src/plugin.ts +26 -0
  116. package/src/react/BlockNodeView.tsx +99 -0
  117. package/src/react/BlockSidePanel.test.ts +412 -0
  118. package/src/react/BlockSidePanel.tsx +451 -0
  119. package/src/react/FloatingToolbar.tsx +304 -0
  120. package/src/react/MentionMenu.tsx +120 -0
  121. package/src/react/Palette.tsx +86 -0
  122. package/src/react/SlashMenu.tsx +129 -0
  123. package/src/react/TableFloatingToolbar.tsx +154 -0
  124. package/src/react/TiptapEditor.tsx +535 -0
  125. package/src/react/Toolbar.tsx +438 -0
  126. package/src/react/toolbarButtons.tsx +579 -0
  127. package/src/register.test.ts +14 -0
  128. package/src/register.ts +27 -0
  129. package/src/render.test.ts +745 -0
  130. package/src/render.ts +480 -0
package/src/render.ts ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Server-safe Tiptap renderer.
3
+ *
4
+ * Walks a Tiptap JSON document (or HTML string) and returns an HTML string.
5
+ * Pure function — no DOM, no Tiptap runtime, no React. Safe to call from
6
+ * any server context: route handlers, page-data builders, RSC, edge.
7
+ *
8
+ * Output is intentionally NOT sanitized. Same posture as `Markdown` /
9
+ * `Html` display primes in `@pilotiq/pilotiq` — admin-trusted authors only.
10
+ * Text content gets HTML-escaped, link / image hrefs get scheme-checked,
11
+ * but the surrounding markup is constructed by us, not parsed from user
12
+ * input, so there's no place an unexpected tag can sneak in.
13
+ *
14
+ * Coverage matches what `RichTextField` ships in Phases A-G:
15
+ * nodes — doc / paragraph / heading / blockquote / codeBlock / bulletList
16
+ * / orderedList / listItem / horizontalRule / hardBreak / text
17
+ * / image / table / tableRow / tableCell / tableHeader
18
+ * / details / detailsSummary / detailsContent
19
+ * / grid / gridColumn
20
+ * / mergeTag / mention
21
+ * marks — bold / italic / strike / underline / subscript / superscript
22
+ * / code / link / textStyle (color) / highlight (color)
23
+ * / lead (paragraph emphasis) / small (semantic <small>)
24
+ * attrs — heading.level / orderedList.start / codeBlock.language
25
+ * / paragraph.textAlign + heading.textAlign
26
+ * / image.src + alt + title + width + height
27
+ * / tableCell.colspan + rowspan + colwidth (also tableHeader)
28
+ * / mergeTag.id / mention.id + label + trigger
29
+ * custom blocks — render to `<div data-type="..." data-attrs="...">` so
30
+ * consumers can replay or style by data-type.
31
+ */
32
+
33
+ export interface RenderRichTextOptions {
34
+ /**
35
+ * Override the rendering of a custom block (anything that isn't a
36
+ * built-in node). Receives the raw node; return the HTML string.
37
+ * Default emits `<div data-type="..." data-attrs="...">`.
38
+ */
39
+ renderBlock?: (node: TiptapNode) => string
40
+ /**
41
+ * Substitution map for `{{ tag }}` placeholders inserted via
42
+ * `RichTextField.mergeTags(['name', …])`. When the renderer hits a
43
+ * `mergeTag` node whose `id` is a key in this map, it emits the value
44
+ * (HTML-escaped). Unmatched ids fall back to a styled `<span class="merge-tag">`
45
+ * that preserves the placeholder visually.
46
+ */
47
+ mergeTags?: Record<string, string>
48
+ /**
49
+ * Override the label rendered inside a mention chip at read time. The
50
+ * editor caches the label on insert (so static snapshots stay self-
51
+ * contained), but rendered surfaces can call back into a directory or
52
+ * cache to refresh stale names. Return `undefined` to fall back to the
53
+ * cached label.
54
+ */
55
+ resolveMention?: (trigger: string, id: string) => string | undefined
56
+ }
57
+
58
+ /** Tiptap JSON node — structural, no runtime dep on `@tiptap/core`. */
59
+ export interface TiptapNode {
60
+ type: string
61
+ text?: string
62
+ attrs?: Record<string, unknown>
63
+ content?: TiptapNode[]
64
+ marks?: TiptapMark[]
65
+ }
66
+
67
+ export interface TiptapMark {
68
+ type: string
69
+ attrs?: Record<string, unknown>
70
+ }
71
+
72
+ /**
73
+ * Render Tiptap content to HTML.
74
+ *
75
+ * Accepts:
76
+ * - a Tiptap JSON document (`{ type: 'doc', content: [...] }`) — walked
77
+ * and converted node-by-node;
78
+ * - a JSON-encoded string of the same — parsed then walked;
79
+ * - a raw HTML string — returned verbatim (the editor was configured
80
+ * with `.storage('html')` and the value is already HTML).
81
+ *
82
+ * Returns `''` on null / undefined / unparseable input.
83
+ */
84
+ export function renderRichTextToHtml(content: unknown, opts: RenderRichTextOptions = {}): string {
85
+ if (content === null || content === undefined) return ''
86
+
87
+ if (typeof content === 'string') {
88
+ const trimmed = content.trimStart()
89
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
90
+ try {
91
+ const parsed = JSON.parse(content)
92
+ return renderNode(parsed, opts)
93
+ } catch {
94
+ return ''
95
+ }
96
+ }
97
+ return content
98
+ }
99
+
100
+ if (typeof content === 'object') return renderNode(content as TiptapNode, opts)
101
+
102
+ return ''
103
+ }
104
+
105
+ /**
106
+ * Detect whether a value looks like Tiptap rich-text content. Conservative:
107
+ * only matches the canonical document root (`{ type: 'doc', content: [...] }`),
108
+ * either object-form or as a JSON-encoded string. Plain text and arbitrary
109
+ * objects are NOT auto-detected — display surfaces should fall through to
110
+ * their default formatter.
111
+ */
112
+ export function isRichTextValue(v: unknown): boolean {
113
+ if (v === null || v === undefined) return false
114
+ if (typeof v === 'object') return isTiptapDoc(v)
115
+ if (typeof v === 'string') {
116
+ const trimmed = v.trimStart()
117
+ if (!trimmed.startsWith('{')) return false
118
+ try {
119
+ return isTiptapDoc(JSON.parse(v))
120
+ } catch {
121
+ return false
122
+ }
123
+ }
124
+ return false
125
+ }
126
+
127
+ function isTiptapDoc(v: unknown): boolean {
128
+ return (
129
+ typeof v === 'object' && v !== null
130
+ && (v as { type?: unknown }).type === 'doc'
131
+ && Array.isArray((v as { content?: unknown }).content)
132
+ )
133
+ }
134
+
135
+ // ─── Node walker ─────────────────────────────────────────────────────
136
+
137
+ function renderNode(node: unknown, opts: RenderRichTextOptions): string {
138
+ if (node === null || node === undefined) return ''
139
+ if (Array.isArray(node)) return node.map(n => renderNode(n, opts)).join('')
140
+ if (typeof node !== 'object') return ''
141
+
142
+ const n = node as TiptapNode
143
+
144
+ switch (n.type) {
145
+ case 'doc': return renderChildren(n, opts)
146
+ case 'paragraph': return wrap('p', n, opts, alignStyle(n))
147
+ case 'heading': {
148
+ const level = clampLevel(n.attrs?.['level'])
149
+ return wrap(`h${level}`, n, opts, alignStyle(n))
150
+ }
151
+ case 'blockquote': return wrap('blockquote', n, opts)
152
+ case 'codeBlock': {
153
+ const lang = typeof n.attrs?.['language'] === 'string'
154
+ ? n.attrs!['language'] as string
155
+ : undefined
156
+ const cls = lang ? ` class="language-${escapeAttr(lang)}"` : ''
157
+ return `<pre><code${cls}>${renderChildren(n, opts)}</code></pre>`
158
+ }
159
+ case 'bulletList': return wrap('ul', n, opts)
160
+ case 'orderedList': {
161
+ const startRaw = n.attrs?.['start']
162
+ const start = typeof startRaw === 'number' ? startRaw : Number(startRaw)
163
+ const startAttr = Number.isFinite(start) && start !== 1 ? ` start="${Math.trunc(start)}"` : ''
164
+ return `<ol${startAttr}>${renderChildren(n, opts)}</ol>`
165
+ }
166
+ case 'listItem': return wrap('li', n, opts)
167
+ case 'horizontalRule': return '<hr>'
168
+ case 'hardBreak': return '<br>'
169
+ case 'image': return renderImage(n)
170
+ case 'table': return renderTable(n, opts)
171
+ case 'tableRow': return wrap('tr', n, opts)
172
+ case 'tableCell': return renderCell('td', n, opts)
173
+ case 'tableHeader': return renderCell('th', n, opts)
174
+ case 'details': return renderDetails(n, opts)
175
+ case 'detailsSummary': return wrap('summary', n, opts)
176
+ // The editor's NodeView wraps content in a `<div data-type="detailsContent">`
177
+ // for click handling, but the read-side HTML doesn't need a wrapper —
178
+ // a `<details>` block's content sits directly after the `<summary>` per
179
+ // the platform spec, which matches reader expectations.
180
+ case 'detailsContent': return renderChildren(n, opts)
181
+ case 'grid': return renderGrid(n, opts)
182
+ case 'gridColumn': return wrap('div', n, opts)
183
+ case 'mergeTag': return renderMergeTag(n, opts)
184
+ case 'mention': return renderMention(n, opts)
185
+ case 'text': return renderText(n)
186
+ default:
187
+ if (opts.renderBlock) return opts.renderBlock(n)
188
+ return renderCustomBlock(n)
189
+ }
190
+ }
191
+
192
+ function renderChildren(n: TiptapNode, opts: RenderRichTextOptions): string {
193
+ if (!n.content) return ''
194
+ return n.content.map(c => renderNode(c, opts)).join('')
195
+ }
196
+
197
+ function wrap(tag: string, n: TiptapNode, opts: RenderRichTextOptions, extraAttrs = ''): string {
198
+ return `<${tag}${extraAttrs}>${renderChildren(n, opts)}</${tag}>`
199
+ }
200
+
201
+ function alignStyle(n: TiptapNode): string {
202
+ const align = n.attrs?.['textAlign']
203
+ if (typeof align !== 'string' || align === '' || align === 'left') return ''
204
+ // `start` / `end` / `center` / `justify` — TextAlign extension allows-list.
205
+ if (!/^[a-z]+$/.test(align)) return ''
206
+ return ` style="text-align: ${align}"`
207
+ }
208
+
209
+ function clampLevel(raw: unknown): number {
210
+ const n = typeof raw === 'number' ? raw : Number(raw)
211
+ if (!Number.isFinite(n)) return 1
212
+ return Math.min(6, Math.max(1, Math.trunc(n)))
213
+ }
214
+
215
+ // ─── Text + marks ────────────────────────────────────────────────────
216
+
217
+ function renderText(n: TiptapNode): string {
218
+ let html = escapeHtml(String(n.text ?? ''))
219
+ const marks = Array.isArray(n.marks) ? n.marks : []
220
+ // Tiptap stores marks innermost-first in array order; wrap from index
221
+ // 0 outward so the first mark ends up nested deepest — matches Tiptap's
222
+ // own DOM serialization.
223
+ for (const mark of marks) html = wrapMark(html, mark)
224
+ return html
225
+ }
226
+
227
+ function wrapMark(inner: string, mark: TiptapMark | undefined): string {
228
+ if (!mark || typeof mark !== 'object') return inner
229
+ const attrs = (mark.attrs ?? {}) as Record<string, unknown>
230
+ switch (mark.type) {
231
+ case 'bold': return `<strong>${inner}</strong>`
232
+ case 'italic': return `<em>${inner}</em>`
233
+ case 'strike': return `<s>${inner}</s>`
234
+ case 'underline': return `<u>${inner}</u>`
235
+ case 'subscript': return `<sub>${inner}</sub>`
236
+ case 'superscript': return `<sup>${inner}</sup>`
237
+ case 'code': return `<code>${inner}</code>`
238
+ case 'lead': return `<span class="lead">${inner}</span>`
239
+ case 'small': return `<small>${inner}</small>`
240
+ case 'link': {
241
+ const href = sanitizeUrl(attrs['href'])
242
+ const target = typeof attrs['target'] === 'string'
243
+ ? ` target="${escapeAttr(String(attrs['target']))}"` : ''
244
+ const rel = attrs['target'] === '_blank' ? ' rel="noopener noreferrer"' : ''
245
+ return `<a href="${href}"${target}${rel}>${inner}</a>`
246
+ }
247
+ case 'textStyle': {
248
+ const color = attrs['color']
249
+ if (typeof color !== 'string' || color === '') return inner
250
+ const safe = sanitizeColor(color)
251
+ if (!safe) return inner
252
+ return `<span style="color: ${safe}">${inner}</span>`
253
+ }
254
+ case 'highlight': {
255
+ const color = attrs['color']
256
+ if (typeof color !== 'string' || color === '') return `<mark>${inner}</mark>`
257
+ const safe = sanitizeColor(color)
258
+ if (!safe) return `<mark>${inner}</mark>`
259
+ return `<mark style="background-color: ${safe}">${inner}</mark>`
260
+ }
261
+ default:
262
+ return inner
263
+ }
264
+ }
265
+
266
+ // ─── Image ───────────────────────────────────────────────────────────
267
+
268
+ function renderImage(n: TiptapNode): string {
269
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>
270
+ const src = sanitizeUrl(attrs['src'])
271
+ // Don't emit a broken `<img>` if src couldn't be sanitized to anything
272
+ // meaningful. `sanitizeUrl` returns `'#'` for unsafe inputs — and `<img
273
+ // src="#">` re-fetches the page, which is the worst possible default.
274
+ if (src === '#') return ''
275
+ const alt = typeof attrs['alt'] === 'string' ? ` alt="${escapeAttr(String(attrs['alt']))}"` : ' alt=""'
276
+ const title = typeof attrs['title'] === 'string' ? ` title="${escapeAttr(String(attrs['title']))}"` : ''
277
+ const width = pixelAttr('width', attrs['width'])
278
+ const height = pixelAttr('height', attrs['height'])
279
+ return `<img src="${src}"${alt}${title}${width}${height}>`
280
+ }
281
+
282
+ function pixelAttr(name: string, raw: unknown): string {
283
+ if (raw === null || raw === undefined || raw === '') return ''
284
+ const n = typeof raw === 'number' ? raw : Number(raw)
285
+ if (!Number.isFinite(n) || n <= 0) return ''
286
+ return ` ${name}="${Math.trunc(n)}"`
287
+ }
288
+
289
+ // ─── Tables ──────────────────────────────────────────────────────────
290
+
291
+ /**
292
+ * Render a Tiptap `table` node. The Tiptap table extension stores per-column
293
+ * widths on individual cells via `colwidth: number[]` — we collect the widths
294
+ * from the first row and emit a `<colgroup>` so read-side renders match the
295
+ * editor's column proportions.
296
+ */
297
+ function renderTable(n: TiptapNode, opts: RenderRichTextOptions): string {
298
+ const colgroup = buildColgroup(n)
299
+ return `<table>${colgroup}<tbody>${renderChildren(n, opts)}</tbody></table>`
300
+ }
301
+
302
+ function buildColgroup(table: TiptapNode): string {
303
+ const firstRow = table.content?.find((c) => c.type === 'tableRow')
304
+ if (!firstRow || !firstRow.content) return ''
305
+ const widths: (number | null)[] = []
306
+ let hasAnyWidth = false
307
+ for (const cell of firstRow.content) {
308
+ if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue
309
+ const colspan = clampPositiveInt(cell.attrs?.['colspan']) ?? 1
310
+ const colwidth = cell.attrs?.['colwidth']
311
+ const widthArr = Array.isArray(colwidth)
312
+ ? colwidth.map((w) => clampPositiveInt(w))
313
+ : []
314
+ for (let i = 0; i < colspan; i++) {
315
+ const w = widthArr[i] ?? null
316
+ if (w !== null) hasAnyWidth = true
317
+ widths.push(w)
318
+ }
319
+ }
320
+ // Only emit a colgroup when at least one width is known. Tiptap's table
321
+ // extension only sets colwidth after the user drags a column-resize handle —
322
+ // out-of-the-box tables have no widths, so an always-emitted colgroup would
323
+ // be noise.
324
+ if (!hasAnyWidth) return ''
325
+ const cols = widths.map((w) => w !== null ? `<col style="width: ${w}px">` : '<col>')
326
+ return `<colgroup>${cols.join('')}</colgroup>`
327
+ }
328
+
329
+ function renderCell(tag: 'td' | 'th', n: TiptapNode, opts: RenderRichTextOptions): string {
330
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>
331
+ const colspan = clampPositiveInt(attrs['colspan'])
332
+ const rowspan = clampPositiveInt(attrs['rowspan'])
333
+ const span = [
334
+ colspan && colspan !== 1 ? ` colspan="${colspan}"` : '',
335
+ rowspan && rowspan !== 1 ? ` rowspan="${rowspan}"` : '',
336
+ ].join('')
337
+ return `<${tag}${span}>${renderChildren(n, opts)}</${tag}>`
338
+ }
339
+
340
+ function clampPositiveInt(raw: unknown): number | null {
341
+ if (raw === null || raw === undefined || raw === '') return null
342
+ const n = typeof raw === 'number' ? raw : Number(raw)
343
+ if (!Number.isFinite(n) || n <= 0) return null
344
+ return Math.trunc(n)
345
+ }
346
+
347
+ // ─── Details (collapsible blocks) ────────────────────────────────────
348
+
349
+ /**
350
+ * Render a `details` node. The `open` attribute round-trips the editor's
351
+ * open/closed state when `Details.configure({ persist: true })` is set —
352
+ * matches the platform `<details open>` attribute exactly so a reader's
353
+ * snapshot reflects how the author last left it.
354
+ */
355
+ function renderDetails(n: TiptapNode, opts: RenderRichTextOptions): string {
356
+ const isOpen = n.attrs?.['open'] === true
357
+ return `<details${isOpen ? ' open' : ''}>${renderChildren(n, opts)}</details>`
358
+ }
359
+
360
+ // ─── Grid (multi-column layout) ──────────────────────────────────────
361
+
362
+ /**
363
+ * Render a `grid` node. The `data-columns` attribute round-trips the
364
+ * editor's column count; class names mirror the editor's `renderHTML` so
365
+ * one stylesheet paints both surfaces (consumer owns the CSS — same
366
+ * posture as `lead` / `small`).
367
+ *
368
+ * Out-of-range column counts (anything other than 2 or 3) clamp to 2 —
369
+ * the schema enforces it on the editor side, but a tampered Tiptap JSON
370
+ * fed straight to the renderer shouldn't paint a `grid-cols-99` class.
371
+ */
372
+ function renderGrid(n: TiptapNode, opts: RenderRichTextOptions): string {
373
+ const cols = clampGridColumnsForRender(n.attrs?.['columns'])
374
+ return `<div class="pilotiq-grid pilotiq-grid-cols-${cols}">${renderChildren(n, opts)}</div>`
375
+ }
376
+
377
+ function clampGridColumnsForRender(raw: unknown): 2 | 3 {
378
+ const n = typeof raw === 'number' ? raw : Number(raw)
379
+ if (!Number.isFinite(n)) return 2
380
+ const trunc = Math.trunc(n)
381
+ return trunc === 3 ? 3 : 2
382
+ }
383
+
384
+ // ─── Merge tags + mentions ───────────────────────────────────────────
385
+
386
+ /**
387
+ * Render a `mergeTag` atom — either substitute the value from
388
+ * `opts.mergeTags` (HTML-escaped) or fall back to a styled `<span>` that
389
+ * preserves the placeholder visually so server-rendered previews stay
390
+ * informative when no substitution map is supplied.
391
+ */
392
+ function renderMergeTag(n: TiptapNode, opts: RenderRichTextOptions): string {
393
+ const id = String((n.attrs ?? {})['id'] ?? '').trim()
394
+ if (id === '') return ''
395
+ const map = opts.mergeTags
396
+ // Only substitute when the map explicitly carries the id — using
397
+ // `Object.prototype.hasOwnProperty` so `null` / empty-string substitutions
398
+ // still win over the fallback span.
399
+ if (map && Object.prototype.hasOwnProperty.call(map, id)) {
400
+ return escapeHtml(String(map[id] ?? ''))
401
+ }
402
+ return `<span class="merge-tag" data-id="${escapeAttr(id)}">{{ ${escapeHtml(id)} }}</span>`
403
+ }
404
+
405
+ /**
406
+ * Render a `mention` atom as a styled `<span>` carrying the cached label.
407
+ * `opts.resolveMention` can override the label per `(trigger, id)` pair —
408
+ * useful for refreshing display names from a directory at render time.
409
+ * Both `id` and `trigger` are required; missing either drops the chip.
410
+ */
411
+ function renderMention(n: TiptapNode, opts: RenderRichTextOptions): string {
412
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>
413
+ const id = String(attrs['id'] ?? '').trim()
414
+ const trigger = String(attrs['trigger'] ?? '').trim()
415
+ if (id === '' || trigger === '') return ''
416
+ const cached = String(attrs['label'] ?? '').trim()
417
+ const resolved = opts.resolveMention?.(trigger, id)
418
+ const label = resolved !== undefined ? resolved : (cached !== '' ? cached : id)
419
+ return (
420
+ `<span class="mention" data-trigger="${escapeAttr(trigger)}" data-id="${escapeAttr(id)}">` +
421
+ escapeHtml(`${trigger}${label}`) +
422
+ `</span>`
423
+ )
424
+ }
425
+
426
+ // ─── Custom blocks ───────────────────────────────────────────────────
427
+
428
+ function renderCustomBlock(n: TiptapNode): string {
429
+ const type = String(n.type ?? '')
430
+ const dataAttrs = n.attrs ? ` data-attrs="${escapeAttr(JSON.stringify(n.attrs))}"` : ''
431
+ const inner = n.content ? renderChildren(n, {}) : ''
432
+ return `<div data-type="${escapeAttr(type)}"${dataAttrs}>${inner}</div>`
433
+ }
434
+
435
+ // ─── Escapers + sanitizers ───────────────────────────────────────────
436
+
437
+ function escapeHtml(s: string): string {
438
+ return s.replace(/[&<>"']/g, ch => HTML_ESCAPES[ch] ?? ch)
439
+ }
440
+
441
+ const HTML_ESCAPES: Record<string, string> = {
442
+ '&': '&amp;',
443
+ '<': '&lt;',
444
+ '>': '&gt;',
445
+ '"': '&quot;',
446
+ "'": '&#39;',
447
+ }
448
+
449
+ function escapeAttr(s: string): string {
450
+ return escapeHtml(s)
451
+ }
452
+
453
+ /**
454
+ * Allowlist for color values. Accepts hex (`#abc` / `#abcdef`), rgb()/rgba(),
455
+ * hsl()/hsla(), oklch(), and named colors (alpha-only). Returns `''` for
456
+ * anything that doesn't match — caller falls back to no inline style.
457
+ */
458
+ function sanitizeColor(raw: string): string {
459
+ const v = raw.trim()
460
+ if (v === '') return ''
461
+ if (/^#[0-9a-f]{3,8}$/i.test(v)) return v
462
+ if (/^rgba?\([^()<>"']+\)$/i.test(v)) return v
463
+ if (/^hsla?\([^()<>"']+\)$/i.test(v)) return v
464
+ if (/^oklch\([^()<>"']+\)$/i.test(v)) return v
465
+ if (/^[a-z]+$/i.test(v)) return v
466
+ return ''
467
+ }
468
+
469
+ /**
470
+ * Block dangerous URL schemes. Anything that isn't an absolute http(s),
471
+ * mailto, tel, anchor (#…), root-relative (/…), or relative path falls
472
+ * back to `#`. Output is HTML-escaped.
473
+ */
474
+ function sanitizeUrl(raw: unknown): string {
475
+ if (typeof raw !== 'string') return '#'
476
+ const v = raw.trim()
477
+ if (v === '') return '#'
478
+ if (/^(?:javascript|data|vbscript):/i.test(v)) return '#'
479
+ return escapeAttr(v)
480
+ }