@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +102 -0
  3. package/dist/components/UIResourceRenderer.cjs +15 -5
  4. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  5. package/dist/components/UIResourceRenderer.d.ts +2 -1
  6. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  7. package/dist/components/UIResourceRenderer.js +15 -5
  8. package/dist/components/UIResourceRenderer.js.map +1 -1
  9. package/dist/context/MCPUIConfigContext.cjs.map +1 -1
  10. package/dist/context/MCPUIConfigContext.d.ts +57 -28
  11. package/dist/context/MCPUIConfigContext.d.ts.map +1 -1
  12. package/dist/context/MCPUIConfigContext.js.map +1 -1
  13. package/dist/context/MCPUIMathContext.cjs +19 -0
  14. package/dist/context/MCPUIMathContext.cjs.map +1 -0
  15. package/dist/context/MCPUIMathContext.d.ts +18 -0
  16. package/dist/context/MCPUIMathContext.d.ts.map +1 -0
  17. package/dist/context/MCPUIMathContext.js +19 -0
  18. package/dist/context/MCPUIMathContext.js.map +1 -0
  19. package/dist/index.cjs +2 -0
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +3 -1
  22. package/dist/index.d.ts +3 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/plugins/katex.cjs +23 -0
  27. package/dist/plugins/katex.cjs.map +1 -0
  28. package/dist/plugins/katex.d.cts +7 -0
  29. package/dist/plugins/katex.d.ts +7 -0
  30. package/dist/plugins/katex.d.ts.map +1 -0
  31. package/dist/plugins/katex.js +23 -0
  32. package/dist/plugins/katex.js.map +1 -0
  33. package/dist/utils/iframe-coep.cjs.map +1 -1
  34. package/dist/utils/iframe-coep.d.ts +7 -6
  35. package/dist/utils/iframe-coep.d.ts.map +1 -1
  36. package/dist/utils/iframe-coep.js.map +1 -1
  37. package/dist/utils/math-markdown.cjs +240 -0
  38. package/dist/utils/math-markdown.cjs.map +1 -0
  39. package/dist/utils/math-markdown.d.ts +16 -0
  40. package/dist/utils/math-markdown.d.ts.map +1 -0
  41. package/dist/utils/math-markdown.js +240 -0
  42. package/dist/utils/math-markdown.js.map +1 -0
  43. package/package.json +22 -3
  44. package/src/components/UIResourceRenderer.tsx +24 -3
  45. package/src/context/MCPUIConfigContext.tsx +57 -28
  46. package/src/context/MCPUIMathContext.tsx +32 -0
  47. package/src/index.ts +4 -0
  48. package/src/plugins/katex.ts +25 -0
  49. package/src/utils/iframe-coep.ts +7 -6
  50. 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.22.0",
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
- * ## Why every field is optional
47
- *
48
- * This interface grows a key every time the library gains a policy switch,
49
- * and those releases are MINOR. Were the fields required, adding one would
50
- * break the build of any consumer holding a COMPLETE `MCPUIConfig` value —
51
- * a typed constant, a helper that returns one, or a direct
52
- * `<MCPUIConfigContext.Provider value={…}>` — because their object would
53
- * suddenly miss a key. Nothing about their behaviour changed; only `tsc`
54
- * would fail. Optional fields make each new key additive.
55
- *
56
- * The completeness guarantee moves from the type to the two values that
57
- * actually need it, exactly as `MCPUIStrings` does it:
58
- * `DEFAULT_MCPUI_CONFIG` is `Required<MCPUIConfig>`, so forgetting a default
59
- * is a compile error HERE, and {@link useMCPUIConfig} returns
60
- * `Required<MCPUIConfig>`, so every renderer still reads a fully resolved
61
- * value and never has to null-check a policy.
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 MCPUIConfig {
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 MCPUIConfig.customIframeDomains} with `iframePolicy: 'extend'`.
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 MCPUIConfig.customIframeDomains}.
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 `Required<MCPUIConfig>`: the interface's fields are optional so that
151
- * adding one stays backward compatible, and this annotation is what keeps a
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: Required<MCPUIConfig> = {
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
- * Deliberately typed with the PARTIAL `MCPUIConfig`, not `Required<…>`: a
165
- * host that bypasses `MCPUIConfigProvider` and feeds this provider directly
166
- * may pass whichever keys it cares about, and `useMCPUIConfig` fills the
167
- * rest. Widening it this way only ever accepts more than before.
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(): Required<MCPUIConfig> {
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
+ }
@@ -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 (`Required<MCPUIConfig>` — what `useMCPUIConfig()`
31
- * and `DEFAULT_MCPUI_CONFIG` hand out). `MCPUIConfig`'s own fields are
32
- * optional so new keys stay backward compatible, and reading an unresolved
33
- * one here would silently re-implement the defaults next to their source.
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: Required<MCPUIConfig>,
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: Required<MCPUIConfig>,
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 `&amp;` into visible `&amp;` 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
+ }