@mcp-html-bridge/ui-engine 0.3.0 → 0.5.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.
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Universal JSON → HTML renderer.
3
+ *
4
+ * No hardcoded business logic. No status badges, no price formatting,
5
+ * no regex-based field type guessing. Just clean structural rendering
6
+ * that handles any JSON shape gracefully:
7
+ *
8
+ * Array<Object> → sortable <table> (union of all keys)
9
+ * Object → <dl> key-value pairs (recursive for nesting)
10
+ * Array<mixed> → <ul> list with recursive items
11
+ * string → text (auto-links URLs)
12
+ * number → number
13
+ * boolean → true/false
14
+ * null → placeholder
15
+ * empty → explicit empty indicator
16
+ *
17
+ * All formatting decisions are the caller's responsibility (LLM or user).
18
+ */
19
+ import { escapeHtml } from './html-builder.js';
20
+
21
+ // ── Constants ──
22
+
23
+ const MAX_DEPTH = 30;
24
+ const MAX_TABLE_ROWS = 500;
25
+ const MAX_LIST_ITEMS = 200;
26
+ const URL_REGEX = /^https?:\/\/[^\s<>"{}|\\^`[\]]+$/;
27
+ const IMAGE_EXT_REGEX = /\.(png|jpe?g|gif|webp|svg|ico|bmp)(\?[^\s]*)?$/i;
28
+
29
+ // ── Structural detection ──
30
+
31
+ function isArrayOfObjects(data: unknown): data is Record<string, unknown>[] {
32
+ if (!Array.isArray(data) || data.length === 0) return false;
33
+ // At least 80% must be non-null objects (tolerates a few nulls in the array)
34
+ let objCount = 0;
35
+ for (const item of data) {
36
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
37
+ objCount++;
38
+ }
39
+ }
40
+ return objCount / data.length >= 0.8 && objCount >= 1;
41
+ }
42
+
43
+ function isFlatObject(data: unknown): data is Record<string, unknown> {
44
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) return false;
45
+ const values = Object.values(data as Record<string, unknown>);
46
+ if (values.length === 0) return true;
47
+ return values.every(v => v === null || typeof v !== 'object');
48
+ }
49
+
50
+ function humanizeKey(key: string): string {
51
+ return key
52
+ .replace(/([A-Z])/g, ' $1')
53
+ .replace(/[_-]/g, ' ')
54
+ .replace(/^\w/, c => c.toUpperCase())
55
+ .trim();
56
+ }
57
+
58
+ function isUrl(value: string): boolean {
59
+ return URL_REGEX.test(value);
60
+ }
61
+
62
+ function isImageUrl(value: string): boolean {
63
+ return IMAGE_EXT_REGEX.test(value);
64
+ }
65
+
66
+ // ── Render primitives ──
67
+
68
+ function renderValue(value: unknown, depth: number): string {
69
+ if (value === null || value === undefined) {
70
+ return '<span class="mcp-null">\u2014</span>';
71
+ }
72
+ if (typeof value === 'boolean') {
73
+ return `<span class="mcp-bool mcp-bool-${value}">${value}</span>`;
74
+ }
75
+ if (typeof value === 'number') {
76
+ return `<span class="mcp-num">${value}</span>`;
77
+ }
78
+ if (typeof value === 'string') {
79
+ if (value.length === 0) {
80
+ return '<span class="mcp-null">(empty)</span>';
81
+ }
82
+ // Auto-link URLs
83
+ if (isUrl(value)) {
84
+ const escaped = escapeHtml(value);
85
+ if (isImageUrl(value)) {
86
+ return `<a href="${escaped}" target="_blank" rel="noopener"><img class="mcp-img" src="${escaped}" alt="" loading="lazy"></a>`;
87
+ }
88
+ return `<a class="mcp-link" href="${escaped}" target="_blank" rel="noopener">${escaped}</a>`;
89
+ }
90
+ if (value.length > 300) {
91
+ return `<div class="mcp-text">${escapeHtml(value)}</div>`;
92
+ }
93
+ return escapeHtml(value);
94
+ }
95
+ // Recurse into objects/arrays
96
+ return renderAny(value, depth + 1);
97
+ }
98
+
99
+ // ── Core recursive renderer ──
100
+
101
+ /** Collect union of all keys across an array of objects, preserving order */
102
+ function collectColumns(rows: Record<string, unknown>[]): string[] {
103
+ const seen = new Set<string>();
104
+ const columns: string[] = [];
105
+ for (const row of rows) {
106
+ for (const key of Object.keys(row)) {
107
+ if (!seen.has(key)) {
108
+ seen.add(key);
109
+ columns.push(key);
110
+ }
111
+ }
112
+ }
113
+ return columns;
114
+ }
115
+
116
+ function renderTable(rows: Record<string, unknown>[], tableId: string): string {
117
+ const columns = collectColumns(rows);
118
+ const truncated = rows.length > MAX_TABLE_ROWS;
119
+ const displayRows = truncated ? rows.slice(0, MAX_TABLE_ROWS) : rows;
120
+
121
+ const headerCells = columns
122
+ .map((col, i) =>
123
+ `<th onclick="__mcpSort('${escapeHtml(tableId)}',${i})" class="mcp-sortable">${escapeHtml(humanizeKey(col))}<span class="mcp-sort-icon">\u21C5</span></th>`)
124
+ .join('');
125
+
126
+ const bodyRows = displayRows
127
+ .map(row => {
128
+ const cells = columns.map(col => {
129
+ const val = Object.prototype.hasOwnProperty.call(row, col) ? row[col] : undefined;
130
+ return `<td>${renderValue(val, 1)}</td>`;
131
+ }).join('');
132
+ return `<tr>${cells}</tr>`;
133
+ })
134
+ .join('\n');
135
+
136
+ const meta = truncated
137
+ ? `${rows.length} rows \u00D7 ${columns.length} columns (showing first ${MAX_TABLE_ROWS})`
138
+ : `${rows.length} rows \u00D7 ${columns.length} columns`;
139
+
140
+ return `<div class="mcp-table-wrap">
141
+ <div class="mcp-table-meta">${meta}</div>
142
+ <div class="mcp-table-scroll">
143
+ <table class="mcp-table" id="${escapeHtml(tableId)}">
144
+ <thead><tr>${headerCells}</tr></thead>
145
+ <tbody>${bodyRows}</tbody>
146
+ </table>
147
+ </div>
148
+ </div>`;
149
+ }
150
+
151
+ function renderKeyValue(obj: Record<string, unknown>, depth: number): string {
152
+ const entries = Object.entries(obj);
153
+ if (entries.length === 0) {
154
+ return '<div class="mcp-empty">(empty object)</div>';
155
+ }
156
+ const items = entries.map(([key, val]) => {
157
+ const rendered = renderValue(val, depth);
158
+ return `<div class="mcp-kv">
159
+ <dt class="mcp-key">${escapeHtml(humanizeKey(key))}</dt>
160
+ <dd class="mcp-val">${rendered}</dd>
161
+ </div>`;
162
+ }).join('\n');
163
+
164
+ return `<dl class="mcp-dl">${items}</dl>`;
165
+ }
166
+
167
+ function renderList(arr: unknown[], depth: number): string {
168
+ if (arr.length === 0) {
169
+ return '<div class="mcp-empty">(empty array)</div>';
170
+ }
171
+ const truncated = arr.length > MAX_LIST_ITEMS;
172
+ const displayItems = truncated ? arr.slice(0, MAX_LIST_ITEMS) : arr;
173
+
174
+ const items = displayItems.map(item =>
175
+ `<li>${renderValue(item, depth)}</li>`
176
+ ).join('\n');
177
+
178
+ const suffix = truncated
179
+ ? `<li class="mcp-truncated">\u2026 and ${arr.length - MAX_LIST_ITEMS} more items</li>`
180
+ : '';
181
+
182
+ return `<ul class="mcp-list">${items}\n${suffix}</ul>`;
183
+ }
184
+
185
+ function renderCollapsible(label: string, content: string, open = true): string {
186
+ return `<details class="mcp-details" ${open ? 'open' : ''}>
187
+ <summary class="mcp-summary">${escapeHtml(label)}</summary>
188
+ <div class="mcp-details-body">${content}</div>
189
+ </details>`;
190
+ }
191
+
192
+ // Global table counter for unique IDs
193
+ let tableCounter = 0;
194
+
195
+ function renderAny(data: unknown, depth: number): string {
196
+ // Depth guard
197
+ if (depth > MAX_DEPTH) {
198
+ return '<span class="mcp-null">(max depth reached)</span>';
199
+ }
200
+
201
+ // Primitive
202
+ if (data === null || data === undefined || typeof data !== 'object') {
203
+ return renderValue(data, depth);
204
+ }
205
+
206
+ // Array of objects → table
207
+ if (isArrayOfObjects(data)) {
208
+ // Filter to only objects for the table, skip non-objects
209
+ const objectRows = (data as unknown[]).filter(
210
+ (item): item is Record<string, unknown> =>
211
+ item !== null && typeof item === 'object' && !Array.isArray(item)
212
+ );
213
+ const tableId = `mcp-grid-${tableCounter++}`;
214
+ const content = renderTable(objectRows, tableId);
215
+ return depth > 0 ? renderCollapsible(`Array (${data.length} items)`, content) : content;
216
+ }
217
+
218
+ // Generic array → list or collapsible
219
+ if (Array.isArray(data)) {
220
+ const content = renderList(data, depth);
221
+ return depth > 0 ? renderCollapsible(`Array (${data.length})`, content) : content;
222
+ }
223
+
224
+ // Flat object → key-value pairs
225
+ const obj = data as Record<string, unknown>;
226
+ if (isFlatObject(obj)) {
227
+ return renderKeyValue(obj, depth);
228
+ }
229
+
230
+ // Nested object → grouped sections
231
+ const entries = Object.entries(obj);
232
+ if (entries.length === 0) {
233
+ return '<div class="mcp-empty">(empty object)</div>';
234
+ }
235
+
236
+ const sections = entries.map(([key, val]) => {
237
+ if (val !== null && typeof val === 'object') {
238
+ return renderCollapsible(humanizeKey(key), renderAny(val, depth + 1), depth < 2);
239
+ }
240
+ return `<div class="mcp-kv">
241
+ <dt class="mcp-key">${escapeHtml(humanizeKey(key))}</dt>
242
+ <dd class="mcp-val">${renderValue(val, depth)}</dd>
243
+ </div>`;
244
+ });
245
+
246
+ // If all entries are primitives at this level, use dl
247
+ const allPrimitive = entries.every(([, v]) => v === null || typeof v !== 'object');
248
+ if (allPrimitive) {
249
+ return renderKeyValue(obj, depth);
250
+ }
251
+
252
+ return `<div class="mcp-section">${sections.join('\n')}</div>`;
253
+ }
254
+
255
+ // ── Public API ──
256
+
257
+ /** Render any JSON data as an HTML fragment. No business logic, pure structure. */
258
+ export function renderJSON(data: unknown): string {
259
+ tableCounter = 0; // Reset per render call
260
+ return `<div class="mcp-root">${renderAny(data, 0)}</div>`;
261
+ }
262
+
263
+ /** Get the CSS for the universal renderer */
264
+ export function getRendererCSS(): string {
265
+ return `
266
+ .mcp-root { max-width: 960px; margin: 0 auto; }
267
+
268
+ /* Table */
269
+ .mcp-table-wrap { overflow: hidden; }
270
+ .mcp-table-meta {
271
+ font-size: var(--text-xs); color: var(--text-tertiary);
272
+ padding-bottom: var(--sp-2); margin-bottom: var(--sp-2);
273
+ border-bottom: 1px solid var(--border);
274
+ }
275
+ .mcp-table-scroll { overflow-x: auto; }
276
+ .mcp-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
277
+ .mcp-table th {
278
+ text-align: left; padding: var(--sp-2) var(--sp-3);
279
+ font-weight: 600; color: var(--text-secondary); font-size: var(--text-xs);
280
+ text-transform: uppercase; letter-spacing: 0.05em;
281
+ border-bottom: 2px solid var(--border);
282
+ white-space: nowrap; user-select: none;
283
+ position: sticky; top: 0; background: var(--bg-primary); z-index: 1;
284
+ }
285
+ .mcp-sortable { cursor: pointer; }
286
+ .mcp-sortable:hover { color: var(--accent); }
287
+ .mcp-sort-icon { margin-left: 4px; opacity: 0.3; font-size: 10px; }
288
+ .mcp-table td {
289
+ padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
290
+ vertical-align: top; max-width: 400px;
291
+ }
292
+ .mcp-table tbody tr:hover { background: var(--accent-subtle); }
293
+
294
+ /* Key-Value */
295
+ .mcp-dl { display: grid; grid-template-columns: 1fr; gap: 0; }
296
+ .mcp-kv {
297
+ display: grid; grid-template-columns: minmax(120px, auto) 1fr;
298
+ gap: var(--sp-3); padding: var(--sp-2) 0;
299
+ border-bottom: 1px solid var(--border);
300
+ }
301
+ .mcp-kv:last-child { border-bottom: none; }
302
+ .mcp-key {
303
+ font-weight: 600; font-size: var(--text-sm); color: var(--text-secondary);
304
+ word-break: break-word;
305
+ }
306
+ .mcp-val { font-size: var(--text-sm); word-break: break-word; }
307
+
308
+ /* List */
309
+ .mcp-list {
310
+ list-style: none; padding: 0; margin: 0;
311
+ display: flex; flex-direction: column; gap: var(--sp-1);
312
+ }
313
+ .mcp-list li {
314
+ padding: var(--sp-1) var(--sp-2); font-size: var(--text-sm);
315
+ border-left: 2px solid var(--border); margin-left: var(--sp-2);
316
+ }
317
+
318
+ /* Collapsible sections */
319
+ .mcp-details {
320
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
321
+ margin-bottom: var(--sp-2);
322
+ }
323
+ .mcp-summary {
324
+ padding: var(--sp-2) var(--sp-3); font-weight: 600; font-size: var(--text-sm);
325
+ cursor: pointer; user-select: none;
326
+ }
327
+ .mcp-summary:hover { color: var(--accent); }
328
+ .mcp-details-body { padding: var(--sp-3); border-top: 1px solid var(--border); }
329
+
330
+ /* Sections */
331
+ .mcp-section { display: flex; flex-direction: column; gap: var(--sp-3); }
332
+
333
+ /* Primitives */
334
+ .mcp-null { color: var(--text-tertiary); font-style: italic; }
335
+ .mcp-bool { font-weight: 600; }
336
+ .mcp-bool-true { color: var(--success); }
337
+ .mcp-bool-false { color: var(--danger); }
338
+ .mcp-num { font-variant-numeric: tabular-nums; }
339
+ .mcp-text { white-space: pre-wrap; line-height: 1.6; }
340
+ .mcp-empty { color: var(--text-tertiary); font-style: italic; padding: var(--sp-2) 0; }
341
+ .mcp-truncated { color: var(--text-tertiary); font-style: italic; }
342
+
343
+ /* Links & images */
344
+ .mcp-link { color: var(--accent); text-decoration: none; word-break: break-all; }
345
+ .mcp-link:hover { text-decoration: underline; }
346
+ .mcp-img { max-width: 200px; max-height: 150px; border-radius: var(--radius-sm); border: 1px solid var(--border); }
347
+
348
+ /* Nested table styling */
349
+ .mcp-details .mcp-table-wrap { margin: 0; }
350
+ .mcp-details .mcp-table th { position: static; }
351
+ `;
352
+ }
353
+
354
+ /** Get the JS for table sorting (supports multiple tables) */
355
+ export function getRendererJS(): string {
356
+ return `
357
+ function __mcpSort(tableId, colIdx) {
358
+ var table = document.getElementById(tableId);
359
+ if (!table) return;
360
+ var tbody = table.tBodies[0];
361
+ var rows = Array.from(tbody.rows);
362
+ var key = tableId + '_' + colIdx;
363
+ var dir = table.dataset.sortKey === key && table.dataset.sortDir === 'asc' ? 'desc' : 'asc';
364
+ table.dataset.sortDir = dir;
365
+ table.dataset.sortKey = key;
366
+
367
+ rows.sort(function(a, b) {
368
+ var ac = a.cells[colIdx], bc = b.cells[colIdx];
369
+ if (!ac || !bc) return 0;
370
+ var av = ac.textContent.trim();
371
+ var bv = bc.textContent.trim();
372
+ var an = parseFloat(av.replace(/[^\\d.-]/g, ''));
373
+ var bn = parseFloat(bv.replace(/[^\\d.-]/g, ''));
374
+ if (!isNaN(an) && !isNaN(bn)) return dir === 'asc' ? an - bn : bn - an;
375
+ return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
376
+ });
377
+
378
+ rows.forEach(function(row) { tbody.appendChild(row); });
379
+ }`;
380
+ }
package/src/types.ts CHANGED
@@ -1,19 +1,3 @@
1
- // ── Render Intent ──
2
- export type RenderIntent =
3
- | 'form'
4
- | 'data-grid'
5
- | 'json-tree'
6
- | 'reading-block'
7
- | 'metrics-card'
8
- | 'composite';
9
-
10
- // ── Sniffer Result ──
11
- export interface SniffResult {
12
- intent: RenderIntent;
13
- confidence: number;
14
- metadata: Record<string, unknown>;
15
- }
16
-
17
1
  // ── Render Options ──
18
2
  export interface RenderOptions {
19
3
  darkMode?: boolean;
@@ -61,13 +45,6 @@ export interface JSONSchema {
61
45
  additionalProperties?: boolean | JSONSchema;
62
46
  }
63
47
 
64
- // ── Renderer function signature ──
65
- export type Renderer = (
66
- data: unknown,
67
- metadata: Record<string, unknown>,
68
- themeCSS: string
69
- ) => string;
70
-
71
48
  // ── MCP Tool definition (from protocol) ──
72
49
  export interface MCPToolDefinition {
73
50
  name: string;