@seed-ship/mcp-ui-solid 6.22.0 → 6.23.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/CHANGELOG.md +94 -0
- package/README.md +102 -0
- package/dist/components/UIResourceRenderer.cjs +15 -5
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +2 -1
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +15 -5
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/context/MCPUIConfigContext.cjs.map +1 -1
- package/dist/context/MCPUIConfigContext.d.ts +57 -28
- package/dist/context/MCPUIConfigContext.d.ts.map +1 -1
- package/dist/context/MCPUIConfigContext.js.map +1 -1
- package/dist/context/MCPUIMathContext.cjs +19 -0
- package/dist/context/MCPUIMathContext.cjs.map +1 -0
- package/dist/context/MCPUIMathContext.d.ts +18 -0
- package/dist/context/MCPUIMathContext.d.ts.map +1 -0
- package/dist/context/MCPUIMathContext.js +19 -0
- package/dist/context/MCPUIMathContext.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/plugins/katex.cjs +23 -0
- package/dist/plugins/katex.cjs.map +1 -0
- package/dist/plugins/katex.d.cts +7 -0
- package/dist/plugins/katex.d.ts +7 -0
- package/dist/plugins/katex.d.ts.map +1 -0
- package/dist/plugins/katex.js +23 -0
- package/dist/plugins/katex.js.map +1 -0
- package/dist/utils/iframe-coep.cjs.map +1 -1
- package/dist/utils/iframe-coep.d.ts +7 -6
- package/dist/utils/iframe-coep.d.ts.map +1 -1
- package/dist/utils/iframe-coep.js.map +1 -1
- package/dist/utils/math-markdown.cjs +240 -0
- package/dist/utils/math-markdown.cjs.map +1 -0
- package/dist/utils/math-markdown.d.ts +16 -0
- package/dist/utils/math-markdown.d.ts.map +1 -0
- package/dist/utils/math-markdown.js +240 -0
- package/dist/utils/math-markdown.js.map +1 -0
- package/package.json +22 -3
- package/src/components/UIResourceRenderer.tsx +24 -3
- package/src/context/MCPUIConfigContext.tsx +57 -28
- package/src/context/MCPUIMathContext.tsx +32 -0
- package/src/index.ts +4 -0
- package/src/plugins/katex.ts +25 -0
- package/src/utils/iframe-coep.ts +7 -6
- package/src/utils/math-markdown.ts +222 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seed-ship/mcp-ui-solid",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.23.0",
|
|
4
4
|
"description": "SolidJS components for rendering MCP-generated UI resources",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -44,6 +44,9 @@
|
|
|
44
44
|
],
|
|
45
45
|
"plugins/duckdb": [
|
|
46
46
|
"./dist/plugins/duckdb.d.ts"
|
|
47
|
+
],
|
|
48
|
+
"plugins/katex": [
|
|
49
|
+
"./dist/plugins/katex.d.ts"
|
|
47
50
|
]
|
|
48
51
|
}
|
|
49
52
|
},
|
|
@@ -147,6 +150,17 @@
|
|
|
147
150
|
"default": "./dist/plugins/duckdb.cjs"
|
|
148
151
|
}
|
|
149
152
|
},
|
|
153
|
+
"./plugins/katex": {
|
|
154
|
+
"solid": "./src/plugins/katex.ts",
|
|
155
|
+
"import": {
|
|
156
|
+
"types": "./dist/plugins/katex.d.ts",
|
|
157
|
+
"default": "./dist/plugins/katex.js"
|
|
158
|
+
},
|
|
159
|
+
"require": {
|
|
160
|
+
"types": "./dist/plugins/katex.d.cts",
|
|
161
|
+
"default": "./dist/plugins/katex.cjs"
|
|
162
|
+
}
|
|
163
|
+
},
|
|
150
164
|
"./src/*": "./src/*"
|
|
151
165
|
},
|
|
152
166
|
"_size-limit-note": "size-limit is a regression guardrail, not a hard cap — it flags accidental bundle bloat. It measures the pre-built dist/index.js and sums the FULL graph reachable from each entry, including lazy import() chunks (leaflet, @antv/g6, chart.js) a consumer fetches on demand, NOT at import time — so the figures are worst-case totals, not eager load cost. Since v6.6.0 the 'Streaming renderer' entry delegates to UIResourceRenderer (Gap 1 parity), so its graph equals the full renderer set; its budget is set to 1 MB (generous headroom — the real eager cost is far lower). The 'Hooks only' and 'Full bundle' limits are pre-existing and unchanged by v6.6.0. size-limit is informational only — it does not gate CI.",
|
|
@@ -176,6 +190,7 @@
|
|
|
176
190
|
"chart.js": "^4.0.0",
|
|
177
191
|
"chartjs-plugin-zoom": "^2.0.0",
|
|
178
192
|
"highlight.js": "^11.9.0",
|
|
193
|
+
"katex": "0.18.4",
|
|
179
194
|
"leaflet": "^1.9.4",
|
|
180
195
|
"leaflet.markercluster": "^1.5.0",
|
|
181
196
|
"protomaps-leaflet": "^4.0.0",
|
|
@@ -197,6 +212,9 @@
|
|
|
197
212
|
"highlight.js": {
|
|
198
213
|
"optional": true
|
|
199
214
|
},
|
|
215
|
+
"katex": {
|
|
216
|
+
"optional": true
|
|
217
|
+
},
|
|
200
218
|
"leaflet": {
|
|
201
219
|
"optional": true
|
|
202
220
|
},
|
|
@@ -232,6 +250,7 @@
|
|
|
232
250
|
"chart.js": "^4.5.1",
|
|
233
251
|
"eslint": "^8.56.0",
|
|
234
252
|
"jsdom": "^27.2.0",
|
|
253
|
+
"katex": "0.18.4",
|
|
235
254
|
"leaflet.markercluster": "^1.5.3",
|
|
236
255
|
"size-limit": "^12.0.0",
|
|
237
256
|
"solid-js": "^1.9.11",
|
|
@@ -262,7 +281,7 @@
|
|
|
262
281
|
},
|
|
263
282
|
"scripts": {
|
|
264
283
|
"build": "vite build && pnpm build:types",
|
|
265
|
-
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist --composite false && pnpm build:types:copy",
|
|
284
|
+
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist --composite false && pnpm build:types:copy && cp dist/plugins/katex.d.ts dist/plugins/katex.d.cts",
|
|
266
285
|
"build:types:copy": "printf \"export * from './components/index';\\nexport { default } from './components/index';\\n\" > dist/components.d.ts && printf \"export * from './hooks/index';\\n\" > dist/hooks.d.ts && printf \"export * from './types/index';\\n\" > dist/types.d.ts && printf \"export * from './adapters/index';\\n\" > dist/adapters.d.ts && pnpm build:types:cts",
|
|
267
286
|
"build:types:cts": "cp dist/index.d.ts dist/index.d.cts && cp dist/components.d.ts dist/components.d.cts 2>/dev/null || true && cp dist/hooks.d.ts dist/hooks.d.cts 2>/dev/null || true && cp dist/types.d.ts dist/types.d.cts 2>/dev/null || true && cp dist/validation.d.ts dist/validation.d.cts 2>/dev/null || true && cp dist/types-export.d.ts dist/types-export.d.cts 2>/dev/null || true && cp dist/adapters.d.ts dist/adapters.d.cts 2>/dev/null || true && cp dist/adapters/presentation.d.ts dist/adapters/presentation.d.cts 2>/dev/null || true && cp dist/plugins/duckdb.d.ts dist/plugins/duckdb.d.cts 2>/dev/null || true",
|
|
268
287
|
"dev": "vite build --watch",
|
|
@@ -270,7 +289,7 @@
|
|
|
270
289
|
"test:watch": "vitest",
|
|
271
290
|
"test:browser": "vitest --mode browser",
|
|
272
291
|
"lint": "eslint src",
|
|
273
|
-
"typecheck": "tsc --noEmit",
|
|
292
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.contracts.json",
|
|
274
293
|
"clean": "rm -rf dist",
|
|
275
294
|
"size": "size-limit",
|
|
276
295
|
"size:analyze": "size-limit --why"
|
|
@@ -67,6 +67,8 @@ import { RenderProvider } from './RenderContext'
|
|
|
67
67
|
import { useAction } from '../hooks/useAction'
|
|
68
68
|
import { createResponsiveGrid } from '../hooks/createResponsiveGrid'
|
|
69
69
|
import { marked } from 'marked'
|
|
70
|
+
import { useMCPUIMath, type MathRenderer } from '../context/MCPUIMathContext'
|
|
71
|
+
import { parseMathMarkdown } from '../utils/math-markdown'
|
|
70
72
|
|
|
71
73
|
/**
|
|
72
74
|
* Copy button component with visual feedback
|
|
@@ -443,6 +445,9 @@ export function highlightQuery(html: string, query: string): string {
|
|
|
443
445
|
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_TEXT)
|
|
444
446
|
const textNodes: Text[] = []
|
|
445
447
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
448
|
+
// A HTML <mark> inside MathML can change or break its layout/semantics.
|
|
449
|
+
// Keep formulas intact; ordinary text around them remains searchable.
|
|
450
|
+
if (node.parentElement?.closest('math')) continue
|
|
446
451
|
textNodes.push(node as Text)
|
|
447
452
|
}
|
|
448
453
|
|
|
@@ -595,7 +600,7 @@ function transformCellCitations(text: string, ctx: CitationCtx): string {
|
|
|
595
600
|
)
|
|
596
601
|
}
|
|
597
602
|
|
|
598
|
-
export function renderCellValue(value: any, citationCtx?: CitationCtx): string {
|
|
603
|
+
export function renderCellValue(value: any, citationCtx?: CitationCtx, mathRenderer?: MathRenderer): string {
|
|
599
604
|
// Handle null/undefined
|
|
600
605
|
if (value === null || value === undefined) {
|
|
601
606
|
return '-'
|
|
@@ -646,6 +651,18 @@ export function renderCellValue(value: any, citationCtx?: CitationCtx): string {
|
|
|
646
651
|
return '-'
|
|
647
652
|
}
|
|
648
653
|
|
|
654
|
+
// Math must be tokenized before Markdown or citation rewrites can consume
|
|
655
|
+
// TeX underscores or bracket arguments (e.g. \sqrt[3]{x}). The opt-in path
|
|
656
|
+
// transforms citations only in ordinary text tokens, never math/code/URLs.
|
|
657
|
+
if (mathRenderer && strValue.includes('$')) {
|
|
658
|
+
return parseMathMarkdown(
|
|
659
|
+
strValue,
|
|
660
|
+
mathRenderer,
|
|
661
|
+
'cellMarkdown',
|
|
662
|
+
citationCtx ? (text) => transformCellCitations(text, citationCtx) : undefined,
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
649
666
|
// Detect and convert markdown links: [text](url) — runs FIRST because
|
|
650
667
|
// the citation transform's negative lookahead `(?!\()` would also skip
|
|
651
668
|
// these, but handling them here keeps the existing return path.
|
|
@@ -706,6 +723,7 @@ function TableRenderer(props: {
|
|
|
706
723
|
const tableParams = props.component.params as any
|
|
707
724
|
let scrollContainerRef: HTMLDivElement | undefined
|
|
708
725
|
const strings = useMCPUIStrings()
|
|
726
|
+
const mathRenderer = useMCPUIMath()
|
|
709
727
|
|
|
710
728
|
// v5.7.0 — opt-in citation chip rendering inside cells. When `citationMap`
|
|
711
729
|
// is present in params, build a CitationCtx once and thread it through
|
|
@@ -1006,7 +1024,7 @@ function TableRenderer(props: {
|
|
|
1006
1024
|
<For each={tableParams.columns}>
|
|
1007
1025
|
{(column: any) => (
|
|
1008
1026
|
<td class="px-6 py-4 text-sm text-gray-700 dark:text-gray-200 whitespace-normal break-words leading-relaxed first:pl-6 last:pr-6">
|
|
1009
|
-
<SafeHtml html={() => highlightQuery(renderCellValue(row[column.key], citationCtx), debouncedQuery())} />
|
|
1027
|
+
<SafeHtml html={() => highlightQuery(renderCellValue(row[column.key], citationCtx, mathRenderer()), debouncedQuery())} />
|
|
1010
1028
|
</td>
|
|
1011
1029
|
)}
|
|
1012
1030
|
</For>
|
|
@@ -1045,7 +1063,7 @@ function TableRenderer(props: {
|
|
|
1045
1063
|
<For each={tableParams.columns}>
|
|
1046
1064
|
{(column: any) => (
|
|
1047
1065
|
<td class="px-6 py-4 text-sm text-gray-700 dark:text-gray-200 whitespace-normal break-words leading-relaxed first:pl-6 last:pr-6">
|
|
1048
|
-
<SafeHtml html={() => highlightQuery(renderCellValue(row[column.key], citationCtx), debouncedQuery())} />
|
|
1066
|
+
<SafeHtml html={() => highlightQuery(renderCellValue(row[column.key], citationCtx, mathRenderer()), debouncedQuery())} />
|
|
1049
1067
|
</td>
|
|
1050
1068
|
)}
|
|
1051
1069
|
</For>
|
|
@@ -1362,6 +1380,7 @@ function extractImageFromMarkdown(
|
|
|
1362
1380
|
*/
|
|
1363
1381
|
function TextRenderer(props: { component: UIComponent }) {
|
|
1364
1382
|
const strings = useMCPUIStrings()
|
|
1383
|
+
const mathRenderer = useMCPUIMath()
|
|
1365
1384
|
const textParams = props.component.params as any
|
|
1366
1385
|
|
|
1367
1386
|
// Check if this is an image markdown that should be rendered as image
|
|
@@ -1399,6 +1418,8 @@ function TextRenderer(props: { component: UIComponent }) {
|
|
|
1399
1418
|
return escapeHtml(String(raw))
|
|
1400
1419
|
}
|
|
1401
1420
|
if (textParams.markdown && !imageData()) {
|
|
1421
|
+
const renderMath = mathRenderer()
|
|
1422
|
+
if (renderMath) return parseMathMarkdown(String(raw), renderMath, 'prose')
|
|
1402
1423
|
return sanitizeHtml(marked.parse(raw, { async: false }) as string, SANITIZE_PROFILES.prose)
|
|
1403
1424
|
}
|
|
1404
1425
|
return sanitizeHtml(String(raw), SANITIZE_PROFILES.prose)
|
|
@@ -43,24 +43,34 @@ import type { IframePolicy } from '../types'
|
|
|
43
43
|
* Host-level rendering policy. Every field has a default — a provider passes
|
|
44
44
|
* a partial override.
|
|
45
45
|
*
|
|
46
|
-
* ##
|
|
47
|
-
*
|
|
48
|
-
* This
|
|
49
|
-
*
|
|
50
|
-
* break
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* `
|
|
61
|
-
*
|
|
46
|
+
* ## Two names, because there are two jobs
|
|
47
|
+
*
|
|
48
|
+
* This type grows a key every time the library gains a policy switch, and
|
|
49
|
+
* those releases are MINOR — so neither AUTHORING a config nor READING one
|
|
50
|
+
* may break when a key appears. Those two demands pull in opposite
|
|
51
|
+
* directions, and one name cannot serve both:
|
|
52
|
+
*
|
|
53
|
+
* - an author must be allowed to omit the new key, so the fields they write
|
|
54
|
+
* have to be OPTIONAL;
|
|
55
|
+
* - a reader assigning `const p: IframePolicy = config.iframePolicy` must not
|
|
56
|
+
* suddenly face `undefined`, so the fields they read have to be REQUIRED.
|
|
57
|
+
*
|
|
58
|
+
* Hence {@link MCPUIConfigInput} (what a host writes — every field optional)
|
|
59
|
+
* and {@link MCPUIConfig} (what the library hands back — every field
|
|
60
|
+
* resolved). `MCPUIConfig` is derived from the input type, so the two can
|
|
61
|
+
* never drift and a key is declared exactly once.
|
|
62
|
+
*
|
|
63
|
+
* Which one you name depends on the surface:
|
|
64
|
+
*
|
|
65
|
+
* - `<MCPUIConfigProvider config={…}>` takes `MCPUIConfigInput` — name that
|
|
66
|
+
* type for a partial you build, and it merges the rest;
|
|
67
|
+
* - `<MCPUIConfigContext.Provider value={…}>` takes the RESOLVED
|
|
68
|
+
* `MCPUIConfig`, because that context is read as well as written, so a
|
|
69
|
+
* direct provider hands over `{ ...DEFAULT_MCPUI_CONFIG, ...mine }`;
|
|
70
|
+
* - anything you RECEIVE is `MCPUIConfig`, which is what
|
|
71
|
+
* {@link useMCPUIConfig} returns.
|
|
62
72
|
*/
|
|
63
|
-
export interface
|
|
73
|
+
export interface MCPUIConfigInput {
|
|
64
74
|
/**
|
|
65
75
|
* When the library puts the boolean `credentialless` attribute on an
|
|
66
76
|
* `<iframe>` it renders.
|
|
@@ -87,14 +97,14 @@ export interface MCPUIConfig {
|
|
|
87
97
|
*
|
|
88
98
|
* This only RECLASSIFIES a host the allow-list already accepts. To render an
|
|
89
99
|
* iframe whose host is outside `DEFAULT_IFRAME_DOMAINS`, add it to
|
|
90
|
-
* {@link
|
|
100
|
+
* {@link MCPUIConfigInput.customIframeDomains} with `iframePolicy: 'extend'`.
|
|
91
101
|
*/
|
|
92
102
|
customTrustedIframeDomains?: string[]
|
|
93
103
|
|
|
94
104
|
/**
|
|
95
105
|
* How `UIResourceRenderer` validates an `iframe` component's host:
|
|
96
106
|
* `'strict'` (default) accepts `DEFAULT_IFRAME_DOMAINS` only, `'extend'`
|
|
97
|
-
* also accepts {@link
|
|
107
|
+
* also accepts {@link MCPUIConfigInput.customIframeDomains}.
|
|
98
108
|
*/
|
|
99
109
|
iframePolicy?: IframePolicy
|
|
100
110
|
|
|
@@ -141,17 +151,27 @@ export interface MCPUIConfig {
|
|
|
141
151
|
chartZoom?: 'expanded' | 'always' | 'never'
|
|
142
152
|
}
|
|
143
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The resolved host policy: every key present. This is what the library hands
|
|
156
|
+
* back, so a reader never null-checks a policy.
|
|
157
|
+
*
|
|
158
|
+
* Extends `Required<`{@link MCPUIConfigInput}`>` rather than declaring the
|
|
159
|
+
* fields again, so a key exists in exactly one place and the two views cannot
|
|
160
|
+
* drift. It remains an interface so existing consumers can extend or augment
|
|
161
|
+
* the public contract as they could before 6.22.0.
|
|
162
|
+
*/
|
|
163
|
+
export interface MCPUIConfig extends Required<MCPUIConfigInput> {}
|
|
164
|
+
|
|
144
165
|
/**
|
|
145
166
|
* Defaults. Chosen so a host that sends no COEP header sees no change from
|
|
146
167
|
* 6.20.0 apart from the fallback link, which `'auto'` keeps hidden there —
|
|
147
168
|
* and, since 6.22.0, so no inline component ever captures the wheel
|
|
148
169
|
* (`chartZoom: 'expanded'`).
|
|
149
170
|
*
|
|
150
|
-
* Typed `
|
|
151
|
-
*
|
|
152
|
-
* new key from shipping without a default.
|
|
171
|
+
* Typed `MCPUIConfig` (i.e. resolved): that annotation is what stops a new
|
|
172
|
+
* key from shipping without a default.
|
|
153
173
|
*/
|
|
154
|
-
export const DEFAULT_MCPUI_CONFIG:
|
|
174
|
+
export const DEFAULT_MCPUI_CONFIG: MCPUIConfig = {
|
|
155
175
|
iframeCredentialless: 'auto',
|
|
156
176
|
customTrustedIframeDomains: [],
|
|
157
177
|
iframePolicy: 'strict',
|
|
@@ -161,10 +181,19 @@ export const DEFAULT_MCPUI_CONFIG: Required<MCPUIConfig> = {
|
|
|
161
181
|
}
|
|
162
182
|
|
|
163
183
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
184
|
+
* Typed with the RESOLVED view, because this context is read as well as
|
|
185
|
+
* written: it is exported, so a consumer may call
|
|
186
|
+
* `useContext(MCPUIConfigContext)` directly, and typing it as the input view
|
|
187
|
+
* would hand them `IframePolicy | undefined` — exactly the regression this
|
|
188
|
+
* whole split exists to undo.
|
|
189
|
+
*
|
|
190
|
+
* The cost lands on the rarer side: a host feeding this context directly
|
|
191
|
+
* passes a COMPLETE value, `{ ...DEFAULT_MCPUI_CONFIG, ...mine }`. Anyone who
|
|
192
|
+
* wants to pass only the keys they care about uses `MCPUIConfigProvider`,
|
|
193
|
+
* whose `config` prop takes `Partial<`{@link MCPUIConfig}`>` and does that
|
|
194
|
+
* merge. That preserves optional properties added through declaration
|
|
195
|
+
* merging; {@link MCPUIConfigInput} remains the unaugmented built-in input
|
|
196
|
+
* shape a host can name for ordinary authored configs.
|
|
168
197
|
*/
|
|
169
198
|
export const MCPUIConfigContext = createContext<MCPUIConfig>(DEFAULT_MCPUI_CONFIG)
|
|
170
199
|
|
|
@@ -173,7 +202,7 @@ export const MCPUIConfigContext = createContext<MCPUIConfig>(DEFAULT_MCPUI_CONFI
|
|
|
173
202
|
* came from the provider or from `DEFAULT_MCPUI_CONFIG`. Returns the defaults
|
|
174
203
|
* outright when no `<MCPUIConfigProvider>` is mounted above.
|
|
175
204
|
*/
|
|
176
|
-
export function useMCPUIConfig():
|
|
205
|
+
export function useMCPUIConfig(): MCPUIConfig {
|
|
177
206
|
return mergeProps(DEFAULT_MCPUI_CONFIG, useContext(MCPUIConfigContext))
|
|
178
207
|
}
|
|
179
208
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createContext, useContext, type Accessor, type JSX } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
export interface MathRenderOptions {
|
|
4
|
+
displayMode: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** A synchronous, host-supplied renderer. Returned markup is always sanitized by MCP-UI. */
|
|
8
|
+
export type MathRenderer = (tex: string, options: MathRenderOptions) => string | null
|
|
9
|
+
|
|
10
|
+
const MCPUIMathContext = createContext<Accessor<MathRenderer | undefined>>(() => undefined)
|
|
11
|
+
|
|
12
|
+
export interface MCPUIMathProviderProps {
|
|
13
|
+
/** Omit or pass null to explicitly disable an inherited renderer. */
|
|
14
|
+
renderMath?: MathRenderer | null
|
|
15
|
+
children: JSX.Element
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function MCPUIMathProvider(props: MCPUIMathProviderProps): JSX.Element {
|
|
19
|
+
return (
|
|
20
|
+
<MCPUIMathContext.Provider value={() => props.renderMath ?? undefined}>
|
|
21
|
+
{props.children}
|
|
22
|
+
</MCPUIMathContext.Provider>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns a reactive accessor for the nearest math renderer. Call the accessor
|
|
28
|
+
* at use time; its value changes when a provider swaps or disables its renderer.
|
|
29
|
+
*/
|
|
30
|
+
export function useMCPUIMath(): Accessor<MathRenderer | undefined> {
|
|
31
|
+
return useContext(MCPUIMathContext)
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -167,6 +167,7 @@ export {
|
|
|
167
167
|
} from './context/MCPUIConfigContext'
|
|
168
168
|
export type {
|
|
169
169
|
MCPUIConfig,
|
|
170
|
+
MCPUIConfigInput,
|
|
170
171
|
MCPUIConfigProviderProps,
|
|
171
172
|
} from './context/MCPUIConfigContext'
|
|
172
173
|
|
|
@@ -389,6 +390,9 @@ export {
|
|
|
389
390
|
DEGRADED_PROJECTION_LABELS,
|
|
390
391
|
} from './utils/degraded-projections'
|
|
391
392
|
export type { DegradedProjectionLabels } from './utils/degraded-projections'
|
|
393
|
+
export { MCPUIMathProvider } from './context/MCPUIMathContext'
|
|
394
|
+
export type { MathRenderer, MathRenderOptions, MCPUIMathProviderProps } from './context/MCPUIMathContext'
|
|
395
|
+
|
|
392
396
|
export { chartToDataTable, CHART_DATA_TABLE_LABELS } from './components/chart-data-table'
|
|
393
397
|
export type { ChartDataTableLabels } from './components/chart-data-table'
|
|
394
398
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import katex from 'katex'
|
|
2
|
+
import type { MathRenderer } from '../context/MCPUIMathContext'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Optional KaTeX adapter. It emits MathML only: no HTML layout tree, inline
|
|
6
|
+
* styles, fonts, stylesheet, shared macro state, or trusted TeX commands.
|
|
7
|
+
*/
|
|
8
|
+
export const renderKatexMath: MathRenderer = (tex, { displayMode }) => {
|
|
9
|
+
try {
|
|
10
|
+
return katex.renderToString(tex, {
|
|
11
|
+
displayMode,
|
|
12
|
+
output: 'mathml',
|
|
13
|
+
// Invalid TeX must return null so the core can preserve the complete
|
|
14
|
+
// source token, including its delimiters, instead of showing KaTeX's
|
|
15
|
+
// red error MathML and silently consuming the dollars.
|
|
16
|
+
throwOnError: true,
|
|
17
|
+
trust: false,
|
|
18
|
+
maxExpand: 1000,
|
|
19
|
+
maxSize: 20,
|
|
20
|
+
macros: {},
|
|
21
|
+
})
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/utils/iframe-coep.ts
CHANGED
|
@@ -27,14 +27,15 @@ import type { MCPUIConfig } from '../context/MCPUIConfigContext'
|
|
|
27
27
|
*
|
|
28
28
|
* An unparsable URL is not trusted, so it gets the attribute.
|
|
29
29
|
*
|
|
30
|
-
* Takes a RESOLVED config
|
|
31
|
-
* and `DEFAULT_MCPUI_CONFIG` hand out
|
|
32
|
-
*
|
|
33
|
-
* one here would
|
|
30
|
+
* Takes a RESOLVED config — `MCPUIConfig`, which is what `useMCPUIConfig()`
|
|
31
|
+
* and `DEFAULT_MCPUI_CONFIG` hand out, every key present. The authored view is
|
|
32
|
+
* `MCPUIConfigInput`, whose fields are optional so that a new key stays
|
|
33
|
+
* backward compatible; accepting one of those here would mean re-implementing
|
|
34
|
+
* the defaults next to their source, and silently drifting from them.
|
|
34
35
|
*/
|
|
35
36
|
export function shouldSetCredentialless(
|
|
36
37
|
url: string,
|
|
37
|
-
config:
|
|
38
|
+
config: MCPUIConfig,
|
|
38
39
|
options: { sandboxedWithoutSameOrigin: boolean }
|
|
39
40
|
): boolean {
|
|
40
41
|
const mode = config.iframeCredentialless
|
|
@@ -71,7 +72,7 @@ export function createCrossOriginIsolated(): Accessor<boolean> {
|
|
|
71
72
|
* Takes a resolved config, for the reason given on `shouldSetCredentialless`.
|
|
72
73
|
*/
|
|
73
74
|
export function shouldShowIframeFallbackLink(
|
|
74
|
-
config:
|
|
75
|
+
config: MCPUIConfig,
|
|
75
76
|
crossOriginIsolated: boolean
|
|
76
77
|
): boolean {
|
|
77
78
|
const mode = config.iframeFallbackLink
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { Config } from 'dompurify'
|
|
2
|
+
import { Marked, Renderer, type MarkedExtension, type Tokens } from 'marked'
|
|
3
|
+
import type { MathRenderer } from '../context/MCPUIMathContext'
|
|
4
|
+
import { escapeHtml } from './escape-html'
|
|
5
|
+
import { canSanitizeHtml, sanitizeHtml } from './sanitize-html'
|
|
6
|
+
|
|
7
|
+
export type MathMarkdownProfile = 'prose' | 'cellMarkdown'
|
|
8
|
+
export type MarkdownTextTransform = (escapedText: string) => string
|
|
9
|
+
|
|
10
|
+
export const MATH_MARKDOWN_LIMITS = {
|
|
11
|
+
sourceLength: 100_000,
|
|
12
|
+
expressionLength: 4_096,
|
|
13
|
+
expressionCount: 128,
|
|
14
|
+
} as const
|
|
15
|
+
|
|
16
|
+
const MATH_TAGS = [
|
|
17
|
+
'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'mtext', 'mspace', 'ms',
|
|
18
|
+
'msup', 'msub', 'msubsup', 'mfrac', 'msqrt', 'mroot', 'mtable', 'mtr',
|
|
19
|
+
'mtd', 'mover', 'munder', 'munderover', 'mpadded', 'mphantom', 'mstyle', 'menclose',
|
|
20
|
+
] as const
|
|
21
|
+
|
|
22
|
+
const MATH_ATTRS = [
|
|
23
|
+
'xmlns', 'display', 'class', 'mathvariant', 'stretchy', 'fence', 'separator',
|
|
24
|
+
'lspace', 'rspace', 'accent', 'accentunder', 'columnalign', 'rowalign',
|
|
25
|
+
'columnspacing', 'rowspacing', 'columnlines', 'rowlines', 'frame', 'notation',
|
|
26
|
+
'width', 'height', 'depth', 'voffset', 'scriptlevel', 'displaystyle',
|
|
27
|
+
] as const
|
|
28
|
+
|
|
29
|
+
const STRICT_MATHML_CONFIG = {
|
|
30
|
+
ALLOWED_TAGS: [...MATH_TAGS, 'span'],
|
|
31
|
+
ALLOWED_ATTR: [...MATH_ATTRS],
|
|
32
|
+
FORBID_TAGS: ['annotation', 'annotation-xml', 'style', 'a'],
|
|
33
|
+
// DOMPurify normally keeps the text of forbidden tags. TeX annotations can
|
|
34
|
+
// be large and duplicate the accessible MathML, so remove their subtree.
|
|
35
|
+
ADD_FORBID_CONTENTS: ['annotation', 'annotation-xml'],
|
|
36
|
+
FORBID_ATTR: ['style'],
|
|
37
|
+
} as Config
|
|
38
|
+
|
|
39
|
+
const CELL_MARKDOWN_TAGS = [
|
|
40
|
+
'a', 'strong', 'em', 'b', 'i', 'code', 'span', 'br', 'button', 'svg', 'path',
|
|
41
|
+
'p', 'ul', 'ol', 'li', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
42
|
+
...MATH_TAGS,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const FINAL_PROFILES: Record<MathMarkdownProfile, Config> = {
|
|
46
|
+
prose: {
|
|
47
|
+
ADD_TAGS: [...MATH_TAGS],
|
|
48
|
+
ADD_ATTR: [...MATH_ATTRS, 'target', 'rel'],
|
|
49
|
+
FORBID_TAGS: ['annotation', 'annotation-xml', 'style', 'form', 'textarea', 'select', 'button', 'iframe', 'object', 'embed'],
|
|
50
|
+
ADD_FORBID_CONTENTS: ['annotation', 'annotation-xml'],
|
|
51
|
+
FORBID_ATTR: ['style'],
|
|
52
|
+
} as Config,
|
|
53
|
+
cellMarkdown: {
|
|
54
|
+
ALLOWED_TAGS: CELL_MARKDOWN_TAGS,
|
|
55
|
+
ALLOWED_ATTR: [
|
|
56
|
+
'href', 'target', 'rel', 'class', 'data-citation-page', 'data-citation-source',
|
|
57
|
+
'data-citation-doc', 'data-citation-verified', 'title', 'fill', 'stroke',
|
|
58
|
+
'viewBox', 'stroke-linecap', 'stroke-linejoin', 'stroke-width', 'd',
|
|
59
|
+
...MATH_ATTRS,
|
|
60
|
+
],
|
|
61
|
+
FORBID_TAGS: ['annotation', 'annotation-xml', 'style'],
|
|
62
|
+
ADD_FORBID_CONTENTS: ['annotation', 'annotation-xml'],
|
|
63
|
+
FORBID_ATTR: ['style'],
|
|
64
|
+
} as Config,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface MathToken extends Tokens.Generic {
|
|
68
|
+
type: 'mcpDisplayMath' | 'mcpInlineMath'
|
|
69
|
+
raw: string
|
|
70
|
+
tex: string
|
|
71
|
+
displayMode: boolean
|
|
72
|
+
render: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isCurrencyLike(raw: string, tex: string, following: string): boolean {
|
|
76
|
+
if (/^\$\s|\s\$$/.test(raw)) return true
|
|
77
|
+
if (/^\d+(?:[.,]\d+)?-\s*$/.test(tex)) return true
|
|
78
|
+
if (/^\d/.test(tex) && /^\d/.test(following)) return true
|
|
79
|
+
|
|
80
|
+
// A currency opener can otherwise pair with the opening delimiter of a
|
|
81
|
+
// later expression ("$5 then ($x$)"). Reinterpret that first candidate
|
|
82
|
+
// only when it starts with a bounded numeric amount and its apparent closer
|
|
83
|
+
// is immediately followed by a complete later inline body. This deliberately
|
|
84
|
+
// leaves inherently ambiguous standalone forms ($5$, $2 + 3$, $2\pi$) as
|
|
85
|
+
// math and does not depend on the prose between the two delimiters.
|
|
86
|
+
const amount = /^\d+(?:[.,]\d{1,2})?(?=$|[\s,;:!?()[\]+*/-])/.exec(tex)?.[0]
|
|
87
|
+
if (!amount) return false
|
|
88
|
+
|
|
89
|
+
// A standalone numeric token is valid math even when followed by prose or
|
|
90
|
+
// another expression ("$5$, then $x$"). Reassignment requires content
|
|
91
|
+
// beyond the amount itself.
|
|
92
|
+
const trailing = tex.slice(amount.length)
|
|
93
|
+
if (!trailing) return false
|
|
94
|
+
|
|
95
|
+
// During streaming, the later formula may not have closed yet. An unmatched
|
|
96
|
+
// opening parenthesis before its dollar is sufficient structural evidence
|
|
97
|
+
// that the numeric dollar was currency; otherwise a permissive renderer can
|
|
98
|
+
// briefly flash "5 then (" as math.
|
|
99
|
+
const hasUnmatchedOpeningParen = (trailing.match(/\(/g)?.length ?? 0) > (trailing.match(/\)/g)?.length ?? 0)
|
|
100
|
+
const containsTexCommand = /\\[A-Za-z]+/.test(trailing)
|
|
101
|
+
if (hasUnmatchedOpeningParen && !containsTexCommand) return true
|
|
102
|
+
|
|
103
|
+
const closerLooksLikeLaterOpener = /^(?![\s,.;:!?)}\]])(?:\\[^\n]|[^\\\n$])+\$(?!\$)/.test(following)
|
|
104
|
+
return closerLooksLikeLaterOpener
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const CELL_LINK_ATTRIBUTES = 'target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline"'
|
|
108
|
+
|
|
109
|
+
function cellLinkExtension(): MarkedExtension {
|
|
110
|
+
return {
|
|
111
|
+
renderer: {
|
|
112
|
+
link(this: Renderer, token: Tokens.Link): string {
|
|
113
|
+
// Marked owns escaping of the destination, title and nested label
|
|
114
|
+
// tokens. Only inject fixed attributes into its already-safe anchor.
|
|
115
|
+
const anchor = Renderer.prototype.link.call(this, token) as string
|
|
116
|
+
return anchor.replace('<a ', `<a ${CELL_LINK_ATTRIBUTES} `)
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function mathExtension(renderer: MathRenderer): MarkedExtension {
|
|
123
|
+
let count = 0
|
|
124
|
+
const token = (raw: string, tex: string, displayMode: boolean): MathToken => ({
|
|
125
|
+
type: displayMode ? 'mcpDisplayMath' : 'mcpInlineMath',
|
|
126
|
+
raw,
|
|
127
|
+
tex,
|
|
128
|
+
displayMode,
|
|
129
|
+
render: ++count <= MATH_MARKDOWN_LIMITS.expressionCount && tex.length <= MATH_MARKDOWN_LIMITS.expressionLength,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
extensions: [
|
|
134
|
+
{
|
|
135
|
+
name: 'mcpDisplayMath',
|
|
136
|
+
level: 'block',
|
|
137
|
+
start(src) {
|
|
138
|
+
const index = src.indexOf('$$')
|
|
139
|
+
return index < 0 ? undefined : index
|
|
140
|
+
},
|
|
141
|
+
tokenizer(src) {
|
|
142
|
+
const match = /^\$\$\s*\n?([\s\S]+?)\n?\s*\$\$(?:\n|$)/.exec(src)
|
|
143
|
+
if (!match) return undefined
|
|
144
|
+
return token(match[0], match[1], true)
|
|
145
|
+
},
|
|
146
|
+
renderer(tokenValue) {
|
|
147
|
+
return renderToken(tokenValue as MathToken, renderer)
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'mcpInlineMath',
|
|
152
|
+
level: 'inline',
|
|
153
|
+
start(src) {
|
|
154
|
+
const index = src.indexOf('$')
|
|
155
|
+
return index < 0 ? undefined : index
|
|
156
|
+
},
|
|
157
|
+
tokenizer(src) {
|
|
158
|
+
const match = /^\$(?!\$)((?:\\[^\n]|[^\\\n$])+?)\$(?!\$)/.exec(src)
|
|
159
|
+
if (!match || isCurrencyLike(match[0], match[1], src.slice(match[0].length))) return undefined
|
|
160
|
+
return token(match[0], match[1], false)
|
|
161
|
+
},
|
|
162
|
+
renderer(tokenValue) {
|
|
163
|
+
return renderToken(tokenValue as MathToken, renderer)
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderToken(token: MathToken, renderer: MathRenderer): string {
|
|
171
|
+
if (!token.render) return escapeHtml(token.raw)
|
|
172
|
+
try {
|
|
173
|
+
const rendered = renderer(token.tex, { displayMode: token.displayMode })
|
|
174
|
+
if (!rendered) return escapeHtml(token.raw)
|
|
175
|
+
return sanitizeHtml(rendered, STRICT_MATHML_CONFIG)
|
|
176
|
+
} catch {
|
|
177
|
+
return escapeHtml(token.raw)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Parse Markdown with isolated, bounded math support and sanitize the complete
|
|
183
|
+
* result for its final sink. The optional transform sees escaped ordinary text
|
|
184
|
+
* tokens only. Math, code, raw HTML and link destinations never pass through
|
|
185
|
+
* it, so citation syntax cannot corrupt TeX optional arguments or attributes.
|
|
186
|
+
*/
|
|
187
|
+
export function parseMathMarkdown(
|
|
188
|
+
source: string,
|
|
189
|
+
renderer: MathRenderer,
|
|
190
|
+
profile: MathMarkdownProfile,
|
|
191
|
+
transformText?: MarkdownTextTransform,
|
|
192
|
+
): string {
|
|
193
|
+
const input = source === null || source === undefined ? '' : String(source)
|
|
194
|
+
if (!canSanitizeHtml()) return escapeHtml(input)
|
|
195
|
+
if (input.length > MATH_MARKDOWN_LIMITS.sourceLength) return escapeHtml(input)
|
|
196
|
+
|
|
197
|
+
const textRenderer = transformText
|
|
198
|
+
? {
|
|
199
|
+
renderer: {
|
|
200
|
+
text(this: Renderer, token: Tokens.Text | Tokens.Escape): string {
|
|
201
|
+
// Block text tokens (notably list-item text) can own already
|
|
202
|
+
// tokenized inline children. Recurse first so math and code keep
|
|
203
|
+
// their own renderers; leaf text callbacks run as parsing descends.
|
|
204
|
+
if ('tokens' in token && token.tokens) {
|
|
205
|
+
return this.parser.parseInline(token.tokens)
|
|
206
|
+
}
|
|
207
|
+
// Delegate entity handling and nested token rendering to Marked's
|
|
208
|
+
// own renderer. `escapeHtml` is deliberately not equivalent: it
|
|
209
|
+
// turns source `&` into visible `&` by double-escaping it.
|
|
210
|
+
const safeText = Renderer.prototype.text.call(this, token)
|
|
211
|
+
return transformText(safeText as string)
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
: undefined
|
|
216
|
+
const extensions = [mathExtension(renderer)]
|
|
217
|
+
if (profile === 'cellMarkdown') extensions.push(cellLinkExtension())
|
|
218
|
+
if (textRenderer) extensions.push(textRenderer)
|
|
219
|
+
const parser = new Marked({ gfm: true }, ...extensions)
|
|
220
|
+
const parsed = parser.parse(input, { async: false }) as string
|
|
221
|
+
return sanitizeHtml(parsed, FINAL_PROFILES[profile])
|
|
222
|
+
}
|