@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 +1 -1
- package/src/engine.ts +34 -72
- package/src/index.ts +13 -5
- package/src/renderer.ts +266 -0
- package/src/types.ts +0 -23
- package/src/data-sniffer.ts +0 -177
- package/src/renderers/composite.ts +0 -73
- package/src/renderers/data-grid.ts +0 -159
- package/src/renderers/json-tree.ts +0 -141
- package/src/renderers/metrics-card.ts +0 -108
- package/src/renderers/reading-block.ts +0 -114
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -1,26 +1,48 @@
|
|
|
1
|
-
// ── Engine Orchestrator:
|
|
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 {
|
|
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:
|
|
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:
|
|
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 {
|
|
3
|
-
|
|
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';
|
package/src/renderer.ts
ADDED
|
@@ -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;
|
package/src/data-sniffer.ts
DELETED
|
@@ -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
|
-
}
|