@seed-ship/mcp-ui-solid 5.6.0 → 5.7.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.
package/dist/types.d.cts CHANGED
@@ -141,9 +141,20 @@ export interface TableVirtualizeOptions {
141
141
  */
142
142
  threshold?: number;
143
143
  }
144
+ /**
145
+ * Citation map entry — source of a `[N]` citation marker rendered inline
146
+ * inside table cells (v5.7.0). See
147
+ * `mcp-ui-solid/docs/briefs/BRIEF-citations-in-table-cells.md`.
148
+ */
149
+ export interface CitationEntry {
150
+ page: number | string;
151
+ file?: string;
152
+ file_id?: number | string;
153
+ }
144
154
  /**
145
155
  * Table component parameters
146
156
  * Updated Sprint Ultimate U.3: Added virtualization support
157
+ * Updated v5.7.0: Optional citationMap + citationRender for chip rendering
147
158
  */
148
159
  export interface TableComponentParams {
149
160
  title?: string;
@@ -179,6 +190,21 @@ export interface TableComponentParams {
179
190
  * Custom CSS class (Sprint 7)
180
191
  */
181
192
  className?: string;
193
+ /**
194
+ * Opt-in citation chip rendering (v5.7.0). Maps marker id (e.g. `1` from
195
+ * `[1]` or `[📄 CITATION 1]` in cell text) to its source. When set,
196
+ * `<TableRenderer>` replaces markers in cell strings with clickable
197
+ * chips carrying `data-citation-page` / `data-citation-doc` /
198
+ * `data-citation-verified` attributes that a host's delegated click
199
+ * handler can route. JSON-serializable — safe to send from MCP servers.
200
+ */
201
+ citationMap?: Record<string | number, CitationEntry>;
202
+ /**
203
+ * Optional override for the chip HTML (v5.7.0). When supplied, wins over
204
+ * the default chip shape. NOT JSON-serializable — must be wired by the
205
+ * consumer at render time, not from a server payload.
206
+ */
207
+ citationRender?: (id: number, mapping: CitationEntry | undefined) => string;
182
208
  }
183
209
  /**
184
210
  * Metric card component parameters
package/dist/types.d.ts CHANGED
@@ -141,9 +141,20 @@ export interface TableVirtualizeOptions {
141
141
  */
142
142
  threshold?: number;
143
143
  }
144
+ /**
145
+ * Citation map entry — source of a `[N]` citation marker rendered inline
146
+ * inside table cells (v5.7.0). See
147
+ * `mcp-ui-solid/docs/briefs/BRIEF-citations-in-table-cells.md`.
148
+ */
149
+ export interface CitationEntry {
150
+ page: number | string;
151
+ file?: string;
152
+ file_id?: number | string;
153
+ }
144
154
  /**
145
155
  * Table component parameters
146
156
  * Updated Sprint Ultimate U.3: Added virtualization support
157
+ * Updated v5.7.0: Optional citationMap + citationRender for chip rendering
147
158
  */
148
159
  export interface TableComponentParams {
149
160
  title?: string;
@@ -179,6 +190,21 @@ export interface TableComponentParams {
179
190
  * Custom CSS class (Sprint 7)
180
191
  */
181
192
  className?: string;
193
+ /**
194
+ * Opt-in citation chip rendering (v5.7.0). Maps marker id (e.g. `1` from
195
+ * `[1]` or `[📄 CITATION 1]` in cell text) to its source. When set,
196
+ * `<TableRenderer>` replaces markers in cell strings with clickable
197
+ * chips carrying `data-citation-page` / `data-citation-doc` /
198
+ * `data-citation-verified` attributes that a host's delegated click
199
+ * handler can route. JSON-serializable — safe to send from MCP servers.
200
+ */
201
+ citationMap?: Record<string | number, CitationEntry>;
202
+ /**
203
+ * Optional override for the chip HTML (v5.7.0). When supplied, wins over
204
+ * the default chip shape. NOT JSON-serializable — must be wired by the
205
+ * consumer at render time, not from a server payload.
206
+ */
207
+ citationRender?: (id: number, mapping: CitationEntry | undefined) => string;
182
208
  }
183
209
  /**
184
210
  * Metric card component parameters
@@ -0,0 +1,365 @@
1
+ # Brief — Citation chips inside `TableRenderer` cells
2
+
3
+ > **Status** : drafted 2026-05-02. Validated end-to-end via a server-side
4
+ > bridge in deposium_MCPs (commit `7df433ae`). Brief proposes lifting the
5
+ > chip-rendering responsibility into `mcp-ui-solid` so consumers stop
6
+ > mirroring chip HTML byte-for-byte.
7
+ >
8
+ > **Audience** : `@seed-ship/mcp-ui-solid` maintainer.
9
+ >
10
+ > **Effort** : ~half a day (helper + one renderCellValue branch + tests).
11
+ > Backward compatible (opt-in via new `params.citationMap`).
12
+
13
+ ---
14
+
15
+ ## 1. The user-facing problem
16
+
17
+ A table emitted via `ui_layout` `type: 'table'` with cells like
18
+ `"[1] ; [4] ; [6]"` (LLM citation markers) renders the markers as
19
+ **plain text** inside the MCP-UI grid. The host app (e.g. Solid chat
20
+ UI) has its own `transformCitationReferences` pipeline that turns
21
+ `[📄 CITATION N]` markers into clickable chips, but that pipeline
22
+ runs on `marked.parse(content)` HTML — it never reaches cells inside
23
+ `TableRenderer`, because `renderCellValue` operates on cell strings
24
+ independently.
25
+
26
+ Concretely (from a deposium chat answer with citations) :
27
+
28
+ ```
29
+ | Rang | Entité | … | Citations |
30
+ | ---- | ----------------- | -- | -------------------- |
31
+ | 1 | MSP | … | [1] ; [4] ; [6] | ← plain text, not clickable
32
+ | 2 | Milieu Consulting | … | [3] ; [4] ; [8] |
33
+ ```
34
+
35
+ The host app's user clicks the citation expecting the source-doc
36
+ panel to open ; nothing happens. Same answer rendered as inline
37
+ markdown table (no `ui_layout`) DOES produce clickable chips because
38
+ the host `transformCitationReferences` walks `<td>` content of the
39
+ `marked.parse` output. So we have a feature gap : MCP-UI styling
40
+ (search inside, sort, export) costs us clickable citations.
41
+
42
+ ## 2. What already works in MCP-UI v5.6.0
43
+
44
+ `renderCellValue` in `UIResourceRenderer.tsx` (L290 → L366) already
45
+ detects HTML in cell values and pipes through `DOMPurify.sanitize`
46
+ with this whitelist (L350-352) :
47
+
48
+ ```ts
49
+ ALLOWED_TAGS: ['a', 'strong', 'em', 'b', 'i', 'code', 'span', 'br',
50
+ 'button', 'svg', 'path'],
51
+ ALLOWED_ATTR: ['href', 'target', 'rel', 'class',
52
+ 'data-citation-page', 'data-citation-source',
53
+ 'data-citation-doc', 'data-citation-verified',
54
+ 'title', 'fill', 'stroke', 'viewBox',
55
+ 'stroke-linecap', 'stroke-linejoin', 'stroke-width', 'd'],
56
+ ADD_ATTR: ['target', 'rel'],
57
+ ```
58
+
59
+ Critically, `data-citation-page`, `data-citation-doc`, and
60
+ `data-citation-verified` are explicitly allowed. So if a cell value is
61
+ already valid chip HTML — `<span class="citation-ref"><button
62
+ data-citation-page="5" data-citation-doc="…">…</button></span>` —
63
+ it survives DOMPurify and renders as a clickable chip in the cell.
64
+
65
+ The host's click handler typically uses event delegation
66
+ (`target.closest('[data-citation-page]')`), so clicks inside MCP-UI
67
+ cells route to the same citation panel as inline markdown chips.
68
+ Confirmed working in deposium 2026-05-02.
69
+
70
+ ## 3. The current bridge (what to replace)
71
+
72
+ deposium_MCPs (commit `7df433ae`) added two server-side helpers :
73
+
74
+ - `renderCitationChipHTML(pageNum, fileName, verified)` — emits the
75
+ exact chip HTML shape that Solid `createCitationButton`
76
+ produces, with the same Tailwind classes + the 3 `data-citation-*`
77
+ attributes.
78
+ - `replaceCitationsInCellHTML(cellText, citationMap)` — walks a cell
79
+ string, normalizes bare `[N]` / `Citation [N]` to canonical
80
+ `[📄 CITATION N]`, then replaces each marker with chip HTML.
81
+
82
+ Used in `rag.ts` at the point where markdown tables are extracted from
83
+ the LLM answer and emitted as `ui_layout` table components.
84
+
85
+ **Why this is fragile and should move upstream** :
86
+
87
+ 1. **Byte-coupling** — the chip shape (CSS classes, attribute names,
88
+ SVG markup) lives twice : in `mcp-ui-solid`'s consumer (Solid host
89
+ `createCitationButton`) AND mirrored in deposium_MCPs. A change in
90
+ one site breaks visual consistency.
91
+ 2. **Per-consumer reinvention** — every MCP server that wants
92
+ clickable cells will write the same mirror. Most won't.
93
+ 3. **Sanitization risk** — server emits raw HTML. If a future
94
+ deposium_MCPs change forgets DOMPurify-allowed shape, cells break
95
+ silently.
96
+ 4. **citationMap leakage** — server has the map already (it sent it
97
+ via SSE `citation_map`), but bridge consumers must thread the same
98
+ map through to every helper call. mcp-ui-solid could just take it
99
+ as a `params.citationMap` once.
100
+
101
+ ## 4. Proposed API
102
+
103
+ ### 4.1 New `params.citationMap` (opt-in)
104
+
105
+ `UIComponent` `type: 'table'` accepts an optional `citationMap` :
106
+
107
+ ```ts
108
+ type TableParams = {
109
+ // existing :
110
+ title?: string
111
+ columns: Array<{ key: string; label: string; sortable?: boolean; … }>
112
+ rows: Array<Record<string, unknown>>
113
+ exportable?: boolean | { formats?: ('csv' | 'tsv' | 'json')[]; filename?: string }
114
+ searchable?: boolean | 'auto'
115
+ virtualize?: { enabled: boolean; rowHeight?: number; … }
116
+ pageSize?: number
117
+ // NEW :
118
+ citationMap?: Record<number, {
119
+ page: number | string
120
+ file?: string
121
+ file_id?: number | string
122
+ }>
123
+ }
124
+ ```
125
+
126
+ When `citationMap` is provided, `renderCellValue` runs an additional
127
+ transform on cell strings : matching `[📄 CITATION N]` (and bare
128
+ `[N]`, `Citation [N]`) markers are replaced with chip HTML before the
129
+ existing DOMPurify pass.
130
+
131
+ ### 4.2 Chip HTML shape
132
+
133
+ The chip shape is intentionally identical to what consumers commonly
134
+ ship for inline markdown chips, so a single CSS class graph styles
135
+ both. Default chip :
136
+
137
+ ```html
138
+ <span class="citation-ref inline-flex items-center gap-0.5 align-middle">
139
+ <span class="text-gray-500">[Doc - 5]</span>
140
+ <button class="inline-flex items-center ml-0.5 px-1 py-0.5 text-xs
141
+ bg-gray-800 hover:bg-gray-700 border border-gray-600
142
+ hover:border-teal-500 rounded cursor-pointer
143
+ transition-colors align-middle"
144
+ data-citation-page="5"
145
+ data-citation-doc="<URI-encoded fileName>"
146
+ data-citation-verified="true"
147
+ title="View source - Doc - 5">
148
+ <svg class="w-3 h-3" …>…</svg>
149
+ </button>
150
+ </span>
151
+ ```
152
+
153
+ Open question : should mcp-ui-solid ship its own neutral chip class
154
+ graph (e.g. `mcp-ui-citation-chip`), or deliberately match the
155
+ deposium-style classes ? See §7.
156
+
157
+ ### 4.3 Optional override : `params.citationRender`
158
+
159
+ For consumers that need a different chip shape (different button
160
+ text, different URL scheme, web-citation vs doc-citation), accept
161
+ an optional render function :
162
+
163
+ ```ts
164
+ type TableParams = {
165
+ // …
166
+ citationMap?: Record<number, { page; file?; file_id? }>
167
+ citationRender?: (
168
+ id: number,
169
+ mapping: { page; file?; file_id? } | undefined
170
+ ) => string // returns sanitized HTML string
171
+ }
172
+ ```
173
+
174
+ When both are provided, `citationRender` wins. The default render
175
+ (used when only `citationMap` is set) is the chip shape in §4.2.
176
+
177
+ This keeps the common case zero-config (just pass `citationMap`)
178
+ while letting heavy users opt out of the default shape.
179
+
180
+ ## 5. Implementation sketch
181
+
182
+ In `UIResourceRenderer.tsx`, extend `renderCellValue` with an
183
+ optional `citationCtx` arg :
184
+
185
+ ```ts
186
+ type CitationCtx = {
187
+ map: Record<number, { page; file?; file_id? }>
188
+ render?: (id: number, mapping: any) => string
189
+ }
190
+
191
+ function defaultCitationChip(
192
+ pageNum: number | string,
193
+ fileName: string,
194
+ verified: boolean = true
195
+ ): string {
196
+ const safeDocName = encodeURIComponent(fileName || '')
197
+ const label = fileName ? `${fileName} - ${pageNum}` : `${pageNum}`
198
+ if (!verified) {
199
+ return `<span class="citation-ref opacity-60">…line-through label…</span>`
200
+ }
201
+ return `<span class="citation-ref inline-flex …"><span>[${label}]</span><button data-citation-page="${pageNum}" data-citation-doc="${safeDocName}" data-citation-verified="true" …><svg …/></button></span>`
202
+ }
203
+
204
+ function transformCellCitations(text: string, ctx: CitationCtx): string {
205
+ // 1. normalize bare [N], `Citation [N]`, `[CITATION N]` → `[📄 CITATION N]`
206
+ let out = text.replace(/(?<![p.])\[(\d{1,2})\](?!\()/g, '[📄 CITATION $1]')
207
+ out = out.replace(/\bCitations?\s*\[(\d+)\]/gi, '[📄 CITATION $1]')
208
+ out = out.replace(/\[CITATION\s+(\d+)\]/gi, '[📄 CITATION $1]')
209
+ // 2. replace each marker with chip HTML
210
+ return out.replace(
211
+ /[【[]\s*📄\s*CITATION\s*(\d+)\s*[】\]]/gi,
212
+ (_m, idStr) => {
213
+ const id = parseInt(idStr, 10)
214
+ const mapping = ctx.map[id]
215
+ if (ctx.render) return ctx.render(id, mapping)
216
+ if (mapping) return defaultCitationChip(mapping.page, mapping.file ?? '', true)
217
+ // unresolved : drop silently when map populated (likely hallucination),
218
+ // else fallback placeholder
219
+ return Object.keys(ctx.map).length > 0 ? '' : `[réf. ${id}]`
220
+ },
221
+ )
222
+ }
223
+
224
+ export function renderCellValue(value: any, citationCtx?: CitationCtx): string {
225
+ // … existing body …
226
+ // After existing string conversion + cleanup, BEFORE the markdown-link /
227
+ // hasHtml / hasMarkdown branches, run the citation transform if ctx set :
228
+ if (citationCtx && typeof strValue === 'string') {
229
+ strValue = transformCellCitations(strValue, citationCtx)
230
+ }
231
+ // …rest unchanged (markdown link, hasHtml → DOMPurify with whitelist)…
232
+ }
233
+ ```
234
+
235
+ The `TableRenderer` body (L641 + L680) passes `citationCtx` from
236
+ `tableParams` :
237
+
238
+ ```ts
239
+ const citationCtx = tableParams.citationMap
240
+ ? { map: tableParams.citationMap, render: tableParams.citationRender }
241
+ : undefined
242
+
243
+ // later in <td>…
244
+ <div innerHTML={highlightQuery(
245
+ renderCellValue(row[column.key], citationCtx),
246
+ debouncedQuery(),
247
+ )} />
248
+ ```
249
+
250
+ The DOMPurify pass downstream (the existing `hasHtml` branch) keeps
251
+ the chip intact because the citation attributes are already
252
+ whitelisted (§2).
253
+
254
+ ## 6. Test plan
255
+
256
+ ### 6.1 Unit (`tests/components/TableRenderer.citation.test.tsx`)
257
+
258
+ - `citationMap` not set → cells render plain text (regression check)
259
+ - `citationMap = {1:{page:5, file:'A.pdf'}}`, cell `"[1]"` → cell HTML
260
+ contains `data-citation-page="5"`, `data-citation-doc="A.pdf"`, click
261
+ on the button bubbles a delegated event with the right attributes
262
+ - Multi-citation cell `"[1] ; [2]"` → 2 chips in cell
263
+ - Unresolved id `"[99]"` with non-empty map → cell empty (silently
264
+ dropped, mirror of typical host behavior on LLM hallucinations)
265
+ - Unresolved id with no map → cell has `[réf. 99]` placeholder
266
+ - `citationRender` override → wins over default shape
267
+ - `[p.5]` page-form → NOT touched (negative lookbehind)
268
+ - `[text](url)` markdown link → NOT touched (negative lookahead)
269
+ - Cell with mixed `**bold** [1]` → bold becomes `<strong>` AND chip
270
+ rendered (both transforms compose : citation first, then existing
271
+ marked-on-markdown branch ; the chip HTML survives marked because
272
+ it's `<span>` content, not markdown syntax)
273
+ - DOMPurify pass preserves all 3 `data-citation-*` attributes
274
+
275
+ ### 6.2 Integration
276
+
277
+ - Render a table with 10 rows, 6 columns, 5 citations per row,
278
+ `citationMap` of 8 entries → DOM has 50 `[data-citation-page]`
279
+ buttons, click delegation works (jsdom event simulation)
280
+ - Search input filtering by entity name does not break chip HTML
281
+ (highlightQuery should skip `<span>`/`<button>` content — already
282
+ handled by L283 `(<[^>]+>)|([^<]+)` pattern)
283
+ - CSV export of a table with chips → chips serialized as plain text
284
+ fallback (e.g. `[Doc - 5]` from the visible label, NOT the raw
285
+ `[📄 CITATION 1]` marker) — see §7 open question
286
+
287
+ ## 7. Open questions
288
+
289
+ 1. **Chip CSS class neutrality** — the deposium chip uses
290
+ `bg-deposium-slate-800`, `text-deposium-teal-400` etc. (a
291
+ custom Tailwind palette). The default chip shape proposed here uses
292
+ generic `bg-gray-800 text-teal-400` — works in any Tailwind setup
293
+ but won't visually match a host with a custom palette. Options :
294
+ (a) ship neutral classes + let host override via CSS `.citation-ref`
295
+ selector ; (b) accept a `chipClasses?: { wrapper, label, button }`
296
+ prop on table params ; (c) use unprefixed semantic CSS variables.
297
+ Recommendation : (a) for v1 simplicity — hosts already style
298
+ `.citation-ref` for their own inline markdown chips, so neutral
299
+ tailwind classes layered with host CSS gives consistent rendering
300
+ for free.
301
+
302
+ 2. **Export serialization** — when exporting a table with chips to
303
+ CSV, the chip HTML should NOT survive (commas in HTML, opaque
304
+ markup). Should `getTableCSV` (called by export menu) strip chips
305
+ to their visible label `[Doc - 5]`, or to the original
306
+ `[📄 CITATION 1]` marker, or to just the page number ? Probably
307
+ visible label ; tests should pin one choice.
308
+
309
+ 3. **Server-side normalization scope** — the `[N]` → `[📄 CITATION N]`
310
+ normalize step uses negative lookbehind `(?<![p.])` to avoid
311
+ `[p.5]` (page form). This duplicates host normalization (e.g.
312
+ Solid `normalizeCitations`). Acceptable duplication or should we
313
+ require the host to send already-canonical markers ? Recommendation
314
+ : include the normalize step — LLMs emit bare `[N]` more often
315
+ than `[📄 CITATION N]`, and host normalization is a markdown-pipeline
316
+ step the table renderer doesn't share. Better DX to handle it
317
+ inside the table.
318
+
319
+ 4. **Streaming / lazy citationMap** — during streaming, the
320
+ `citation_map` SSE event sometimes arrives AFTER token streaming
321
+ completes. ui_layout components are typically emitted at the end,
322
+ so by the time TableRenderer renders, citationMap should be in
323
+ place. But for hosts that show the table during streaming
324
+ (unlikely), reactivity through `params.citationMap` would matter.
325
+ For v1 — params is a snapshot at render time, no special-cased
326
+ reactivity. Hosts that need it can re-emit the ui_layout when the
327
+ map updates.
328
+
329
+ ## 8. Migration
330
+
331
+ 100% backward compatible. No `citationMap` set → old behavior. Hosts
332
+ opt in by adding `citationMap: gaResult.citation_map` to their table
333
+ params. Once landed, deposium_MCPs reverts commit `7df433ae`
334
+ (removes `renderCitationChipHTML` + `replaceCitationsInCellHTML`)
335
+ and the chat handler emits the raw `[📄 CITATION N]` markers in
336
+ cells with `params.citationMap` set.
337
+
338
+ ## 9. Why not just rely on host-side rendering ?
339
+
340
+ We considered : have hosts render the chip HTML themselves and pass
341
+ that as cell content. That's exactly the deposium bridge — and the
342
+ reason this brief exists. The byte-coupling and per-consumer
343
+ reinvention costs outweigh the simplicity benefit.
344
+
345
+ We also considered : `cellMode: 'markdown' | 'html' | 'plain'` with
346
+ `'markdown'` running marked.parse on cells. That solves bold/italic
347
+ but NOT citations — `marked.parse('[📄 CITATION 1]')` doesn't
348
+ produce a chip ; you'd still need a citation transform on top. So
349
+ this brief is basically that, with the citation transform first-class.
350
+
351
+ ## 10. References
352
+
353
+ - `mcp-ui-solid/src/components/UIResourceRenderer.tsx` :
354
+ - L290-366 `renderCellValue` (where the new citation branch goes)
355
+ - L345-353 DOMPurify whitelist (already allows our attributes)
356
+ - L372+ `TableRenderer` (where citationCtx flows from params to cells)
357
+ - `mcp-ui-solid/CHANGELOG.md` v5.6.0 — last stable shipped
358
+ - deposium_MCPs commit `7df433ae` — server-side bridge that this
359
+ brief obsoletes
360
+ - deposium_solid `ChatInterfaceStreaming.tsx`
361
+ - L253 `createCitationButton` — chip shape canonical
362
+ - L291-409 `transformCitationReferences` — the host pipeline
363
+ that this brief replicates inside MCP-UI for cell content
364
+ - L2360 `target.closest('[data-citation-page]')` — host click
365
+ delegation that catches both inline AND cell chips
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-ship/mcp-ui-solid",
3
- "version": "5.6.0",
3
+ "version": "5.7.0",
4
4
  "description": "SolidJS components for rendering MCP-generated UI resources",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -139,11 +139,11 @@
139
139
  }
140
140
  },
141
141
  "dependencies": {
142
+ "@seed-ship/mcp-ui-spec": "^5.0.3",
142
143
  "@types/dompurify": "^3.0.5",
143
144
  "dompurify": "^3.4.1",
144
145
  "marked": "^16.3.0",
145
- "zod": "^3.22.4",
146
- "@seed-ship/mcp-ui-spec": "^5.0.2"
146
+ "zod": "^3.22.4"
147
147
  },
148
148
  "devDependencies": {
149
149
  "@size-limit/esbuild": "^12.0.0",
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Citation chip rendering inside `<TableRenderer>` cells (v5.7.0).
3
+ *
4
+ * Spec: `mcp-ui-solid/docs/briefs/BRIEF-citations-in-table-cells.md`.
5
+ *
6
+ * Tests are split between the pure `renderCellValue(value, citationCtx)`
7
+ * helper (fast, no DOM) and a couple of integration assertions on a real
8
+ * `<UIResourceRenderer>` mount to catch wiring bugs.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach } from 'vitest'
12
+ import { render, cleanup } from '@solidjs/testing-library'
13
+ import { renderCellValue, UIResourceRenderer } from './UIResourceRenderer'
14
+ import type { CitationCtx } from './UIResourceRenderer'
15
+ import type { UIComponent, TableComponentParams } from '../types'
16
+
17
+ const baseMap: CitationCtx['map'] = {
18
+ '1': { page: 5, file: 'A.pdf' },
19
+ '2': { page: 12, file: 'B.pdf' },
20
+ }
21
+
22
+ describe('renderCellValue — citation transform (v5.7.0)', () => {
23
+ it('NO citationCtx → cell text is unchanged (regression)', () => {
24
+ expect(renderCellValue('[1] ; [4]')).toBe('[1] ; [4]')
25
+ })
26
+
27
+ it('citationCtx with mapped id → cell HTML carries data-citation-page + data-citation-doc', () => {
28
+ const html = renderCellValue('[1]', { map: baseMap })
29
+ expect(html).toContain('data-citation-page="5"')
30
+ expect(html).toContain('data-citation-doc="A.pdf"')
31
+ expect(html).toContain('data-citation-verified="true"')
32
+ expect(html).toContain('class="citation-ref')
33
+ })
34
+
35
+ it('multi-citation cell → multiple chips emitted', () => {
36
+ const html = renderCellValue('[1] ; [2]', { map: baseMap })
37
+ const matches = html.match(/data-citation-page="(\d+)"/g) ?? []
38
+ expect(matches).toHaveLength(2)
39
+ expect(html).toContain('data-citation-page="5"')
40
+ expect(html).toContain('data-citation-page="12"')
41
+ })
42
+
43
+ it('unresolved id with NON-EMPTY map → marker dropped silently (likely hallucination)', () => {
44
+ const html = renderCellValue('[99]', { map: baseMap })
45
+ expect(html).not.toContain('99')
46
+ expect(html).not.toContain('citation-ref')
47
+ expect(html).not.toContain('réf')
48
+ })
49
+
50
+ it('unresolved id with EMPTY map → human-visible `[réf. N]` placeholder', () => {
51
+ const html = renderCellValue('[99]', { map: {} })
52
+ expect(html).toContain('[réf. 99]')
53
+ })
54
+
55
+ it('citationRender override → wins over default chip shape', () => {
56
+ const html = renderCellValue('[1]', {
57
+ map: baseMap,
58
+ render: (id, mapping) => `<a class="custom-chip" data-id="${id}">${mapping?.file ?? '?'}</a>`,
59
+ })
60
+ expect(html).toContain('class="custom-chip"')
61
+ expect(html).toContain('data-id="1"')
62
+ expect(html).not.toContain('data-citation-verified')
63
+ })
64
+
65
+ it('`[p.5]` page form → NOT touched (negative lookbehind)', () => {
66
+ const html = renderCellValue('See [p.5]', { map: baseMap })
67
+ expect(html).toContain('[p.5]')
68
+ expect(html).not.toContain('data-citation-page')
69
+ })
70
+
71
+ it('`[text](url)` markdown link → NOT touched (citation regex skips parens)', () => {
72
+ const html = renderCellValue('[click](https://example.com)', { map: baseMap })
73
+ expect(html).toContain('href="https://example.com"')
74
+ expect(html).not.toContain('data-citation-page')
75
+ })
76
+
77
+ it('mixed `**bold** [1]` → bold becomes <strong> AND chip is rendered (compose)', () => {
78
+ const html = renderCellValue('**MSP** [1]', { map: baseMap })
79
+ expect(html).toContain('<strong>MSP</strong>')
80
+ expect(html).toContain('data-citation-page="5"')
81
+ })
82
+
83
+ it('canonical `[📄 CITATION 1]` marker → chip emitted directly (no normalize step needed)', () => {
84
+ const html = renderCellValue('[📄 CITATION 1]', { map: baseMap })
85
+ expect(html).toContain('data-citation-page="5"')
86
+ })
87
+
88
+ it('all 3 data-citation-* attrs survive DOMPurify (whitelist intact)', () => {
89
+ const html = renderCellValue('[1]', { map: baseMap })
90
+ expect(html).toContain('data-citation-page')
91
+ expect(html).toContain('data-citation-doc')
92
+ expect(html).toContain('data-citation-verified')
93
+ })
94
+
95
+ it('chip emits a button element (host click delegation target)', () => {
96
+ const html = renderCellValue('[1]', { map: baseMap })
97
+ expect(html).toContain('<button')
98
+ expect(html).toMatch(/<button[^>]*data-citation-page="5"/)
99
+ })
100
+ })
101
+
102
+ describe('<TableRenderer> — citationMap wiring (v5.7.0)', () => {
103
+ beforeEach(() => {
104
+ cleanup()
105
+ })
106
+
107
+ function tableComponent(params: Partial<TableComponentParams>): UIComponent {
108
+ return {
109
+ id: 'tbl-cit',
110
+ type: 'table',
111
+ position: { colStart: 1, colSpan: 12 },
112
+ params: {
113
+ columns: [
114
+ { key: 'name', label: 'Name' },
115
+ { key: 'cites', label: 'Citations' },
116
+ ],
117
+ rows: [
118
+ { name: 'MSP', cites: '[1] ; [2]' },
119
+ { name: 'Other', cites: '[1]' },
120
+ ],
121
+ ...params,
122
+ } as TableComponentParams,
123
+ }
124
+ }
125
+
126
+ it('NO citationMap → cells render plain text (regression)', () => {
127
+ const { container } = render(() => (
128
+ <UIResourceRenderer content={tableComponent({})} />
129
+ ))
130
+ const buttons = container.querySelectorAll('[data-citation-page]')
131
+ expect(buttons.length).toBe(0)
132
+ expect(container.textContent).toContain('[1] ; [2]')
133
+ })
134
+
135
+ it('with citationMap → DOM has clickable chips per resolved marker', () => {
136
+ const { container } = render(() => (
137
+ <UIResourceRenderer content={tableComponent({ citationMap: baseMap })} />
138
+ ))
139
+ const buttons = container.querySelectorAll('button[data-citation-page]')
140
+ // Row 1 has 2 markers, row 2 has 1 → 3 chips total
141
+ expect(buttons.length).toBe(3)
142
+ const pages = Array.from(buttons).map((b) => b.getAttribute('data-citation-page'))
143
+ expect(pages.sort()).toEqual(['12', '5', '5'])
144
+ })
145
+
146
+ it('with citationRender override → custom chips replace defaults', () => {
147
+ const customRender = (id: number) => `<a class="my-chip" data-id="${id}">x</a>`
148
+ const { container } = render(() => (
149
+ <UIResourceRenderer
150
+ content={tableComponent({ citationMap: baseMap, citationRender: customRender })}
151
+ />
152
+ ))
153
+ const customs = container.querySelectorAll('a.my-chip')
154
+ expect(customs.length).toBe(3)
155
+ expect(container.querySelector('[data-citation-page]')).toBeNull()
156
+ })
157
+ })