@seed-ship/mcp-ui-solid 6.22.0 → 6.24.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 +219 -0
- package/README.md +209 -2
- package/dist/components/ChartJSRenderer.cjs +192 -66
- package/dist/components/ChartJSRenderer.cjs.map +1 -1
- package/dist/components/ChartJSRenderer.d.ts.map +1 -1
- package/dist/components/ChartJSRenderer.js +192 -66
- package/dist/components/ChartJSRenderer.js.map +1 -1
- package/dist/components/DegradedFallback.cjs +34 -2
- package/dist/components/DegradedFallback.cjs.map +1 -1
- package/dist/components/DegradedFallback.d.ts +7 -0
- package/dist/components/DegradedFallback.d.ts.map +1 -1
- package/dist/components/DegradedFallback.js +34 -2
- package/dist/components/DegradedFallback.js.map +1 -1
- package/dist/components/MissingValue.cjs +43 -0
- package/dist/components/MissingValue.cjs.map +1 -0
- package/dist/components/MissingValue.d.ts +27 -0
- package/dist/components/MissingValue.d.ts.map +1 -0
- package/dist/components/MissingValue.js +43 -0
- package/dist/components/MissingValue.js.map +1 -0
- package/dist/components/UIResourceRenderer.cjs +250 -202
- 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 +250 -202
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/components/chart-data-table.cjs +30 -5
- package/dist/components/chart-data-table.cjs.map +1 -1
- package/dist/components/chart-data-table.d.ts +18 -1
- package/dist/components/chart-data-table.d.ts.map +1 -1
- package/dist/components/chart-data-table.js +30 -5
- package/dist/components/chart-data-table.js.map +1 -1
- package/dist/components/chart-missing-plugin.cjs +194 -0
- package/dist/components/chart-missing-plugin.cjs.map +1 -0
- package/dist/components/chart-missing-plugin.d.ts +127 -0
- package/dist/components/chart-missing-plugin.d.ts.map +1 -0
- package/dist/components/chart-missing-plugin.js +194 -0
- package/dist/components/chart-missing-plugin.js.map +1 -0
- 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/context/MCPUIStringsContext.cjs +12 -1
- package/dist/context/MCPUIStringsContext.cjs.map +1 -1
- package/dist/context/MCPUIStringsContext.d.ts +62 -0
- package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
- package/dist/context/MCPUIStringsContext.js +12 -1
- package/dist/context/MCPUIStringsContext.js.map +1 -1
- 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/services/component-registry.cjs +9 -2
- package/dist/services/component-registry.cjs.map +1 -1
- package/dist/services/component-registry.d.ts.map +1 -1
- package/dist/services/component-registry.js +9 -2
- package/dist/services/component-registry.js.map +1 -1
- package/dist/services/validation.cjs +31 -8
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +31 -8
- package/dist/services/validation.js.map +1 -1
- package/dist/types/index.d.ts +14 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/degraded-projections.cjs +19 -3
- package/dist/utils/degraded-projections.cjs.map +1 -1
- package/dist/utils/degraded-projections.d.ts +22 -2
- package/dist/utils/degraded-projections.d.ts.map +1 -1
- package/dist/utils/degraded-projections.js +19 -3
- package/dist/utils/degraded-projections.js.map +1 -1
- 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/dist/utils/missing-value.cjs +60 -0
- package/dist/utils/missing-value.cjs.map +1 -0
- package/dist/utils/missing-value.d.ts +79 -0
- package/dist/utils/missing-value.d.ts.map +1 -0
- package/dist/utils/missing-value.js +60 -0
- package/dist/utils/missing-value.js.map +1 -0
- package/package.json +23 -4
- package/src/components/ChartJSRenderer.tsx +159 -6
- package/src/components/DegradedFallback.tsx +24 -2
- package/src/components/MissingValue.tsx +61 -0
- package/src/components/UIResourceRenderer.tsx +64 -10
- package/src/components/chart-data-table.ts +50 -6
- package/src/components/chart-missing-plugin.ts +334 -0
- package/src/context/MCPUIConfigContext.tsx +57 -28
- package/src/context/MCPUIMathContext.tsx +32 -0
- package/src/context/MCPUIStringsContext.tsx +76 -0
- package/src/index.ts +4 -0
- package/src/plugins/katex.ts +25 -0
- package/src/services/component-registry.ts +10 -2
- package/src/services/validation.ts +44 -9
- package/src/types/index.ts +13 -1
- package/src/utils/degraded-projections.ts +44 -4
- package/src/utils/iframe-coep.ts +7 -6
- package/src/utils/math-markdown.ts +222 -0
- package/src/utils/missing-value.ts +127 -0
|
@@ -45,6 +45,8 @@ import type {
|
|
|
45
45
|
// Runtime-free template helper: `src/validation.ts` (the SSR-safe subpath)
|
|
46
46
|
// re-exports this module, so it must stay free of any solid-js import.
|
|
47
47
|
import { formatMCPUIString } from '../utils/format-string';
|
|
48
|
+
// Runtime-free as well (v6.24.0): the missing-value rules shared with the renderers.
|
|
49
|
+
import { PART_TO_WHOLE_CHART_TYPES } from '../utils/missing-value';
|
|
48
50
|
|
|
49
51
|
/**
|
|
50
52
|
* All known ComponentType values — used to distinguish known-but-unvalidated
|
|
@@ -477,38 +479,71 @@ export function validateChartComponent(
|
|
|
477
479
|
}
|
|
478
480
|
}
|
|
479
481
|
|
|
480
|
-
// Data type validation — numbers for categorical, {x,y} objects for point charts
|
|
482
|
+
// Data type validation — numbers for categorical, {x,y} objects for point charts.
|
|
483
|
+
//
|
|
484
|
+
// `null` is an explicit missing value (v6.24.0): a number slot, or a point's
|
|
485
|
+
// `y`, that exists but was not observed. Line, bar, radar, scatter and bubble
|
|
486
|
+
// charts keep its slot and mark it. Pie, doughnut and polar-area charts
|
|
487
|
+
// cannot: Chart.js parses a null share as `0`, so the other shares and the
|
|
488
|
+
// wedge itself would all be drawn wrong — those types refuse it.
|
|
489
|
+
const missingUnsupported = PART_TO_WHOLE_CHART_TYPES.has(chartType);
|
|
481
490
|
for (const [index, dataset] of params.data.datasets.entries()) {
|
|
482
491
|
if (!Array.isArray(dataset.data)) continue;
|
|
483
492
|
const datasetIsPointChart = chartType === 'scatter' || chartType === 'bubble' || usesPoints(dataset.data);
|
|
484
493
|
for (const [dataIndex, value] of dataset.data.entries()) {
|
|
494
|
+
const path = `params.data.datasets[${index}].data[${dataIndex}]`;
|
|
485
495
|
if (datasetIsPointChart) {
|
|
486
496
|
const vObj = value as any;
|
|
487
497
|
if (
|
|
488
498
|
typeof value !== 'object' ||
|
|
489
499
|
value === null ||
|
|
490
500
|
vObj.x == null ||
|
|
491
|
-
typeof vObj.y !== 'number' ||
|
|
501
|
+
(vObj.y !== null && typeof vObj.y !== 'number') ||
|
|
492
502
|
(vObj.r !== undefined && (typeof vObj.r !== 'number' || !Number.isFinite(vObj.r) || vObj.r < 0))
|
|
493
503
|
) {
|
|
494
504
|
errors.push({
|
|
495
|
-
path
|
|
496
|
-
message: `Invalid point data: expected {x, y} object`,
|
|
505
|
+
path,
|
|
506
|
+
message: `Invalid point data: expected {x, y} object (y may be null for a missing value)`,
|
|
497
507
|
code: 'INVALID_POINT_DATA',
|
|
498
508
|
});
|
|
509
|
+
} else if (missingUnsupported && vObj.y === null) {
|
|
510
|
+
errors.push({
|
|
511
|
+
path,
|
|
512
|
+
message: `Missing value (null) is not supported by '${chartType}' charts, which would draw it as a zero share — use a bar or line chart, or a table`,
|
|
513
|
+
code: 'MISSING_VALUE_UNSUPPORTED',
|
|
514
|
+
});
|
|
499
515
|
}
|
|
500
|
-
} else {
|
|
501
|
-
if (
|
|
516
|
+
} else if (value === null) {
|
|
517
|
+
if (missingUnsupported) {
|
|
502
518
|
errors.push({
|
|
503
|
-
path
|
|
504
|
-
message: `
|
|
505
|
-
code: '
|
|
519
|
+
path,
|
|
520
|
+
message: `Missing value (null) is not supported by '${chartType}' charts, which would draw it as a zero share — use a bar or line chart, or a table`,
|
|
521
|
+
code: 'MISSING_VALUE_UNSUPPORTED',
|
|
506
522
|
});
|
|
507
523
|
}
|
|
524
|
+
} else if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
525
|
+
errors.push({
|
|
526
|
+
path,
|
|
527
|
+
message: `Invalid data value: ${value} (must be a finite number, or null for a missing value)`,
|
|
528
|
+
code: 'INVALID_DATA_TYPE',
|
|
529
|
+
});
|
|
508
530
|
}
|
|
509
531
|
}
|
|
510
532
|
}
|
|
511
533
|
|
|
534
|
+
// `unit` (v6.24.0) ends up in the value-axis title, in every tooltip and in
|
|
535
|
+
// the data-view headers: a short string, or nothing. An empty string is
|
|
536
|
+
// tolerated — the renderer treats it as "no unit" — so a producer that
|
|
537
|
+
// emits `unit: ''` does not lose its whole chart over it.
|
|
538
|
+
const unit = (params as { unit?: unknown }).unit;
|
|
539
|
+
if (unit !== undefined && (typeof unit !== 'string' || unit.length > 32)) {
|
|
540
|
+
errors.push({
|
|
541
|
+
path: 'params.unit',
|
|
542
|
+
message: 'Invalid unit: expected a string of at most 32 characters',
|
|
543
|
+
code: 'INVALID_UNIT',
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
512
547
|
return {
|
|
513
548
|
valid: errors.length === 0,
|
|
514
549
|
errors: errors.length > 0 ? errors : undefined,
|
package/src/types/index.ts
CHANGED
|
@@ -81,7 +81,13 @@ export interface ChartComponentParams {
|
|
|
81
81
|
labels: string[]
|
|
82
82
|
datasets: Array<{
|
|
83
83
|
label: string
|
|
84
|
-
|
|
84
|
+
/**
|
|
85
|
+
* `null` is an explicit missing value (v6.24.0): its label keeps its
|
|
86
|
+
* slot, a line breaks there, a bar shows a dashed outline — never a
|
|
87
|
+
* zero. Pie, doughnut and polar-area charts reject it, since they
|
|
88
|
+
* would draw it as a zero share.
|
|
89
|
+
*/
|
|
90
|
+
data: Array<number | null> | Array<{ x: string | number; y: number | null; r?: number }>
|
|
85
91
|
backgroundColor?: string | string[]
|
|
86
92
|
borderColor?: string | string[]
|
|
87
93
|
borderWidth?: number
|
|
@@ -135,6 +141,12 @@ export interface ChartComponentParams {
|
|
|
135
141
|
* Custom CSS class (Sprint 7)
|
|
136
142
|
*/
|
|
137
143
|
className?: string
|
|
144
|
+
/**
|
|
145
|
+
* Unit of every value in the chart (`'mm'`, `'km/h'`), shown as the value
|
|
146
|
+
* axis title (unless `options.scales` already sets one), after each tooltip
|
|
147
|
+
* value, and in the data-view column headers (v6.24.0). One unit per chart.
|
|
148
|
+
*/
|
|
149
|
+
unit?: string
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
/**
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { formatMCPUIString } from './format-string';
|
|
15
|
+
import { isMissingChartValue } from './missing-value';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Column headers and series names of the degraded tables.
|
|
@@ -46,10 +47,17 @@ export interface DegradedProjectionLabels {
|
|
|
46
47
|
feature: string;
|
|
47
48
|
/** Chart table — fallback dataset name. Template — `{n}`. */
|
|
48
49
|
series: string;
|
|
50
|
+
/**
|
|
51
|
+
* Chart table — a dataset column header followed by the chart's `unit`.
|
|
52
|
+
* Template — `{label}`, `{unit}`. Only applied when the chart has a unit.
|
|
53
|
+
* Optional so that a complete labels table written for 6.20.0–6.23.0 still
|
|
54
|
+
* type-checks. @since v6.24.0
|
|
55
|
+
*/
|
|
56
|
+
withUnit?: string;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/** English defaults for {@link DegradedProjectionLabels}. */
|
|
52
|
-
export const DEGRADED_PROJECTION_LABELS: DegradedProjectionLabels = {
|
|
60
|
+
export const DEGRADED_PROJECTION_LABELS: Required<DegradedProjectionLabels> = {
|
|
53
61
|
source: 'Source',
|
|
54
62
|
target: 'Target',
|
|
55
63
|
label: 'Label',
|
|
@@ -61,6 +69,7 @@ export const DEGRADED_PROJECTION_LABELS: DegradedProjectionLabels = {
|
|
|
61
69
|
marker: 'marker',
|
|
62
70
|
feature: 'feature',
|
|
63
71
|
series: 'Series {n}',
|
|
72
|
+
withUnit: '{label} ({unit})',
|
|
64
73
|
};
|
|
65
74
|
|
|
66
75
|
const withLabels = (labels?: Partial<DegradedProjectionLabels>): DegradedProjectionLabels => ({
|
|
@@ -73,6 +82,12 @@ export interface DegradedTable {
|
|
|
73
82
|
rows: Array<Array<string | number>>;
|
|
74
83
|
}
|
|
75
84
|
|
|
85
|
+
/** A degraded chart table: its cells, plus which of them are missing values. */
|
|
86
|
+
export interface DegradedChartTable extends DegradedTable {
|
|
87
|
+
/** Same shape as `rows`; `true` for a dataset entry that is missing. @since v6.24.0 */
|
|
88
|
+
missing: boolean[][];
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
const MAX_PROJECTED_ROWS = 200;
|
|
77
92
|
|
|
78
93
|
/** Compact a value to a single table cell string. */
|
|
@@ -188,25 +203,50 @@ export function mapToDegradedTable(params: {
|
|
|
188
203
|
* Chart → a series table: one row per label, one column per dataset. So a
|
|
189
204
|
* chart that can't draw still shows its numbers. Point/object data (scatter,
|
|
190
205
|
* bubble, time series) is stringified per cell.
|
|
206
|
+
*
|
|
207
|
+
* `missing` flags the cells whose dataset entry is a missing value (`null`
|
|
208
|
+
* or non-finite) so the table can mark them instead of showing an empty
|
|
209
|
+
* cell. A slot past the end of a shorter or empty dataset has no entry and is
|
|
210
|
+
* not flagged; a point object keeps its JSON text (where a `"y":null` reads
|
|
211
|
+
* as such) and is not flagged either. With a `unit`, dataset column headers
|
|
212
|
+
* become `labels.withUnit`.
|
|
191
213
|
*/
|
|
192
214
|
export function chartToDegradedTable(params: {
|
|
215
|
+
unit?: string;
|
|
193
216
|
data?: {
|
|
194
217
|
labels?: Array<string | number>;
|
|
195
218
|
datasets?: Array<{ label?: string; data?: unknown[] }>;
|
|
196
219
|
};
|
|
197
|
-
}, labels?: Partial<DegradedProjectionLabels>):
|
|
220
|
+
}, labels?: Partial<DegradedProjectionLabels>): DegradedChartTable {
|
|
198
221
|
const l = withLabels(labels);
|
|
222
|
+
const unit = typeof params.unit === 'string' && params.unit !== '' ? params.unit : undefined;
|
|
199
223
|
const datasets = params.data?.datasets ?? [];
|
|
200
224
|
const dataLabels = params.data?.labels ?? [];
|
|
201
225
|
const rowCount = Math.max(dataLabels.length, ...datasets.map((d) => d.data?.length ?? 0), 0);
|
|
202
226
|
|
|
203
227
|
const columns = [
|
|
204
228
|
'',
|
|
205
|
-
...datasets.map((d, i) =>
|
|
229
|
+
...datasets.map((d, i) => {
|
|
230
|
+
const name = d.label ?? formatMCPUIString(l.series, { n: i + 1 });
|
|
231
|
+
return unit === undefined
|
|
232
|
+
? name
|
|
233
|
+
: formatMCPUIString(l.withUnit ?? DEGRADED_PROJECTION_LABELS.withUnit, { label: name, unit });
|
|
234
|
+
}),
|
|
206
235
|
];
|
|
207
236
|
const rows: Array<Array<string | number>> = [];
|
|
237
|
+
const missing: boolean[][] = [];
|
|
208
238
|
for (let r = 0; r < Math.min(rowCount, MAX_PROJECTED_ROWS); r++) {
|
|
209
239
|
rows.push([cell(dataLabels[r] ?? r + 1), ...datasets.map((d) => cell(d.data?.[r]))]);
|
|
240
|
+
missing.push([
|
|
241
|
+
false,
|
|
242
|
+
...datasets.map((d) => {
|
|
243
|
+
// Past the end of a shorter or empty dataset there is no entry, so
|
|
244
|
+
// nothing to flag (the cell stays empty, as before 6.24.0).
|
|
245
|
+
if (r >= (d.data?.length ?? 0)) return false;
|
|
246
|
+
const entry = d.data?.[r];
|
|
247
|
+
return (entry === null || typeof entry !== 'object') && isMissingChartValue(entry);
|
|
248
|
+
}),
|
|
249
|
+
]);
|
|
210
250
|
}
|
|
211
|
-
return { columns, rows };
|
|
251
|
+
return { columns, rows, missing };
|
|
212
252
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Missing values — one definition shared by tables, chart data views,
|
|
3
|
+
* degraded projections, chart validation and the chart renderer.
|
|
4
|
+
*
|
|
5
|
+
* @since v6.24.0
|
|
6
|
+
*
|
|
7
|
+
* A missing value is a value the payload says EXISTS but was not observed:
|
|
8
|
+
* `null` in a chart dataset (or as a point's `y`), `null` / `undefined` in a
|
|
9
|
+
* table cell. It is not a zero, and it is not the absence of a row or of a
|
|
10
|
+
* label: every view keeps its slot and marks it.
|
|
11
|
+
*
|
|
12
|
+
* Runtime-free on purpose (no `solid-js`): the pure projections in
|
|
13
|
+
* `chart-data-table.ts` and `degraded-projections.ts`, and `validation.ts`
|
|
14
|
+
* (published as the `/validation` subpath), import it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The glyph a table cell shows for a missing value — `renderCellValue`'s historical `-`. */
|
|
18
|
+
export const MISSING_VALUE_MARKER = '-'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Chart types that encode a value as a share of a whole (or, for polar area,
|
|
22
|
+
* as a wedge whose emptiness reads as zero). Chart.js parses a `null` share
|
|
23
|
+
* as `0`, so these types refuse missing values instead of drawing them.
|
|
24
|
+
*/
|
|
25
|
+
export const PART_TO_WHOLE_CHART_TYPES: ReadonlySet<string> = new Set([
|
|
26
|
+
'pie',
|
|
27
|
+
'doughnut',
|
|
28
|
+
'polarArea',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a raw TABLE cell value is displayed as {@link MISSING_VALUE_MARKER}.
|
|
33
|
+
*
|
|
34
|
+
* Mirrors the rules `renderCellValue` applies before any formatting — `null`,
|
|
35
|
+
* `undefined`, and a string that is empty or the word `undefined` once the
|
|
36
|
+
* backend's `"X – undefined"` debris is stripped — plus the literal marker
|
|
37
|
+
* itself (whitespace around it aside, which a table cell does not render),
|
|
38
|
+
* since it looks identical on screen and so must read identically.
|
|
39
|
+
*
|
|
40
|
+
* Objects are never missing here: `renderCellValue` renders them (link,
|
|
41
|
+
* name, or escaped JSON).
|
|
42
|
+
*/
|
|
43
|
+
export function isMissingCellValue(value: unknown): boolean {
|
|
44
|
+
if (value === null || value === undefined) return true
|
|
45
|
+
if (typeof value === 'object') return false
|
|
46
|
+
const cleaned = stripUndefinedDebris(String(value)).trim()
|
|
47
|
+
return cleaned === '' || cleaned === MISSING_VALUE_MARKER || cleaned.toLowerCase() === 'undefined'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Removes the `"Text – undefined"` / `"undefined – Text"` debris some
|
|
52
|
+
* backends concatenate. Shared with `renderCellValue` so both apply the very
|
|
53
|
+
* same cleanup.
|
|
54
|
+
*/
|
|
55
|
+
export function stripUndefinedDebris(value: string): string {
|
|
56
|
+
return value.replace(/\s*[–-]\s*undefined\s*$/gi, '').replace(/^undefined\s*[–-]\s*/gi, '')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether one CHART data entry is missing: `null` / `undefined`, a non-finite
|
|
61
|
+
* number (Chart.js parses those to `null` too), or a point whose `y` is.
|
|
62
|
+
*/
|
|
63
|
+
export function isMissingChartValue(value: unknown): boolean {
|
|
64
|
+
if (value === null || value === undefined) return true
|
|
65
|
+
if (typeof value === 'number') return !Number.isFinite(value)
|
|
66
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
67
|
+
const y = (value as { y?: unknown }).y
|
|
68
|
+
return y === null || y === undefined || (typeof y === 'number' && !Number.isFinite(y))
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What a chart's missing values amount to.
|
|
75
|
+
*
|
|
76
|
+
* Only ENTRIES count: an explicit `null` (or non-finite number, or a point
|
|
77
|
+
* whose `y` is one). A dataset with no entry at all (`data: []`) is not
|
|
78
|
+
* "missing" — it is how 6.23.0 and earlier payloads said "nothing yet", e.g.
|
|
79
|
+
* while streaming, and it keeps rendering exactly as it did then.
|
|
80
|
+
*/
|
|
81
|
+
export interface ChartMissingSummary {
|
|
82
|
+
/** At least one entry of one dataset is missing. */
|
|
83
|
+
hasMissing: boolean
|
|
84
|
+
/** Per dataset, in order: it has entries and every one of them is missing. */
|
|
85
|
+
fullyMissing: boolean[]
|
|
86
|
+
/**
|
|
87
|
+
* Per dataset, in order: at least one observed value AND at least one
|
|
88
|
+
* missing entry — the series a reader actually sees broken.
|
|
89
|
+
*/
|
|
90
|
+
partiallyMissing: boolean[]
|
|
91
|
+
/** Something is missing and no dataset has a single observed value: nothing to plot. */
|
|
92
|
+
allMissing: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface ChartDataLike {
|
|
96
|
+
data?: {
|
|
97
|
+
datasets?: Array<{ data?: unknown[] } | null | undefined>
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Summarises the missing values of a chart payload. Never throws on a malformed payload. */
|
|
102
|
+
export function summarizeChartMissing(params: ChartDataLike | null | undefined): ChartMissingSummary {
|
|
103
|
+
const datasets = Array.isArray(params?.data?.datasets) ? params.data.datasets : []
|
|
104
|
+
const fullyMissing: boolean[] = []
|
|
105
|
+
const partiallyMissing: boolean[] = []
|
|
106
|
+
let anyMissing = false
|
|
107
|
+
let anyObserved = false
|
|
108
|
+
for (const dataset of datasets) {
|
|
109
|
+
const data = Array.isArray(dataset?.data) ? dataset.data : []
|
|
110
|
+
let observed = 0
|
|
111
|
+
let missing = 0
|
|
112
|
+
for (const value of data) {
|
|
113
|
+
if (isMissingChartValue(value)) missing += 1
|
|
114
|
+
else observed += 1
|
|
115
|
+
}
|
|
116
|
+
fullyMissing.push(missing > 0 && observed === 0)
|
|
117
|
+
partiallyMissing.push(observed > 0 && missing > 0)
|
|
118
|
+
anyMissing ||= missing > 0
|
|
119
|
+
anyObserved ||= observed > 0
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
hasMissing: anyMissing,
|
|
123
|
+
fullyMissing,
|
|
124
|
+
partiallyMissing,
|
|
125
|
+
allMissing: anyMissing && !anyObserved,
|
|
126
|
+
}
|
|
127
|
+
}
|