@mcp-html-bridge/ui-engine 0.2.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcp-html-bridge/ui-engine",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Core UI rendering engine — schema/data → self-contained HTML",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -1,26 +1,48 @@
1
- // ── Engine Orchestrator: main entry point ──
1
+ // ── Engine Orchestrator: JSON self-contained HTML document ──
2
2
  import type { EngineInput, RenderOptions, JSONSchema } from './types.js';
3
3
  import { document as htmlDocument } from './html-builder.js';
4
4
  import { generateThemeCSS } from './theme.js';
5
5
  import { generateBridgeJS } from './bridge.js';
6
- import { sniff, sniffSchema } from './data-sniffer.js';
6
+ import { renderJSON, getRendererCSS, getRendererJS } from './renderer.js';
7
7
  import { renderForm, getFormCSS } from './renderers/form.js';
8
- import { renderDataGrid, getDataGridCSS, getDataGridJS } from './renderers/data-grid.js';
9
- import { renderJsonTree, getJsonTreeCSS, getJsonTreeJS } from './renderers/json-tree.js';
10
- import { renderReadingBlock, getReadingBlockCSS } from './renderers/reading-block.js';
11
- import { renderMetricsCard, getMetricsCardCSS } from './renderers/metrics-card.js';
12
- import { renderComposite, getCompositeCSS } from './renderers/composite.js';
13
8
  import { generatePlaygroundHTML, getPlaygroundCSS, getPlaygroundJS } from './playground.js';
14
9
 
10
+ /** Options for rendering */
11
+ interface DataRenderOptions extends RenderOptions {
12
+ toolName?: string;
13
+ toolDescription?: string;
14
+ }
15
+
16
+ /** Render any JSON data as a full HTML document. Pure structural rendering. */
17
+ export function renderFromData(
18
+ data: unknown,
19
+ options: DataRenderOptions = {}
20
+ ): string {
21
+ const body = renderJSON(data);
22
+ const cssParts = [generateThemeCSS(), getRendererCSS()];
23
+ const jsParts = [generateBridgeJS(), getRendererJS()];
24
+
25
+ if (options.debug) {
26
+ cssParts.push(getPlaygroundCSS());
27
+ jsParts.push(getPlaygroundJS());
28
+ }
29
+
30
+ const playgroundHTML = options.debug ? generatePlaygroundHTML() : '';
31
+
32
+ return htmlDocument({
33
+ title: options.title ?? options.toolName ?? 'MCP Result',
34
+ css: cssParts.join('\n'),
35
+ body: body + playgroundHTML,
36
+ js: jsParts.join('\n'),
37
+ });
38
+ }
39
+
15
40
  /** Render a form from a JSON Schema (for tool input) */
16
41
  export function renderFromSchema(
17
42
  schema: JSONSchema,
18
- options: RenderOptions & { toolName?: string; toolDescription?: string } = {}
43
+ options: DataRenderOptions = {}
19
44
  ): string {
20
- const sniffResult = sniffSchema(schema as Record<string, unknown>);
21
-
22
45
  const body = renderForm(schema, {
23
- ...sniffResult.metadata,
24
46
  toolName: options.toolName,
25
47
  toolDescription: options.toolDescription,
26
48
  });
@@ -46,68 +68,8 @@ export function renderFromSchema(
46
68
  });
47
69
  }
48
70
 
49
- /** Render HTML from tool result data */
50
- export function renderFromData(
51
- data: unknown,
52
- options: RenderOptions & { toolName?: string; toolDescription?: string } = {}
53
- ): string {
54
- const results = sniff(data);
55
- const best = results[0];
56
-
57
- let body: string;
58
- const cssParts = [generateThemeCSS()];
59
- const jsParts = [generateBridgeJS()];
60
-
61
- switch (best.intent) {
62
- case 'data-grid':
63
- body = renderDataGrid(data, best.metadata);
64
- cssParts.push(getDataGridCSS());
65
- jsParts.push(getDataGridJS());
66
- break;
67
- case 'metrics-card':
68
- body = renderMetricsCard(data, best.metadata);
69
- cssParts.push(getMetricsCardCSS());
70
- break;
71
- case 'reading-block':
72
- body = renderReadingBlock(data, best.metadata);
73
- cssParts.push(getReadingBlockCSS());
74
- break;
75
- case 'json-tree':
76
- body = renderJsonTree(data, best.metadata);
77
- cssParts.push(getJsonTreeCSS());
78
- jsParts.push(getJsonTreeJS());
79
- // Inject tree data for copy-all
80
- jsParts.push(`__mcpTreeData = ${JSON.stringify(data)};`);
81
- break;
82
- case 'composite':
83
- body = renderComposite(data, best.metadata);
84
- // Composite may use all renderers, include all CSS/JS
85
- cssParts.push(getDataGridCSS(), getMetricsCardCSS(), getReadingBlockCSS(), getJsonTreeCSS(), getCompositeCSS());
86
- jsParts.push(getDataGridJS(), getJsonTreeJS());
87
- break;
88
- default:
89
- body = renderJsonTree(data, best.metadata);
90
- cssParts.push(getJsonTreeCSS());
91
- jsParts.push(getJsonTreeJS());
92
- jsParts.push(`__mcpTreeData = ${JSON.stringify(data)};`);
93
- }
94
-
95
- if (options.debug) {
96
- body += generatePlaygroundHTML();
97
- cssParts.push(getPlaygroundCSS());
98
- jsParts.push(getPlaygroundJS());
99
- }
100
-
101
- return htmlDocument({
102
- title: options.title ?? options.toolName ?? 'MCP Result',
103
- css: cssParts.join('\n'),
104
- body,
105
- js: jsParts.join('\n'),
106
- });
107
- }
108
-
109
71
  /** Unified API */
110
- export function render(input: EngineInput, options: RenderOptions = {}): string {
72
+ export function render(input: EngineInput, options: DataRenderOptions = {}): string {
111
73
  if (input.mode === 'schema') {
112
74
  return renderFromSchema(input.schema, {
113
75
  ...options,
package/src/index.ts CHANGED
@@ -1,20 +1,28 @@
1
1
  // ── Public API ──
2
- export { render, renderFromSchema, renderFromData } from './engine.js';
3
- export { sniff, sniffSchema } from './data-sniffer.js';
2
+ export {
3
+ render,
4
+ renderFromSchema,
5
+ renderFromData,
6
+ } from './engine.js';
7
+
8
+ // Universal JSON → HTML renderer (the core)
9
+ export { renderJSON, getRendererCSS, getRendererJS } from './renderer.js';
10
+
11
+ // Building blocks (for custom composition)
4
12
  export { generateThemeCSS } from './theme.js';
5
13
  export { generateBridgeJS } from './bridge.js';
6
14
  export { escapeHtml, tag, style, script, document } from './html-builder.js';
7
15
 
16
+ // Form renderer (JSON Schema → form UI)
17
+ export { renderForm, getFormCSS } from './renderers/form.js';
18
+
8
19
  // Re-export types
9
20
  export type {
10
- RenderIntent,
11
- SniffResult,
12
21
  RenderOptions,
13
22
  EngineInput,
14
23
  SchemaInput,
15
24
  DataInput,
16
25
  JSONSchema,
17
- Renderer,
18
26
  MCPToolDefinition,
19
27
  MCPServerInfo,
20
28
  } from './types.js';
@@ -0,0 +1,266 @@
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
+ *
7
+ * Array<Object> → sortable <table>
8
+ * Object → <dl> key-value pairs (recursive for nesting)
9
+ * Array<scalar> → <ul> list
10
+ * string → text
11
+ * number → number
12
+ * boolean → true/false
13
+ * null → placeholder
14
+ *
15
+ * All formatting decisions are the caller's responsibility (LLM or user).
16
+ */
17
+ import { escapeHtml } from './html-builder.js';
18
+
19
+ // ── Structural detection ──
20
+
21
+ function isArrayOfObjects(data: unknown): data is Record<string, unknown>[] {
22
+ return Array.isArray(data) && data.length > 0 &&
23
+ data.every(item => item !== null && typeof item === 'object' && !Array.isArray(item));
24
+ }
25
+
26
+ function isFlatObject(data: unknown): data is Record<string, unknown> {
27
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) return false;
28
+ return Object.values(data as Record<string, unknown>).every(
29
+ v => v === null || typeof v !== 'object'
30
+ );
31
+ }
32
+
33
+ function humanizeKey(key: string): string {
34
+ return key
35
+ .replace(/([A-Z])/g, ' $1')
36
+ .replace(/[_-]/g, ' ')
37
+ .replace(/^\w/, c => c.toUpperCase())
38
+ .trim();
39
+ }
40
+
41
+ // ── Render primitives ──
42
+
43
+ function renderValue(value: unknown, depth: number): string {
44
+ if (value === null || value === undefined) {
45
+ return '<span class="mcp-null">—</span>';
46
+ }
47
+ if (typeof value === 'boolean') {
48
+ return `<span class="mcp-bool">${value}</span>`;
49
+ }
50
+ if (typeof value === 'number') {
51
+ return `<span class="mcp-num">${value}</span>`;
52
+ }
53
+ if (typeof value === 'string') {
54
+ if (value.length > 300) {
55
+ return `<div class="mcp-text">${escapeHtml(value)}</div>`;
56
+ }
57
+ return escapeHtml(value);
58
+ }
59
+ // Recurse into objects/arrays
60
+ return renderAny(value, depth + 1);
61
+ }
62
+
63
+ // ── Core recursive renderer ──
64
+
65
+ function renderTable(rows: Record<string, unknown>[]): string {
66
+ const columns = Object.keys(rows[0]);
67
+ const headerCells = columns
68
+ .map((col, i) =>
69
+ `<th onclick="__mcpSort(${i})" class="mcp-sortable">${escapeHtml(humanizeKey(col))}<span class="mcp-sort-icon">⇅</span></th>`)
70
+ .join('');
71
+
72
+ const bodyRows = rows
73
+ .map(row =>
74
+ `<tr>${columns.map(col => `<td>${renderValue(row[col], 0)}</td>`).join('')}</tr>`)
75
+ .join('\n');
76
+
77
+ return `<div class="mcp-table-wrap">
78
+ <div class="mcp-table-meta">${rows.length} rows × ${columns.length} columns</div>
79
+ <div class="mcp-table-scroll">
80
+ <table class="mcp-table" id="mcp-grid">
81
+ <thead><tr>${headerCells}</tr></thead>
82
+ <tbody>${bodyRows}</tbody>
83
+ </table>
84
+ </div>
85
+ </div>`;
86
+ }
87
+
88
+ function renderKeyValue(obj: Record<string, unknown>, depth: number): string {
89
+ const entries = Object.entries(obj);
90
+ const items = entries.map(([key, val]) => {
91
+ const rendered = renderValue(val, depth);
92
+ return `<div class="mcp-kv">
93
+ <dt class="mcp-key">${escapeHtml(humanizeKey(key))}</dt>
94
+ <dd class="mcp-val">${rendered}</dd>
95
+ </div>`;
96
+ }).join('\n');
97
+
98
+ return `<dl class="mcp-dl">${items}</dl>`;
99
+ }
100
+
101
+ function renderList(arr: unknown[], depth: number): string {
102
+ const items = arr.map(item =>
103
+ `<li>${renderValue(item, depth)}</li>`
104
+ ).join('\n');
105
+ return `<ul class="mcp-list">${items}</ul>`;
106
+ }
107
+
108
+ function renderCollapsible(label: string, content: string, open = true): string {
109
+ return `<details class="mcp-details" ${open ? 'open' : ''}>
110
+ <summary class="mcp-summary">${escapeHtml(label)}</summary>
111
+ <div class="mcp-details-body">${content}</div>
112
+ </details>`;
113
+ }
114
+
115
+ function renderAny(data: unknown, depth: number): string {
116
+ // Primitive
117
+ if (data === null || data === undefined || typeof data !== 'object') {
118
+ return renderValue(data, depth);
119
+ }
120
+
121
+ // Array of objects → table
122
+ if (isArrayOfObjects(data)) {
123
+ const content = renderTable(data);
124
+ return depth > 0 ? renderCollapsible(`Array (${data.length} items)`, content) : content;
125
+ }
126
+
127
+ // Generic array → list or collapsible
128
+ if (Array.isArray(data)) {
129
+ const content = renderList(data, depth);
130
+ return depth > 0 ? renderCollapsible(`Array (${data.length})`, content) : content;
131
+ }
132
+
133
+ // Flat object → key-value pairs
134
+ const obj = data as Record<string, unknown>;
135
+ if (isFlatObject(obj)) {
136
+ return renderKeyValue(obj, depth);
137
+ }
138
+
139
+ // Nested object → grouped sections
140
+ const entries = Object.entries(obj);
141
+ const sections = entries.map(([key, val]) => {
142
+ if (val !== null && typeof val === 'object') {
143
+ return renderCollapsible(humanizeKey(key), renderAny(val, depth + 1), depth < 2);
144
+ }
145
+ return `<div class="mcp-kv">
146
+ <dt class="mcp-key">${escapeHtml(humanizeKey(key))}</dt>
147
+ <dd class="mcp-val">${renderValue(val, depth)}</dd>
148
+ </div>`;
149
+ });
150
+
151
+ // If all entries are primitives at this level, use dl
152
+ const allPrimitive = entries.every(([, v]) => v === null || typeof v !== 'object');
153
+ if (allPrimitive) {
154
+ return renderKeyValue(obj, depth);
155
+ }
156
+
157
+ return `<div class="mcp-section">${sections.join('\n')}</div>`;
158
+ }
159
+
160
+ // ── Public API ──
161
+
162
+ /** Render any JSON data as an HTML fragment. No business logic, pure structure. */
163
+ export function renderJSON(data: unknown): string {
164
+ return `<div class="mcp-root">${renderAny(data, 0)}</div>`;
165
+ }
166
+
167
+ /** Get the CSS for the universal renderer */
168
+ export function getRendererCSS(): string {
169
+ return `
170
+ .mcp-root { max-width: 960px; margin: 0 auto; }
171
+
172
+ /* Table */
173
+ .mcp-table-wrap { overflow: hidden; }
174
+ .mcp-table-meta {
175
+ font-size: var(--text-xs); color: var(--text-tertiary);
176
+ padding-bottom: var(--sp-2); margin-bottom: var(--sp-2);
177
+ border-bottom: 1px solid var(--border);
178
+ }
179
+ .mcp-table-scroll { overflow-x: auto; }
180
+ .mcp-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
181
+ .mcp-table th {
182
+ text-align: left; padding: var(--sp-2) var(--sp-3);
183
+ font-weight: 600; color: var(--text-secondary); font-size: var(--text-xs);
184
+ text-transform: uppercase; letter-spacing: 0.05em;
185
+ border-bottom: 2px solid var(--border);
186
+ white-space: nowrap; user-select: none;
187
+ }
188
+ .mcp-sortable { cursor: pointer; }
189
+ .mcp-sortable:hover { color: var(--accent); }
190
+ .mcp-sort-icon { margin-left: 4px; opacity: 0.3; font-size: 10px; }
191
+ .mcp-table td {
192
+ padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
193
+ vertical-align: top;
194
+ }
195
+ .mcp-table tbody tr:hover { background: var(--accent-subtle); }
196
+
197
+ /* Key-Value */
198
+ .mcp-dl { display: grid; grid-template-columns: 1fr; gap: 0; }
199
+ .mcp-kv {
200
+ display: grid; grid-template-columns: minmax(120px, auto) 1fr;
201
+ gap: var(--sp-3); padding: var(--sp-2) 0;
202
+ border-bottom: 1px solid var(--border);
203
+ }
204
+ .mcp-kv:last-child { border-bottom: none; }
205
+ .mcp-key {
206
+ font-weight: 600; font-size: var(--text-sm); color: var(--text-secondary);
207
+ word-break: break-word;
208
+ }
209
+ .mcp-val { font-size: var(--text-sm); word-break: break-word; }
210
+
211
+ /* List */
212
+ .mcp-list {
213
+ list-style: none; padding: 0; margin: 0;
214
+ display: flex; flex-direction: column; gap: var(--sp-1);
215
+ }
216
+ .mcp-list li {
217
+ padding: var(--sp-1) var(--sp-2); font-size: var(--text-sm);
218
+ border-left: 2px solid var(--border); margin-left: var(--sp-2);
219
+ }
220
+
221
+ /* Collapsible sections */
222
+ .mcp-details {
223
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
224
+ margin-bottom: var(--sp-2);
225
+ }
226
+ .mcp-summary {
227
+ padding: var(--sp-2) var(--sp-3); font-weight: 600; font-size: var(--text-sm);
228
+ cursor: pointer; user-select: none;
229
+ }
230
+ .mcp-summary:hover { color: var(--accent); }
231
+ .mcp-details-body { padding: var(--sp-3); border-top: 1px solid var(--border); }
232
+
233
+ /* Sections */
234
+ .mcp-section { display: flex; flex-direction: column; gap: var(--sp-3); }
235
+
236
+ /* Primitives */
237
+ .mcp-null { color: var(--text-tertiary); font-style: italic; }
238
+ .mcp-bool { font-weight: 600; }
239
+ .mcp-num { font-variant-numeric: tabular-nums; }
240
+ .mcp-text { white-space: pre-wrap; line-height: 1.6; }
241
+ `;
242
+ }
243
+
244
+ /** Get the JS for table sorting (the only interactive behavior) */
245
+ export function getRendererJS(): string {
246
+ return `
247
+ function __mcpSort(colIdx) {
248
+ var table = document.getElementById('mcp-grid');
249
+ if (!table) return;
250
+ var tbody = table.tBodies[0];
251
+ var rows = Array.from(tbody.rows);
252
+ var dir = table.dataset.sortDir === 'asc' ? 'desc' : 'asc';
253
+ table.dataset.sortDir = dir;
254
+
255
+ rows.sort(function(a, b) {
256
+ var av = a.cells[colIdx].textContent.trim();
257
+ var bv = b.cells[colIdx].textContent.trim();
258
+ var an = parseFloat(av.replace(/[^\\d.-]/g, ''));
259
+ var bn = parseFloat(bv.replace(/[^\\d.-]/g, ''));
260
+ if (!isNaN(an) && !isNaN(bn)) return dir === 'asc' ? an - bn : bn - an;
261
+ return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
262
+ });
263
+
264
+ rows.forEach(function(row) { tbody.appendChild(row); });
265
+ }`;
266
+ }
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;
@@ -1,177 +0,0 @@
1
- // ── Data Sniffer: confidence-scored heuristic engine ──
2
- import type { SniffResult, RenderIntent } from './types.js';
3
-
4
- /** Measure maximum nesting depth of a value */
5
- function measureDepth(val: unknown, current = 0): number {
6
- if (current > 20) return current; // guard
7
- if (Array.isArray(val)) {
8
- return val.reduce<number>((max, item) => Math.max(max, measureDepth(item, current + 1)), current);
9
- }
10
- if (val !== null && typeof val === 'object') {
11
- return Object.values(val as Record<string, unknown>).reduce<number>(
12
- (max, v) => Math.max(max, measureDepth(v, current + 1)),
13
- current
14
- );
15
- }
16
- return current;
17
- }
18
-
19
- /** Check if an array of objects has consistent keys */
20
- function keyConsistency(arr: Record<string, unknown>[]): number {
21
- if (arr.length === 0) return 0;
22
- const firstKeys = new Set(Object.keys(arr[0]));
23
- let matchCount = 0;
24
- for (let i = 1; i < arr.length; i++) {
25
- const keys = Object.keys(arr[i]);
26
- const overlap = keys.filter((k) => firstKeys.has(k)).length;
27
- matchCount += overlap / Math.max(firstKeys.size, keys.length);
28
- }
29
- return matchCount / (arr.length - 1);
30
- }
31
-
32
- const READING_KEYS = new Set([
33
- 'description', 'content', 'body', 'text', 'message',
34
- 'summary', 'readme', 'notes', 'details', 'markdown',
35
- ]);
36
-
37
- /** Detect data-grid intent */
38
- function detectDataGrid(data: unknown): SniffResult | null {
39
- if (!Array.isArray(data) || data.length === 0) return null;
40
- const objects = data.filter(
41
- (item): item is Record<string, unknown> =>
42
- item !== null && typeof item === 'object' && !Array.isArray(item)
43
- );
44
- if (objects.length < 2) return null;
45
-
46
- const consistency = keyConsistency(objects);
47
- if (consistency < 0.5) return null;
48
-
49
- const confidence = Math.min(0.95, 0.5 + consistency * 0.3 + Math.min(objects.length / 20, 0.15));
50
- return {
51
- intent: 'data-grid',
52
- confidence,
53
- metadata: {
54
- rowCount: objects.length,
55
- columns: Object.keys(objects[0]),
56
- keyConsistency: consistency,
57
- },
58
- };
59
- }
60
-
61
- /** Detect metrics-card intent */
62
- function detectMetricsCard(data: unknown): SniffResult | null {
63
- if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
64
- const entries = Object.entries(data as Record<string, unknown>);
65
- if (entries.length === 0 || entries.length > 12) return null;
66
-
67
- const numericCount = entries.filter(([, v]) => typeof v === 'number').length;
68
- const ratio = numericCount / entries.length;
69
-
70
- if (numericCount < 1 || ratio < 0.3) return null;
71
-
72
- const confidence = Math.min(0.9, 0.4 + ratio * 0.4 + (entries.length <= 6 ? 0.1 : 0));
73
- return {
74
- intent: 'metrics-card',
75
- confidence,
76
- metadata: {
77
- numericKeys: entries.filter(([, v]) => typeof v === 'number').map(([k]) => k),
78
- totalKeys: entries.length,
79
- },
80
- };
81
- }
82
-
83
- /** Detect json-tree intent */
84
- function detectJsonTree(data: unknown): SniffResult | null {
85
- const depth = measureDepth(data);
86
- if (depth < 3) return null;
87
-
88
- const confidence = Math.min(0.85, 0.3 + (depth - 2) * 0.1);
89
- return {
90
- intent: 'json-tree',
91
- confidence,
92
- metadata: { depth },
93
- };
94
- }
95
-
96
- /** Detect reading-block intent */
97
- function detectReadingBlock(data: unknown): SniffResult | null {
98
- if (typeof data === 'string' && data.length > 200) {
99
- return {
100
- intent: 'reading-block',
101
- confidence: 0.85,
102
- metadata: { length: data.length },
103
- };
104
- }
105
- if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
106
- const entries = Object.entries(data as Record<string, unknown>);
107
- const longTextEntries = entries.filter(
108
- ([k, v]) =>
109
- (typeof v === 'string' && v.length > 200) || READING_KEYS.has(k.toLowerCase())
110
- );
111
- if (longTextEntries.length > 0) {
112
- const confidence = Math.min(0.8, 0.4 + longTextEntries.length * 0.15);
113
- return {
114
- intent: 'reading-block',
115
- confidence,
116
- metadata: { textKeys: longTextEntries.map(([k]) => k) },
117
- };
118
- }
119
- }
120
- return null;
121
- }
122
-
123
- /** Main sniff function: returns ranked intents */
124
- export function sniff(data: unknown): SniffResult[] {
125
- const results: SniffResult[] = [];
126
-
127
- const grid = detectDataGrid(data);
128
- if (grid) results.push(grid);
129
-
130
- const metrics = detectMetricsCard(data);
131
- if (metrics) results.push(metrics);
132
-
133
- const tree = detectJsonTree(data);
134
- if (tree) results.push(tree);
135
-
136
- const reading = detectReadingBlock(data);
137
- if (reading) results.push(reading);
138
-
139
- // If multiple intents detected with similar confidence, suggest composite
140
- if (results.length > 1) {
141
- const topConfidence = Math.max(...results.map((r) => r.confidence));
142
- const close = results.filter((r) => topConfidence - r.confidence < 0.2);
143
- if (close.length > 1) {
144
- results.push({
145
- intent: 'composite',
146
- confidence: topConfidence * 0.9,
147
- metadata: { subIntents: close.map((r) => r.intent) },
148
- });
149
- }
150
- }
151
-
152
- // Fallback: if nothing detected, default to json-tree
153
- if (results.length === 0) {
154
- results.push({
155
- intent: 'json-tree',
156
- confidence: 0.3,
157
- metadata: { fallback: true },
158
- });
159
- }
160
-
161
- return results.sort((a, b) => b.confidence - a.confidence);
162
- }
163
-
164
- /** Detect intent from JSON Schema (for form rendering) */
165
- export function sniffSchema(schema: Record<string, unknown>): SniffResult {
166
- const props = schema['properties'] as Record<string, unknown> | undefined;
167
- const propCount = props ? Object.keys(props).length : 0;
168
-
169
- return {
170
- intent: 'form',
171
- confidence: 0.95,
172
- metadata: {
173
- propertyCount: propCount,
174
- required: schema['required'] ?? [],
175
- },
176
- };
177
- }
@@ -1,73 +0,0 @@
1
- // ── Composite Renderer: orchestrates multiple renderers for mixed data ──
2
- import { escapeHtml } from '../html-builder.js';
3
- import { sniff } from '../data-sniffer.js';
4
- import { renderDataGrid } from './data-grid.js';
5
- import { renderJsonTree } from './json-tree.js';
6
- import { renderReadingBlock } from './reading-block.js';
7
- import { renderMetricsCard } from './metrics-card.js';
8
- import type { RenderIntent } from '../types.js';
9
-
10
- function humanizeKey(key: string): string {
11
- return key
12
- .replace(/([A-Z])/g, ' $1')
13
- .replace(/[_-]/g, ' ')
14
- .replace(/^\w/, (c) => c.toUpperCase())
15
- .trim();
16
- }
17
-
18
- function renderByIntent(intent: RenderIntent, data: unknown, metadata: Record<string, unknown>): string {
19
- switch (intent) {
20
- case 'data-grid':
21
- return renderDataGrid(data, metadata);
22
- case 'metrics-card':
23
- return renderMetricsCard(data, metadata);
24
- case 'reading-block':
25
- return renderReadingBlock(data, metadata);
26
- case 'json-tree':
27
- return renderJsonTree(data, metadata);
28
- default:
29
- return renderJsonTree(data, metadata);
30
- }
31
- }
32
-
33
- export function renderComposite(
34
- data: unknown,
35
- _metadata: Record<string, unknown>
36
- ): string {
37
- if (data === null || typeof data !== 'object' || Array.isArray(data)) {
38
- // For arrays or primitives, just sniff and render directly
39
- const results = sniff(data);
40
- const best = results[0];
41
- return renderByIntent(best.intent, data, best.metadata);
42
- }
43
-
44
- const obj = data as Record<string, unknown>;
45
- const sections: string[] = [];
46
-
47
- for (const [key, value] of Object.entries(obj)) {
48
- const results = sniff(value);
49
- const best = results[0];
50
- const label = humanizeKey(key);
51
-
52
- sections.push(`<section class="composite-section animate-in">
53
- <h3 class="section-title">${escapeHtml(label)}</h3>
54
- ${renderByIntent(best.intent, value, best.metadata)}
55
- </section>`);
56
- }
57
-
58
- return `<div class="composite-layout">${sections.join('\n')}</div>`;
59
- }
60
-
61
- export function getCompositeCSS(): string {
62
- return `
63
- .composite-layout {
64
- display: flex;
65
- flex-direction: column;
66
- gap: var(--sp-8);
67
- }
68
-
69
- .composite-section {
70
- /* Each section self-contained */
71
- }
72
- `;
73
- }
@@ -1,159 +0,0 @@
1
- // ── Data Grid Renderer: Array of objects → sortable table ──
2
- import { escapeHtml } from '../html-builder.js';
3
-
4
- const STATUS_MAP: Record<string, string> = {
5
- active: 'badge-success',
6
- enabled: 'badge-success',
7
- available: 'badge-success',
8
- in_stock: 'badge-success',
9
- completed: 'badge-success',
10
- inactive: 'badge-danger',
11
- disabled: 'badge-danger',
12
- out_of_stock: 'badge-danger',
13
- backorder: 'badge-warning',
14
- pending: 'badge-warning',
15
- processing: 'badge-warning',
16
- low_stock: 'badge-warning',
17
- info: 'badge-info',
18
- draft: 'badge-info',
19
- };
20
-
21
- function formatCell(value: unknown, key: string): string {
22
- if (value === null || value === undefined) return '<span class="null">—</span>';
23
-
24
- const strVal = String(value);
25
-
26
- // Status badge detection
27
- const normalized = strVal.toLowerCase().replace(/[\s-]+/g, '_');
28
- if (STATUS_MAP[normalized]) {
29
- return `<span class="badge ${STATUS_MAP[normalized]}">${escapeHtml(strVal)}</span>`;
30
- }
31
-
32
- // Price formatting detection
33
- if (typeof value === 'number' && /price|cost|amount|total|revenue/i.test(key)) {
34
- return `<span class="price">$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>`;
35
- }
36
-
37
- // Number formatting
38
- if (typeof value === 'number') {
39
- return `<span class="num">${value.toLocaleString()}</span>`;
40
- }
41
-
42
- // Boolean
43
- if (typeof value === 'boolean') {
44
- return value ? '<span class="bool-true">✓</span>' : '<span class="bool-false">✗</span>';
45
- }
46
-
47
- // Nested object/array → compact JSON
48
- if (typeof value === 'object') {
49
- return `<code class="cell-json">${escapeHtml(JSON.stringify(value))}</code>`;
50
- }
51
-
52
- return escapeHtml(strVal);
53
- }
54
-
55
- function humanizeKey(key: string): string {
56
- return key
57
- .replace(/([A-Z])/g, ' $1')
58
- .replace(/[_-]/g, ' ')
59
- .replace(/^\w/, (c) => c.toUpperCase())
60
- .trim();
61
- }
62
-
63
- export function renderDataGrid(
64
- data: unknown,
65
- metadata: Record<string, unknown>
66
- ): string {
67
- const rows = data as Record<string, unknown>[];
68
- const columns = (metadata['columns'] as string[]) ?? Object.keys(rows[0] ?? {});
69
-
70
- const headerCells = columns
71
- .map(
72
- (col, i) =>
73
- `<th onclick="__mcpSort(${i})" class="sortable">${escapeHtml(humanizeKey(col))}<span class="sort-icon">⇅</span></th>`
74
- )
75
- .join('');
76
-
77
- const bodyRows = rows
78
- .map(
79
- (row, ri) =>
80
- `<tr class="animate-in" style="animation-delay:${Math.min(ri * 20, 300)}ms">${columns.map((col) => `<td>${formatCell(row[col], col)}</td>`).join('')}</tr>`
81
- )
82
- .join('\n');
83
-
84
- const rowCount = rows.length;
85
- const colCount = columns.length;
86
-
87
- return `<div class="grid-container card">
88
- <div class="grid-header">
89
- <span class="grid-meta">${rowCount} rows × ${colCount} columns</span>
90
- </div>
91
- <div class="table-wrap">
92
- <table class="data-table" id="mcp-grid">
93
- <thead><tr>${headerCells}</tr></thead>
94
- <tbody>${bodyRows}</tbody>
95
- </table>
96
- </div>
97
- </div>`;
98
- }
99
-
100
- export function getDataGridCSS(): string {
101
- return `
102
- .grid-container { overflow: hidden; }
103
- .grid-header {
104
- display: flex; justify-content: space-between; align-items: center;
105
- padding-bottom: var(--sp-3); margin-bottom: var(--sp-3); border-bottom: 1px solid var(--border);
106
- }
107
- .grid-meta { font-size: var(--text-xs); color: var(--text-tertiary); }
108
- .table-wrap { overflow-x: auto; }
109
- .data-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
110
- .data-table th {
111
- text-align: left; padding: var(--sp-2) var(--sp-3);
112
- font-weight: 600; color: var(--text-secondary); font-size: var(--text-xs);
113
- text-transform: uppercase; letter-spacing: 0.05em;
114
- border-bottom: 2px solid var(--border);
115
- white-space: nowrap; user-select: none;
116
- }
117
- .sortable { cursor: pointer; }
118
- .sortable:hover { color: var(--accent); }
119
- .sort-icon { margin-left: 4px; opacity: 0.3; font-size: 10px; }
120
- .data-table td {
121
- padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
122
- vertical-align: middle;
123
- }
124
- .data-table tbody tr:hover { background: var(--accent-subtle); }
125
- .data-table tbody tr:nth-child(even) { background: var(--bg-secondary); }
126
- .data-table tbody tr:nth-child(even):hover { background: var(--accent-subtle); }
127
-
128
- .null { color: var(--text-tertiary); }
129
- .price { font-weight: 600; font-variant-numeric: tabular-nums; }
130
- .num { font-variant-numeric: tabular-nums; }
131
- .bool-true { color: var(--success); font-weight: 700; }
132
- .bool-false { color: var(--danger); font-weight: 700; }
133
- .cell-json { font-size: var(--text-xs); max-width: 200px; overflow: hidden; text-overflow: ellipsis; display: inline-block; }
134
- `;
135
- }
136
-
137
- export function getDataGridJS(): string {
138
- return `
139
- function __mcpSort(colIdx) {
140
- var table = document.getElementById('mcp-grid');
141
- if (!table) return;
142
- var tbody = table.tBodies[0];
143
- var rows = Array.from(tbody.rows);
144
- var dir = table.dataset.sortDir === 'asc' ? 'desc' : 'asc';
145
- table.dataset.sortDir = dir;
146
- table.dataset.sortCol = colIdx;
147
-
148
- rows.sort(function(a, b) {
149
- var av = a.cells[colIdx].textContent.trim();
150
- var bv = b.cells[colIdx].textContent.trim();
151
- var an = parseFloat(av.replace(/[^\\d.-]/g, ''));
152
- var bn = parseFloat(bv.replace(/[^\\d.-]/g, ''));
153
- if (!isNaN(an) && !isNaN(bn)) return dir === 'asc' ? an - bn : bn - an;
154
- return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
155
- });
156
-
157
- rows.forEach(function(row) { tbody.appendChild(row); });
158
- }`;
159
- }
@@ -1,141 +0,0 @@
1
- // ── JSON Tree Renderer: recursive collapsible tree ──
2
- import { escapeHtml } from '../html-builder.js';
3
-
4
- function typeClass(val: unknown): string {
5
- if (val === null) return 'jt-null';
6
- if (typeof val === 'string') return 'jt-string';
7
- if (typeof val === 'number') return 'jt-number';
8
- if (typeof val === 'boolean') return 'jt-boolean';
9
- return '';
10
- }
11
-
12
- function formatValue(val: unknown): string {
13
- if (val === null) return '<span class="jt-null">null</span>';
14
- if (typeof val === 'string') return `<span class="jt-string">"${escapeHtml(val)}"</span>`;
15
- if (typeof val === 'number') return `<span class="jt-number">${val}</span>`;
16
- if (typeof val === 'boolean') return `<span class="jt-boolean">${val}</span>`;
17
- return escapeHtml(String(val));
18
- }
19
-
20
- function renderNode(key: string | null, val: unknown, depth: number): string {
21
- const keyHtml = key !== null ? `<span class="jt-key">${escapeHtml(key)}</span><span class="jt-colon">: </span>` : '';
22
- const indent = depth * 16;
23
- const nodeId = `jt-${Math.random().toString(36).slice(2, 8)}`;
24
-
25
- if (val === null || typeof val !== 'object') {
26
- return `<div class="jt-line" style="padding-left:${indent}px">
27
- ${keyHtml}${formatValue(val)}
28
- <button class="jt-copy" onclick="__mcpCopy(this)" data-val="${escapeHtml(JSON.stringify(val))}" title="Copy">⎘</button>
29
- </div>`;
30
- }
31
-
32
- const isArray = Array.isArray(val);
33
- const entries = isArray
34
- ? (val as unknown[]).map((v, i) => [String(i), v] as const)
35
- : Object.entries(val as Record<string, unknown>);
36
-
37
- const bracket = isArray ? ['[', ']'] : ['{', '}'];
38
- const count = entries.length;
39
- const preview = count === 0 ? (isArray ? '[]' : '{}') : '';
40
-
41
- if (count === 0) {
42
- return `<div class="jt-line" style="padding-left:${indent}px">${keyHtml}<span class="jt-bracket">${preview}</span></div>`;
43
- }
44
-
45
- const children = entries.map(([k, v]) => renderNode(isArray ? null : k, v, depth + 1)).join('\n');
46
-
47
- return `<div class="jt-node" style="padding-left:${indent}px">
48
- <div class="jt-toggle" onclick="__mcpToggle('${nodeId}')">
49
- <span class="jt-arrow" id="${nodeId}-arrow">▼</span>
50
- ${keyHtml}<span class="jt-bracket">${bracket[0]}</span>
51
- <span class="jt-count">${count} ${isArray ? 'items' : 'keys'}</span>
52
- <button class="jt-copy" onclick="event.stopPropagation();__mcpCopy(this)" data-val="${escapeHtml(JSON.stringify(val))}" title="Copy">⎘</button>
53
- </div>
54
- <div class="jt-children" id="${nodeId}">${children}</div>
55
- <div style="padding-left:0"><span class="jt-bracket">${bracket[1]}</span></div>
56
- </div>`;
57
- }
58
-
59
- export function renderJsonTree(
60
- data: unknown,
61
- _metadata: Record<string, unknown>
62
- ): string {
63
- return `<div class="json-tree card">
64
- <div class="jt-toolbar">
65
- <button class="btn btn-ghost btn-sm" onclick="__mcpExpandAll()">Expand All</button>
66
- <button class="btn btn-ghost btn-sm" onclick="__mcpCollapseAll()">Collapse All</button>
67
- <button class="btn btn-ghost btn-sm" onclick="__mcpCopyAll()">Copy JSON</button>
68
- </div>
69
- <div class="jt-root">${renderNode(null, data, 0)}</div>
70
- </div>`;
71
- }
72
-
73
- export function getJsonTreeCSS(): string {
74
- return `
75
- .json-tree { font-family: var(--font-mono); font-size: var(--text-sm); overflow-x: auto; }
76
- .jt-toolbar { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-3); padding-bottom: var(--sp-3); border-bottom: 1px solid var(--border); }
77
- .btn-sm { padding: var(--sp-1) var(--sp-2); font-size: var(--text-xs); }
78
-
79
- .jt-line, .jt-node > .jt-toggle { padding: 2px 0; display: flex; align-items: center; gap: 4px; }
80
- .jt-toggle { cursor: pointer; user-select: none; }
81
- .jt-toggle:hover { background: var(--accent-subtle); border-radius: var(--radius-sm); }
82
- .jt-arrow { font-size: 10px; width: 14px; text-align: center; transition: transform var(--duration-fast) var(--ease-out); display: inline-block; }
83
- .jt-arrow.collapsed { transform: rotate(-90deg); }
84
-
85
- .jt-key { color: var(--accent); font-weight: 600; }
86
- .jt-colon { color: var(--text-tertiary); }
87
- .jt-string { color: #22863a; }
88
- .jt-number { color: #005cc5; }
89
- .jt-boolean { color: #d73a49; }
90
- .jt-null { color: var(--text-tertiary); font-style: italic; }
91
- .jt-bracket { color: var(--text-tertiary); font-weight: 700; }
92
- .jt-count { color: var(--text-tertiary); font-size: var(--text-xs); margin-left: var(--sp-1); }
93
-
94
- @media (prefers-color-scheme: dark) {
95
- .jt-string { color: #7ee787; }
96
- .jt-number { color: #79c0ff; }
97
- .jt-boolean { color: #ff7b72; }
98
- }
99
-
100
- .jt-children { overflow: hidden; }
101
- .jt-children.hidden { display: none; }
102
- .jt-copy {
103
- opacity: 0; border: none; background: none; cursor: pointer;
104
- color: var(--text-tertiary); font-size: 14px; padding: 0 4px;
105
- transition: opacity var(--duration-fast) var(--ease-out);
106
- }
107
- .jt-line:hover .jt-copy, .jt-toggle:hover .jt-copy { opacity: 1; }
108
- .jt-copy:hover { color: var(--accent); }
109
- `;
110
- }
111
-
112
- export function getJsonTreeJS(): string {
113
- return `
114
- var __mcpTreeData;
115
- function __mcpToggle(id) {
116
- var el = document.getElementById(id);
117
- var arrow = document.getElementById(id + '-arrow');
118
- if (!el) return;
119
- el.classList.toggle('hidden');
120
- if (arrow) arrow.classList.toggle('collapsed');
121
- }
122
- function __mcpExpandAll() {
123
- document.querySelectorAll('.jt-children').forEach(function(el) { el.classList.remove('hidden'); });
124
- document.querySelectorAll('.jt-arrow').forEach(function(el) { el.classList.remove('collapsed'); });
125
- }
126
- function __mcpCollapseAll() {
127
- document.querySelectorAll('.jt-children').forEach(function(el) { el.classList.add('hidden'); });
128
- document.querySelectorAll('.jt-arrow').forEach(function(el) { el.classList.add('collapsed'); });
129
- }
130
- function __mcpCopy(btn) {
131
- var val = btn.getAttribute('data-val');
132
- navigator.clipboard.writeText(val).then(function() {
133
- var orig = btn.textContent;
134
- btn.textContent = '✓';
135
- setTimeout(function() { btn.textContent = orig; }, 1000);
136
- });
137
- }
138
- function __mcpCopyAll() {
139
- if (__mcpTreeData) navigator.clipboard.writeText(JSON.stringify(__mcpTreeData, null, 2));
140
- }`;
141
- }
@@ -1,108 +0,0 @@
1
- // ── Metrics Card Renderer: KPI/summary → card layout ──
2
- import { escapeHtml } from '../html-builder.js';
3
-
4
- function formatMetricValue(value: number, key: string): string {
5
- // Percentage
6
- if (/rate|ratio|percent|pct/i.test(key)) {
7
- return `${(value * (value <= 1 ? 100 : 1)).toFixed(1)}%`;
8
- }
9
- // Currency
10
- if (/price|cost|revenue|amount|total|value/i.test(key)) {
11
- if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
12
- if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`;
13
- return `$${value.toFixed(2)}`;
14
- }
15
- // Large numbers
16
- if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
17
- if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
18
- // Scientific notation for very small/large
19
- if (Math.abs(value) < 0.001 && value !== 0) return value.toExponential(2);
20
-
21
- return Number.isInteger(value) ? value.toLocaleString() : value.toFixed(2);
22
- }
23
-
24
- function humanizeKey(key: string): string {
25
- return key
26
- .replace(/([A-Z])/g, ' $1')
27
- .replace(/[_-]/g, ' ')
28
- .replace(/^\w/, (c) => c.toUpperCase())
29
- .trim();
30
- }
31
-
32
- export function renderMetricsCard(
33
- data: unknown,
34
- _metadata: Record<string, unknown>
35
- ): string {
36
- if (data === null || typeof data !== 'object' || Array.isArray(data)) {
37
- return '<div class="card">Invalid metrics data</div>';
38
- }
39
-
40
- const obj = data as Record<string, unknown>;
41
- const cards: string[] = [];
42
-
43
- for (const [key, value] of Object.entries(obj)) {
44
- if (typeof value === 'number') {
45
- const formatted = formatMetricValue(value, key);
46
- const label = humanizeKey(key);
47
- cards.push(`<div class="metric-card animate-in">
48
- <div class="metric-label">${escapeHtml(label)}</div>
49
- <div class="metric-value">${escapeHtml(formatted)}</div>
50
- </div>`);
51
- } else if (typeof value === 'string') {
52
- cards.push(`<div class="metric-card metric-text animate-in">
53
- <div class="metric-label">${escapeHtml(humanizeKey(key))}</div>
54
- <div class="metric-text-value">${escapeHtml(value)}</div>
55
- </div>`);
56
- }
57
- }
58
-
59
- return `<div class="metrics-grid">${cards.join('\n')}</div>`;
60
- }
61
-
62
- export function getMetricsCardCSS(): string {
63
- return `
64
- .metrics-grid {
65
- display: grid;
66
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
67
- gap: var(--sp-4);
68
- }
69
-
70
- .metric-card {
71
- background: var(--bg-elevated);
72
- border: 1px solid var(--border);
73
- border-radius: var(--radius-md);
74
- padding: var(--sp-5);
75
- display: flex;
76
- flex-direction: column;
77
- gap: var(--sp-2);
78
- transition: box-shadow var(--duration-fast) var(--ease-out),
79
- transform var(--duration-fast) var(--ease-out);
80
- }
81
- .metric-card:hover {
82
- box-shadow: var(--shadow-md);
83
- transform: translateY(-2px);
84
- }
85
-
86
- .metric-label {
87
- font-size: var(--text-xs);
88
- font-weight: 600;
89
- text-transform: uppercase;
90
- letter-spacing: 0.05em;
91
- color: var(--text-tertiary);
92
- }
93
-
94
- .metric-value {
95
- font-size: var(--text-3xl);
96
- font-weight: 800;
97
- color: var(--text-primary);
98
- font-variant-numeric: tabular-nums;
99
- letter-spacing: -0.02em;
100
- }
101
-
102
- .metric-text-value {
103
- font-size: var(--text-base);
104
- font-weight: 500;
105
- color: var(--text-secondary);
106
- }
107
- `;
108
- }
@@ -1,114 +0,0 @@
1
- // ── Reading Block Renderer: long text/markdown → formatted block ──
2
- import { escapeHtml } from '../html-builder.js';
3
-
4
- /** Simple markdown-like formatting (no dependencies) */
5
- function formatText(text: string): string {
6
- let html = escapeHtml(text);
7
-
8
- // Headers
9
- html = html.replace(/^### (.+)$/gm, '<h3 class="rb-h3">$1</h3>');
10
- html = html.replace(/^## (.+)$/gm, '<h2 class="rb-h2">$1</h2>');
11
- html = html.replace(/^# (.+)$/gm, '<h1 class="rb-h1">$1</h1>');
12
-
13
- // Bold and italic
14
- html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
15
- html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
16
-
17
- // Inline code
18
- html = html.replace(/`([^`]+)`/g, '<code class="rb-code">$1</code>');
19
-
20
- // Code blocks
21
- html = html.replace(/```[\s\S]*?```/g, (match) => {
22
- const code = match.slice(3, -3).replace(/^\w*\n/, '');
23
- return `<pre class="rb-pre"><code>${code}</code></pre>`;
24
- });
25
-
26
- // Line breaks → paragraphs
27
- html = html
28
- .split(/\n\n+/)
29
- .map((p) => {
30
- const trimmed = p.trim();
31
- if (!trimmed) return '';
32
- if (trimmed.startsWith('<h') || trimmed.startsWith('<pre')) return trimmed;
33
- return `<p>${trimmed.replace(/\n/g, '<br>')}</p>`;
34
- })
35
- .filter(Boolean)
36
- .join('\n');
37
-
38
- return html;
39
- }
40
-
41
- export function renderReadingBlock(
42
- data: unknown,
43
- metadata: Record<string, unknown>
44
- ): string {
45
- const textKeys = metadata['textKeys'] as string[] | undefined;
46
-
47
- // Direct string
48
- if (typeof data === 'string') {
49
- return `<article class="reading-block card animate-in">${formatText(data)}</article>`;
50
- }
51
-
52
- // Object with text keys
53
- if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
54
- const obj = data as Record<string, unknown>;
55
- const sections: string[] = [];
56
-
57
- for (const [key, value] of Object.entries(obj)) {
58
- if (typeof value === 'string' && (value.length > 100 || (textKeys && textKeys.includes(key)))) {
59
- const label = key.replace(/([A-Z])/g, ' $1').replace(/[_-]/g, ' ').replace(/^\w/, (c) => c.toUpperCase()).trim();
60
- sections.push(`<section class="rb-section">
61
- <h3 class="rb-section-title">${escapeHtml(label)}</h3>
62
- <div class="rb-content">${formatText(value)}</div>
63
- </section>`);
64
- } else {
65
- // Non-text fields → small metadata row
66
- sections.push(`<div class="rb-meta-row">
67
- <span class="rb-meta-key">${escapeHtml(key)}</span>
68
- <span class="rb-meta-val">${escapeHtml(String(value))}</span>
69
- </div>`);
70
- }
71
- }
72
-
73
- return `<article class="reading-block card animate-in">${sections.join('\n')}</article>`;
74
- }
75
-
76
- // Fallback
77
- return `<article class="reading-block card animate-in"><pre>${escapeHtml(JSON.stringify(data, null, 2))}</pre></article>`;
78
- }
79
-
80
- export function getReadingBlockCSS(): string {
81
- return `
82
- .reading-block { max-width: 720px; line-height: 1.75; }
83
- .reading-block p { margin-bottom: var(--sp-4); color: var(--text-primary); }
84
- .reading-block strong { font-weight: 700; }
85
-
86
- .rb-h1 { font-size: var(--text-2xl); font-weight: 800; margin: var(--sp-6) 0 var(--sp-3); }
87
- .rb-h2 { font-size: var(--text-xl); font-weight: 700; margin: var(--sp-5) 0 var(--sp-2); }
88
- .rb-h3 { font-size: var(--text-lg); font-weight: 600; margin: var(--sp-4) 0 var(--sp-2); }
89
-
90
- .rb-code {
91
- background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;
92
- font-family: var(--font-mono); font-size: 0.9em;
93
- }
94
- .rb-pre {
95
- background: var(--bg-tertiary); padding: var(--sp-4); border-radius: var(--radius-sm);
96
- overflow-x: auto; font-family: var(--font-mono); font-size: var(--text-sm);
97
- margin: var(--sp-4) 0;
98
- }
99
-
100
- .rb-section { margin-bottom: var(--sp-6); }
101
- .rb-section-title {
102
- font-size: var(--text-base); font-weight: 700; color: var(--accent);
103
- margin-bottom: var(--sp-2); padding-bottom: var(--sp-1); border-bottom: 1px solid var(--border);
104
- }
105
- .rb-content { color: var(--text-primary); }
106
-
107
- .rb-meta-row {
108
- display: flex; gap: var(--sp-3); padding: var(--sp-1) 0;
109
- font-size: var(--text-sm); border-bottom: 1px solid var(--border);
110
- }
111
- .rb-meta-key { font-weight: 600; color: var(--text-secondary); min-width: 120px; }
112
- .rb-meta-val { color: var(--text-primary); }
113
- `;
114
- }