feat: 增加 ECharts 渲染与预览交互增强
- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染 - 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略 - 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步 - 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放 - 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export * from "./browser/engine.js";
|
||||
export * from "./browser/freeze-svg.js";
|
||||
export * from "./browser/render-blocks.js";
|
||||
export * from "./browser/render-chart.js";
|
||||
export * from "./browser/resource-policy.js";
|
||||
export * from "./browser/types.js";
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as echarts from "echarts/core";
|
||||
import {
|
||||
BarChart,
|
||||
BoxplotChart,
|
||||
CandlestickChart,
|
||||
HeatmapChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
ScatterChart
|
||||
} from "echarts/charts";
|
||||
import {
|
||||
AriaComponent,
|
||||
AxisPointerComponent,
|
||||
CalendarComponent,
|
||||
DataZoomComponent,
|
||||
DatasetComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
MarkAreaComponent,
|
||||
MarkLineComponent,
|
||||
MarkPointComponent,
|
||||
PolarComponent,
|
||||
RadarComponent,
|
||||
TitleComponent,
|
||||
ToolboxComponent,
|
||||
TooltipComponent,
|
||||
TransformComponent,
|
||||
VisualMapComponent
|
||||
} from "echarts/components";
|
||||
import { LabelLayout } from "echarts/features";
|
||||
import { SVGRenderer } from "echarts/renderers";
|
||||
import type {
|
||||
EChartsEngine,
|
||||
EChartsInstanceLike
|
||||
} from "./types.js";
|
||||
|
||||
echarts.use([
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
ScatterChart,
|
||||
RadarChart,
|
||||
HeatmapChart,
|
||||
BoxplotChart,
|
||||
CandlestickChart,
|
||||
GridComponent,
|
||||
PolarComponent,
|
||||
RadarComponent,
|
||||
CalendarComponent,
|
||||
TitleComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
AxisPointerComponent,
|
||||
ToolboxComponent,
|
||||
DatasetComponent,
|
||||
TransformComponent,
|
||||
DataZoomComponent,
|
||||
VisualMapComponent,
|
||||
MarkPointComponent,
|
||||
MarkLineComponent,
|
||||
MarkAreaComponent,
|
||||
AriaComponent,
|
||||
LabelLayout,
|
||||
SVGRenderer
|
||||
]);
|
||||
|
||||
export const defaultEChartsEngine: EChartsEngine = {
|
||||
init(container, theme, options) {
|
||||
return echarts.init(
|
||||
container,
|
||||
theme,
|
||||
options
|
||||
) as unknown as EChartsInstanceLike;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { echartsDomContract } from "../dom-contract.js";
|
||||
import { cloneSafeEChartsSvg } from "./resource-policy.js";
|
||||
import type { EChartsOutputMode } from "./types.js";
|
||||
|
||||
function svgDataUrl(serialized: string) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`;
|
||||
}
|
||||
|
||||
export function freezeEChartsSvg(
|
||||
host: HTMLElement,
|
||||
source: SVGSVGElement,
|
||||
outputMode: Exclude<EChartsOutputMode, "interactive">,
|
||||
label: string
|
||||
) {
|
||||
const svg = cloneSafeEChartsSvg(source);
|
||||
svg.removeAttribute("style");
|
||||
svg.setAttribute("role", "img");
|
||||
svg.setAttribute("aria-label", label);
|
||||
svg.style.width = "100%";
|
||||
svg.style.height = "100%";
|
||||
svg.style.display = "block";
|
||||
|
||||
if (outputMode === "inline-svg") {
|
||||
return svg;
|
||||
}
|
||||
|
||||
const image = host.ownerDocument.createElement("img");
|
||||
image.className = `${echartsDomContract.figureClass}-svg-image`;
|
||||
image.alt = label;
|
||||
image.src = svgDataUrl(new XMLSerializer().serializeToString(svg));
|
||||
image.style.width = "100%";
|
||||
image.style.height = "100%";
|
||||
image.style.objectFit = "contain";
|
||||
image.style.display = "block";
|
||||
return image;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { echartsDomContract } from "../dom-contract.js";
|
||||
import {
|
||||
disposeEChartsBlock,
|
||||
renderEChartsBlock
|
||||
} from "./render-chart.js";
|
||||
import type {
|
||||
EChartsBlockRenderOutcome,
|
||||
RenderEChartsBlocksOptions
|
||||
} from "./types.js";
|
||||
|
||||
export async function renderEChartsBlocks(
|
||||
root: ParentNode,
|
||||
options: RenderEChartsBlocksOptions = {}
|
||||
) {
|
||||
const elements = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
`.${echartsDomContract.figureClass}[${echartsDomContract.pendingAttribute}]`
|
||||
)
|
||||
);
|
||||
const outcomes = new Array<EChartsBlockRenderOutcome>(
|
||||
elements.length
|
||||
);
|
||||
const concurrency = Math.max(
|
||||
1,
|
||||
Math.min(options.concurrency ?? 2, elements.length || 1)
|
||||
);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < elements.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
const element = elements[index];
|
||||
if (!element) {
|
||||
continue;
|
||||
}
|
||||
const outcome = await renderEChartsBlock(element, options);
|
||||
outcomes[index] = outcome;
|
||||
if (!outcome.success && outcome.error) {
|
||||
options.onError?.(outcome.error, element, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: concurrency }, () => worker())
|
||||
);
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
export function disposeEChartsBlocks(root: ParentNode) {
|
||||
const elements = root.querySelectorAll<HTMLElement>(
|
||||
`.${echartsDomContract.figureClass}`
|
||||
);
|
||||
for (const element of elements) {
|
||||
disposeEChartsBlock(element);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { echartsDomContract } from "../dom-contract.js";
|
||||
import {
|
||||
DEFAULT_ECHARTS_FENCE_HEIGHT,
|
||||
EChartsFenceError,
|
||||
echartsFenceSpecSchema,
|
||||
type NormalizedEChartsFenceSpec
|
||||
} from "../protocol.js";
|
||||
import {
|
||||
normalizeAndValidateEChartsOption
|
||||
} from "../security-policy.js";
|
||||
import {
|
||||
validateAndApplyEChartsOptionDefaults
|
||||
} from "../semantic-validator.js";
|
||||
import { defaultEChartsEngine } from "./engine.js";
|
||||
import { freezeEChartsSvg } from "./freeze-svg.js";
|
||||
import type {
|
||||
EChartsBlockRenderOutcome,
|
||||
EChartsInstanceLike,
|
||||
EChartsOutputMode,
|
||||
RenderEChartsBlockOptions
|
||||
} from "./types.js";
|
||||
|
||||
interface ActiveChart {
|
||||
instance: EChartsInstanceLike;
|
||||
observer?: ResizeObserver;
|
||||
}
|
||||
|
||||
const activeCharts = new WeakMap<HTMLElement, ActiveChart>();
|
||||
|
||||
function abortError() {
|
||||
return new DOMException("ECharts 渲染已取消", "AbortError");
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined) {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDefinition(value: unknown): NormalizedEChartsFenceSpec {
|
||||
const protocolResult = echartsFenceSpecSchema.safeParse(value);
|
||||
if (!protocolResult.success) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_PROTOCOL",
|
||||
"ECharts 占位节点中的协议无效"
|
||||
);
|
||||
}
|
||||
const normalized: NormalizedEChartsFenceSpec = {
|
||||
version: protocolResult.data.version,
|
||||
theme: protocolResult.data.theme,
|
||||
option: validateAndApplyEChartsOptionDefaults(
|
||||
normalizeAndValidateEChartsOption(
|
||||
protocolResult.data.option
|
||||
)
|
||||
)
|
||||
};
|
||||
if (protocolResult.data.height !== undefined) {
|
||||
normalized.height = protocolResult.data.height;
|
||||
} else if (protocolResult.data.aspectRatio === undefined) {
|
||||
normalized.height = DEFAULT_ECHARTS_FENCE_HEIGHT;
|
||||
}
|
||||
if (protocolResult.data.aspectRatio !== undefined) {
|
||||
normalized.aspectRatio = protocolResult.data.aspectRatio;
|
||||
}
|
||||
if (protocolResult.data.caption !== undefined) {
|
||||
normalized.caption = protocolResult.data.caption;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readDefinition(element: HTMLElement) {
|
||||
const definition = element.querySelector<HTMLElement>(
|
||||
`.${echartsDomContract.definitionClass}`
|
||||
);
|
||||
if (!definition) {
|
||||
throw new Error("缺少 ECharts 配置节点");
|
||||
}
|
||||
try {
|
||||
return normalizeDefinition(
|
||||
JSON.parse(definition.textContent ?? "")
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error("ECharts 配置节点无法解析");
|
||||
}
|
||||
}
|
||||
|
||||
function applyChartSize(
|
||||
host: HTMLElement,
|
||||
definition: NormalizedEChartsFenceSpec
|
||||
) {
|
||||
host.style.width = "100%";
|
||||
if (definition.height !== undefined) {
|
||||
host.style.height = definition.height;
|
||||
host.style.removeProperty("aspect-ratio");
|
||||
return;
|
||||
}
|
||||
host.style.removeProperty("height");
|
||||
host.style.aspectRatio = String(definition.aspectRatio);
|
||||
}
|
||||
|
||||
function resolveDefaultTheme(
|
||||
theme: NormalizedEChartsFenceSpec["theme"]
|
||||
) {
|
||||
return theme === "dark" ? "dark" : undefined;
|
||||
}
|
||||
|
||||
function nextFrame(element: HTMLElement) {
|
||||
const requestFrame =
|
||||
element.ownerDocument.defaultView?.requestAnimationFrame;
|
||||
if (!requestFrame) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
requestFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRender(
|
||||
instance: EChartsInstanceLike,
|
||||
host: HTMLElement,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | undefined,
|
||||
setOption: () => void
|
||||
) {
|
||||
let finished = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortHandler: (() => void) | undefined;
|
||||
let finishedHandler: (() => void) | undefined;
|
||||
|
||||
const completion = new Promise<void>((resolve, reject) => {
|
||||
const finish = () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
resolve();
|
||||
};
|
||||
const fail = (error: Error) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
reject(error);
|
||||
};
|
||||
finishedHandler = finish;
|
||||
instance.on("finished", finishedHandler);
|
||||
timer = setTimeout(
|
||||
() => fail(new Error(`ECharts 渲染超过 ${timeoutMs}ms`)),
|
||||
timeoutMs
|
||||
);
|
||||
abortHandler = () => fail(abortError());
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
try {
|
||||
setOption();
|
||||
} catch (error) {
|
||||
fail(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("ECharts setOption 失败")
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
completion,
|
||||
(async () => {
|
||||
await nextFrame(host);
|
||||
await nextFrame(host);
|
||||
if (!host.querySelector("svg")) {
|
||||
await completion;
|
||||
}
|
||||
})()
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (abortHandler) {
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
if (finishedHandler) {
|
||||
instance.off("finished", finishedHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function storeInteractiveChart(
|
||||
element: HTMLElement,
|
||||
host: HTMLElement,
|
||||
instance: EChartsInstanceLike
|
||||
) {
|
||||
let observer: ResizeObserver | undefined;
|
||||
const ResizeObserverConstructor =
|
||||
element.ownerDocument.defaultView?.ResizeObserver;
|
||||
if (ResizeObserverConstructor) {
|
||||
observer = new ResizeObserverConstructor(() => instance.resize());
|
||||
observer.observe(host);
|
||||
}
|
||||
const active: ActiveChart = { instance };
|
||||
if (observer !== undefined) {
|
||||
active.observer = observer;
|
||||
}
|
||||
activeCharts.set(element, active);
|
||||
}
|
||||
|
||||
function showRenderError(element: HTMLElement, error: Error) {
|
||||
element.removeAttribute(echartsDomContract.pendingAttribute);
|
||||
element.classList.add(echartsDomContract.errorClass);
|
||||
const host = element.querySelector<HTMLElement>(
|
||||
`.${echartsDomContract.hostClass}`
|
||||
);
|
||||
if (host) {
|
||||
host.setAttribute("role", "alert");
|
||||
host.textContent = `ECharts 图表渲染失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeEChartsBlock(element: HTMLElement) {
|
||||
const active = activeCharts.get(element);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active.observer?.disconnect();
|
||||
active.instance.dispose();
|
||||
activeCharts.delete(element);
|
||||
}
|
||||
|
||||
export async function renderEChartsBlock(
|
||||
element: HTMLElement,
|
||||
options: RenderEChartsBlockOptions = {}
|
||||
): Promise<EChartsBlockRenderOutcome> {
|
||||
const startedAt = performance.now();
|
||||
const outputMode: EChartsOutputMode =
|
||||
options.outputMode ?? "inline-svg";
|
||||
let instance: EChartsInstanceLike | undefined;
|
||||
|
||||
try {
|
||||
throwIfAborted(options.signal);
|
||||
disposeEChartsBlock(element);
|
||||
|
||||
const definition = readDefinition(element);
|
||||
const host = element.querySelector<HTMLElement>(
|
||||
`.${echartsDomContract.hostClass}`
|
||||
);
|
||||
if (!host) {
|
||||
throw new Error("缺少 ECharts 渲染容器");
|
||||
}
|
||||
|
||||
applyChartSize(host, definition);
|
||||
const fonts = element.ownerDocument.fonts;
|
||||
if (fonts) {
|
||||
await fonts.ready;
|
||||
}
|
||||
throwIfAborted(options.signal);
|
||||
|
||||
const engine = options.engine ?? defaultEChartsEngine;
|
||||
const theme =
|
||||
options.resolveTheme?.(definition.theme, element) ??
|
||||
resolveDefaultTheme(definition.theme);
|
||||
instance = engine.init(host, theme, {
|
||||
renderer: "svg",
|
||||
useDirtyRect: false
|
||||
});
|
||||
|
||||
const shouldAnimate =
|
||||
options.animation ??
|
||||
(outputMode === "interactive"
|
||||
? definition.option.animation
|
||||
: false);
|
||||
const option = {
|
||||
...definition.option,
|
||||
animation:
|
||||
typeof shouldAnimate === "boolean" ? shouldAnimate : false
|
||||
};
|
||||
await waitForRender(
|
||||
instance,
|
||||
host,
|
||||
options.timeoutMs ?? 5_000,
|
||||
options.signal,
|
||||
() => instance?.setOption(option, { notMerge: true })
|
||||
);
|
||||
throwIfAborted(options.signal);
|
||||
|
||||
if (outputMode === "interactive") {
|
||||
storeInteractiveChart(element, host, instance);
|
||||
element.removeAttribute(echartsDomContract.pendingAttribute);
|
||||
element.dataset.echartsRendered = "interactive";
|
||||
return {
|
||||
element,
|
||||
outputMode,
|
||||
success: true,
|
||||
durationMs: performance.now() - startedAt,
|
||||
instance
|
||||
};
|
||||
}
|
||||
|
||||
const svg = host.querySelector<SVGSVGElement>("svg");
|
||||
if (!svg) {
|
||||
throw new Error("ECharts 未生成 SVG");
|
||||
}
|
||||
const frozenOutput = freezeEChartsSvg(
|
||||
host,
|
||||
svg,
|
||||
outputMode,
|
||||
definition.caption ?? "ECharts 图表"
|
||||
);
|
||||
instance.dispose();
|
||||
instance = undefined;
|
||||
host.replaceChildren(frozenOutput);
|
||||
element.removeAttribute(echartsDomContract.pendingAttribute);
|
||||
element.dataset.echartsRendered = outputMode;
|
||||
return {
|
||||
element,
|
||||
outputMode,
|
||||
success: true,
|
||||
durationMs: performance.now() - startedAt
|
||||
};
|
||||
} catch (error) {
|
||||
instance?.dispose();
|
||||
const normalizedError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("未知 ECharts 渲染错误");
|
||||
showRenderError(element, normalizedError);
|
||||
return {
|
||||
element,
|
||||
outputMode,
|
||||
success: false,
|
||||
durationMs: performance.now() - startedAt,
|
||||
error: normalizedError
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const forbiddenSvgElements = [
|
||||
"script",
|
||||
"foreignObject",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed"
|
||||
] as const;
|
||||
|
||||
const urlFunctionPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/giu;
|
||||
|
||||
function assertSafeUrlValue(value: string, context: string) {
|
||||
for (const match of value.matchAll(urlFunctionPattern)) {
|
||||
const target = match[2]?.trim() ?? "";
|
||||
if (!target.startsWith("#")) {
|
||||
throw new Error(`${context} 包含外部 SVG 资源`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeHref(value: string, context: string) {
|
||||
const normalized = value.trim();
|
||||
if (normalized && !normalized.startsWith("#")) {
|
||||
throw new Error(`${context} 包含外部 SVG 链接`);
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneSafeEChartsSvg(
|
||||
source: SVGSVGElement
|
||||
): SVGSVGElement {
|
||||
const clone = source.cloneNode(true) as SVGSVGElement;
|
||||
if (clone.querySelector(forbiddenSvgElements.join(","))) {
|
||||
throw new Error("ECharts SVG 包含禁止元素");
|
||||
}
|
||||
|
||||
for (const element of [clone, ...clone.querySelectorAll("*")]) {
|
||||
for (const attribute of Array.from(element.attributes)) {
|
||||
const name = attribute.name.toLowerCase();
|
||||
if (name.startsWith("on")) {
|
||||
element.removeAttribute(attribute.name);
|
||||
continue;
|
||||
}
|
||||
if (name === "href" || name === "xlink:href") {
|
||||
assertSafeHref(attribute.value, attribute.name);
|
||||
continue;
|
||||
}
|
||||
assertSafeUrlValue(attribute.value, attribute.name);
|
||||
}
|
||||
if (
|
||||
element.tagName.toLowerCase() === "style" &&
|
||||
/@import|url\(\s*(?!['"]?#)/iu.test(element.textContent ?? "")
|
||||
) {
|
||||
throw new Error("ECharts SVG 样式包含外部资源");
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
NormalizedEChartsFenceSpec
|
||||
} from "../protocol.js";
|
||||
|
||||
export type EChartsOutputMode =
|
||||
| "interactive"
|
||||
| "inline-svg"
|
||||
| "svg-image";
|
||||
|
||||
export interface EChartsInstanceLike {
|
||||
setOption(option: unknown, options?: { notMerge?: boolean }): void;
|
||||
on(eventName: string, handler: () => void): void;
|
||||
off(eventName: string, handler: () => void): void;
|
||||
resize(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface EChartsEngine {
|
||||
init(
|
||||
container: HTMLElement,
|
||||
theme: string | object | null | undefined,
|
||||
options: {
|
||||
renderer: "svg";
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
devicePixelRatio?: number;
|
||||
useDirtyRect?: boolean;
|
||||
}
|
||||
): EChartsInstanceLike;
|
||||
}
|
||||
|
||||
export interface RenderEChartsBlockOptions {
|
||||
engine?: EChartsEngine;
|
||||
outputMode?: EChartsOutputMode;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
animation?: boolean;
|
||||
resolveTheme?: (
|
||||
theme: NormalizedEChartsFenceSpec["theme"],
|
||||
element: HTMLElement
|
||||
) => string | object | null | undefined;
|
||||
}
|
||||
|
||||
export interface RenderEChartsBlocksOptions
|
||||
extends RenderEChartsBlockOptions {
|
||||
concurrency?: number;
|
||||
onError?: (
|
||||
error: Error,
|
||||
element: HTMLElement,
|
||||
index: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface EChartsBlockRenderOutcome {
|
||||
element: HTMLElement;
|
||||
outputMode: EChartsOutputMode;
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
error?: Error;
|
||||
instance?: EChartsInstanceLike;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const echartsDomContract = {
|
||||
figureClass: "md-echarts",
|
||||
hostClass: "md-echarts-host",
|
||||
definitionClass: "md-echarts-definition",
|
||||
errorClass: "md-echarts-error",
|
||||
pendingAttribute: "data-echarts-pending",
|
||||
protocolAttribute: "data-echarts-protocol"
|
||||
} as const;
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./dom-contract.js";
|
||||
export * from "./markdown-it-plugin.js";
|
||||
export * from "./parse-yaml.js";
|
||||
export * from "./protocol.js";
|
||||
export * from "./security-policy.js";
|
||||
export * from "./semantic-validator.js";
|
||||
@@ -0,0 +1,140 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import type { Options as MarkdownItOptions } from "markdown-it";
|
||||
import type Renderer from "markdown-it/lib/renderer.mjs";
|
||||
import type Token from "markdown-it/lib/token.mjs";
|
||||
import { echartsDomContract } from "./dom-contract.js";
|
||||
import {
|
||||
ECHARTS_FENCE_VERSION,
|
||||
EChartsFenceError,
|
||||
type NormalizedEChartsFenceSpec
|
||||
} from "./protocol.js";
|
||||
import {
|
||||
parseEChartsFence,
|
||||
type ParseEChartsFenceOptions
|
||||
} from "./parse-yaml.js";
|
||||
|
||||
export interface MarkdownItEChartsErrorContext {
|
||||
source: string;
|
||||
tokenIndex: number;
|
||||
environment: unknown;
|
||||
}
|
||||
|
||||
export interface MarkdownItEChartsOptions {
|
||||
parse?: ParseEChartsFenceOptions;
|
||||
onError?: (
|
||||
error: EChartsFenceError,
|
||||
context: MarkdownItEChartsErrorContext
|
||||
) => void;
|
||||
}
|
||||
|
||||
type FenceRenderer = (
|
||||
tokens: Token[],
|
||||
index: number,
|
||||
options: MarkdownItOptions,
|
||||
environment: unknown,
|
||||
renderer: Renderer
|
||||
) => string;
|
||||
|
||||
function renderError(markdown: MarkdownIt, error: EChartsFenceError) {
|
||||
const message = markdown.utils.escapeHtml(error.message);
|
||||
return `<div class="${echartsDomContract.errorClass}" role="alert">ECharts 图表配置无效:${message}</div>\n`;
|
||||
}
|
||||
|
||||
function renderDefinition(
|
||||
markdown: MarkdownIt,
|
||||
definition: NormalizedEChartsFenceSpec
|
||||
) {
|
||||
const serialized = markdown.utils.escapeHtml(
|
||||
JSON.stringify(definition)
|
||||
);
|
||||
const caption =
|
||||
definition.caption === undefined
|
||||
? ""
|
||||
: `<figcaption>${markdown.utils.escapeHtml(definition.caption)}</figcaption>`;
|
||||
const label = markdown.utils.escapeHtml(
|
||||
definition.caption ?? "ECharts 图表"
|
||||
);
|
||||
|
||||
return [
|
||||
`<figure class="${echartsDomContract.figureClass}"`,
|
||||
` ${echartsDomContract.pendingAttribute}="true"`,
|
||||
` ${echartsDomContract.protocolAttribute}="${ECHARTS_FENCE_VERSION}">`,
|
||||
`<div class="${echartsDomContract.hostClass}" role="img" aria-label="${label}"></div>`,
|
||||
`<pre class="${echartsDomContract.definitionClass}" hidden="hidden">${serialized}</pre>`,
|
||||
caption,
|
||||
"</figure>\n"
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderDefaultFence(
|
||||
defaultFenceRenderer: FenceRenderer | undefined,
|
||||
tokens: Token[],
|
||||
index: number,
|
||||
options: MarkdownItOptions,
|
||||
environment: unknown,
|
||||
renderer: Renderer
|
||||
) {
|
||||
if (defaultFenceRenderer) {
|
||||
return defaultFenceRenderer(
|
||||
tokens,
|
||||
index,
|
||||
options,
|
||||
environment,
|
||||
renderer
|
||||
);
|
||||
}
|
||||
return renderer.renderToken(tokens, index, options);
|
||||
}
|
||||
|
||||
export function markdownItECharts(
|
||||
markdown: MarkdownIt,
|
||||
pluginOptions: MarkdownItEChartsOptions = {}
|
||||
) {
|
||||
const defaultFenceRenderer = markdown.renderer.rules.fence;
|
||||
|
||||
markdown.renderer.rules.fence = (
|
||||
tokens,
|
||||
index,
|
||||
options,
|
||||
environment,
|
||||
renderer
|
||||
) => {
|
||||
const token = tokens[index];
|
||||
const language = token?.info
|
||||
.trim()
|
||||
.split(/\s+/u)[0]
|
||||
?.toLowerCase();
|
||||
if (!token || language !== "echarts") {
|
||||
return renderDefaultFence(
|
||||
defaultFenceRenderer,
|
||||
tokens,
|
||||
index,
|
||||
options,
|
||||
environment,
|
||||
renderer
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return renderDefinition(
|
||||
markdown,
|
||||
parseEChartsFence(token.content, pluginOptions.parse)
|
||||
);
|
||||
} catch (error) {
|
||||
const normalizedError =
|
||||
error instanceof EChartsFenceError
|
||||
? error
|
||||
: new EChartsFenceError(
|
||||
"INVALID_YAML",
|
||||
error instanceof Error ? error.message : "未知错误",
|
||||
{ cause: error }
|
||||
);
|
||||
pluginOptions.onError?.(normalizedError, {
|
||||
source: token.content,
|
||||
tokenIndex: index,
|
||||
environment
|
||||
});
|
||||
return renderError(markdown, normalizedError);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
isAlias,
|
||||
isMap,
|
||||
isScalar,
|
||||
isSeq,
|
||||
parseDocument
|
||||
} from "yaml";
|
||||
import type { Pair, ParsedNode } from "yaml";
|
||||
import {
|
||||
DEFAULT_ECHARTS_FENCE_HEIGHT,
|
||||
EChartsFenceError,
|
||||
echartsFenceSpecSchema,
|
||||
type NormalizedEChartsFenceSpec
|
||||
} from "./protocol.js";
|
||||
import {
|
||||
defaultEChartsSecurityLimits,
|
||||
normalizeAndValidateEChartsOption,
|
||||
type EChartsSecurityLimits
|
||||
} from "./security-policy.js";
|
||||
import {
|
||||
validateAndApplyEChartsOptionDefaults
|
||||
} from "./semantic-validator.js";
|
||||
|
||||
export interface ParseEChartsFenceOptions {
|
||||
maxSourceBytes?: number;
|
||||
securityLimits?: EChartsSecurityLimits;
|
||||
}
|
||||
|
||||
export const defaultMaxEChartsFenceSourceBytes = 512 * 1024;
|
||||
|
||||
function inspectYamlNode(node: ParsedNode | null | undefined): void {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (isAlias(node)) {
|
||||
throw new EChartsFenceError(
|
||||
"YAML_ALIAS_NOT_ALLOWED",
|
||||
"ECharts YAML 不允许使用别名"
|
||||
);
|
||||
}
|
||||
if (node.anchor !== undefined) {
|
||||
throw new EChartsFenceError(
|
||||
"YAML_ANCHOR_NOT_ALLOWED",
|
||||
"ECharts YAML 不允许使用锚点"
|
||||
);
|
||||
}
|
||||
if (node.tag !== undefined) {
|
||||
throw new EChartsFenceError(
|
||||
"YAML_TAG_NOT_ALLOWED",
|
||||
"ECharts YAML 不允许使用显式标签"
|
||||
);
|
||||
}
|
||||
if (isMap(node)) {
|
||||
for (const pair of node.items as Pair<ParsedNode, ParsedNode>[]) {
|
||||
inspectYamlNode(pair.key);
|
||||
inspectYamlNode(pair.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isSeq(node)) {
|
||||
for (const item of node.items) {
|
||||
inspectYamlNode(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isScalar(node)) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_YAML",
|
||||
"ECharts YAML 包含无法识别的节点"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatZodError(error: {
|
||||
issues: { path: PropertyKey[]; message: string }[];
|
||||
}) {
|
||||
return error.issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.join(".");
|
||||
return path ? `${path}:${issue.message}` : issue.message;
|
||||
})
|
||||
.join(";");
|
||||
}
|
||||
|
||||
export function parseEChartsFence(
|
||||
source: string,
|
||||
options: ParseEChartsFenceOptions = {}
|
||||
): NormalizedEChartsFenceSpec {
|
||||
const maxSourceBytes =
|
||||
options.maxSourceBytes ?? defaultMaxEChartsFenceSourceBytes;
|
||||
if (new TextEncoder().encode(source).byteLength > maxSourceBytes) {
|
||||
throw new EChartsFenceError(
|
||||
"SOURCE_TOO_LARGE",
|
||||
`ECharts YAML 不能超过 ${maxSourceBytes} 字节`
|
||||
);
|
||||
}
|
||||
|
||||
const document = parseDocument(source, {
|
||||
schema: "core",
|
||||
strict: true,
|
||||
uniqueKeys: true,
|
||||
prettyErrors: true
|
||||
});
|
||||
if (document.errors.length > 0) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_YAML",
|
||||
document.errors.map((error) => error.message).join(";")
|
||||
);
|
||||
}
|
||||
|
||||
inspectYamlNode(document.contents);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = document.toJS({
|
||||
maxAliasCount: 0,
|
||||
mapAsMap: false
|
||||
});
|
||||
} catch (error) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_YAML",
|
||||
error instanceof Error ? error.message : "ECharts YAML 解析失败",
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
const protocolResult = echartsFenceSpecSchema.safeParse(parsed);
|
||||
if (!protocolResult.success) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_PROTOCOL",
|
||||
formatZodError(protocolResult.error)
|
||||
);
|
||||
}
|
||||
|
||||
const securityLimits =
|
||||
options.securityLimits ?? defaultEChartsSecurityLimits;
|
||||
const option = validateAndApplyEChartsOptionDefaults(
|
||||
normalizeAndValidateEChartsOption(
|
||||
protocolResult.data.option,
|
||||
securityLimits
|
||||
)
|
||||
);
|
||||
const normalized: NormalizedEChartsFenceSpec = {
|
||||
version: protocolResult.data.version,
|
||||
theme: protocolResult.data.theme,
|
||||
option
|
||||
};
|
||||
if (protocolResult.data.height !== undefined) {
|
||||
normalized.height = protocolResult.data.height;
|
||||
} else if (protocolResult.data.aspectRatio === undefined) {
|
||||
normalized.height = DEFAULT_ECHARTS_FENCE_HEIGHT;
|
||||
}
|
||||
if (protocolResult.data.aspectRatio !== undefined) {
|
||||
normalized.aspectRatio = protocolResult.data.aspectRatio;
|
||||
}
|
||||
if (protocolResult.data.caption !== undefined) {
|
||||
normalized.caption = protocolResult.data.caption;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ECHARTS_FENCE_VERSION = 1;
|
||||
export const DEFAULT_ECHARTS_FENCE_HEIGHT = "80mm";
|
||||
|
||||
export const supportedEChartsSeriesTypes = [
|
||||
"line",
|
||||
"bar",
|
||||
"pie",
|
||||
"scatter",
|
||||
"radar",
|
||||
"heatmap",
|
||||
"boxplot",
|
||||
"candlestick"
|
||||
] as const;
|
||||
|
||||
export type SupportedEChartsSeriesType =
|
||||
(typeof supportedEChartsSeriesTypes)[number];
|
||||
|
||||
export const echartsFenceThemeSchema = z.enum([
|
||||
"document",
|
||||
"light",
|
||||
"dark"
|
||||
]);
|
||||
|
||||
const positiveLengthPattern =
|
||||
/^(?:0*[1-9]\d*(?:\.\d+)?|0*\.\d*[1-9]\d*)(?:mm|cm|in|px)$/u;
|
||||
|
||||
export const echartsFenceSpecSchema = z
|
||||
.object({
|
||||
version: z.literal(ECHARTS_FENCE_VERSION).default(
|
||||
ECHARTS_FENCE_VERSION
|
||||
),
|
||||
height: z
|
||||
.string()
|
||||
.regex(
|
||||
positiveLengthPattern,
|
||||
"height 必须是大于零的 mm、cm、in 或 px 长度"
|
||||
)
|
||||
.optional(),
|
||||
aspectRatio: z.number().positive().max(10).optional(),
|
||||
theme: echartsFenceThemeSchema.default("document"),
|
||||
caption: z.string().trim().min(1).max(500).optional(),
|
||||
option: z.record(z.string(), z.unknown())
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type EChartsFenceSpec = z.infer<typeof echartsFenceSpecSchema>;
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue =
|
||||
| JsonPrimitive
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
export interface NormalizedEChartsFenceSpec {
|
||||
version: typeof ECHARTS_FENCE_VERSION;
|
||||
height?: string;
|
||||
aspectRatio?: number;
|
||||
theme: z.infer<typeof echartsFenceThemeSchema>;
|
||||
caption?: string;
|
||||
option: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export const echartsFenceErrorCodes = [
|
||||
"SOURCE_TOO_LARGE",
|
||||
"INVALID_YAML",
|
||||
"YAML_ALIAS_NOT_ALLOWED",
|
||||
"YAML_ANCHOR_NOT_ALLOWED",
|
||||
"YAML_TAG_NOT_ALLOWED",
|
||||
"INVALID_PROTOCOL",
|
||||
"INVALID_OPTION",
|
||||
"UNSAFE_OPTION",
|
||||
"LIMIT_EXCEEDED"
|
||||
] as const;
|
||||
|
||||
export type EChartsFenceErrorCode =
|
||||
(typeof echartsFenceErrorCodes)[number];
|
||||
|
||||
export class EChartsFenceError extends Error {
|
||||
constructor(
|
||||
readonly code: EChartsFenceErrorCode,
|
||||
message: string,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "EChartsFenceError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
EChartsFenceError,
|
||||
supportedEChartsSeriesTypes,
|
||||
type JsonValue
|
||||
} from "./protocol.js";
|
||||
|
||||
export interface EChartsSecurityLimits {
|
||||
maxDepth: number;
|
||||
maxNodes: number;
|
||||
maxObjectKeys: number;
|
||||
maxArrayLength: number;
|
||||
maxStringLength: number;
|
||||
maxSeries: number;
|
||||
}
|
||||
|
||||
export const defaultEChartsSecurityLimits: EChartsSecurityLimits = {
|
||||
maxDepth: 32,
|
||||
maxNodes: 50_000,
|
||||
maxObjectKeys: 1_000,
|
||||
maxArrayLength: 20_000,
|
||||
maxStringLength: 100_000,
|
||||
maxSeries: 32
|
||||
};
|
||||
|
||||
const forbiddenPropertyNames = new Set([
|
||||
"__proto__",
|
||||
"prototype",
|
||||
"constructor",
|
||||
"renderItem",
|
||||
"optionToContent",
|
||||
"contentToOption",
|
||||
"onclick",
|
||||
"onClick",
|
||||
"link",
|
||||
"sublink"
|
||||
]);
|
||||
|
||||
const allowedTopLevelOptionKeys = new Set([
|
||||
"aria",
|
||||
"animation",
|
||||
"animationDelay",
|
||||
"animationDelayUpdate",
|
||||
"animationDuration",
|
||||
"animationDurationUpdate",
|
||||
"animationEasing",
|
||||
"animationEasingUpdate",
|
||||
"angleAxis",
|
||||
"axisPointer",
|
||||
"backgroundColor",
|
||||
"calendar",
|
||||
"color",
|
||||
"darkMode",
|
||||
"dataZoom",
|
||||
"dataset",
|
||||
"grid",
|
||||
"legend",
|
||||
"polar",
|
||||
"radar",
|
||||
"radiusAxis",
|
||||
"series",
|
||||
"textStyle",
|
||||
"title",
|
||||
"toolbox",
|
||||
"tooltip",
|
||||
"visualMap",
|
||||
"xAxis",
|
||||
"yAxis"
|
||||
]);
|
||||
|
||||
const allowedToolboxFeatures = new Set([
|
||||
"brush",
|
||||
"dataZoom",
|
||||
"magicType",
|
||||
"restore",
|
||||
"saveAsImage"
|
||||
]);
|
||||
|
||||
const unsafeStringPattern =
|
||||
/^(?:javascript|file|https?):|^\/\/|^data:|url\s*\(|image:\/\//iu;
|
||||
|
||||
interface TraversalState {
|
||||
nodes: number;
|
||||
}
|
||||
|
||||
function optionPath(path: readonly (string | number)[]) {
|
||||
return path.length === 0 ? "option" : `option.${path.join(".")}`;
|
||||
}
|
||||
|
||||
function assertSafeKey(key: string, path: readonly (string | number)[]) {
|
||||
if (forbiddenPropertyNames.has(key)) {
|
||||
throw new EChartsFenceError(
|
||||
"UNSAFE_OPTION",
|
||||
`${optionPath(path)} 包含禁止属性 ${key}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeValue(
|
||||
value: unknown,
|
||||
path: readonly (string | number)[],
|
||||
depth: number,
|
||||
state: TraversalState,
|
||||
limits: EChartsSecurityLimits
|
||||
): JsonValue {
|
||||
state.nodes += 1;
|
||||
if (state.nodes > limits.maxNodes) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`ECharts option 节点数量不能超过 ${limits.maxNodes}`
|
||||
);
|
||||
}
|
||||
|
||||
if (depth > limits.maxDepth) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`ECharts option 嵌套深度不能超过 ${limits.maxDepth}`
|
||||
);
|
||||
}
|
||||
|
||||
if (value === null || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_OPTION",
|
||||
`${optionPath(path)} 必须是有限数值`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (value.length > limits.maxStringLength) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${optionPath(path)} 字符串长度不能超过 ${limits.maxStringLength}`
|
||||
);
|
||||
}
|
||||
if (unsafeStringPattern.test(value.trim())) {
|
||||
throw new EChartsFenceError(
|
||||
"UNSAFE_OPTION",
|
||||
`${optionPath(path)} 包含被禁止的外部资源或危险 URL`
|
||||
);
|
||||
}
|
||||
if (
|
||||
path.at(-1) === "formatter" &&
|
||||
(value.includes("<") || value.includes(">"))
|
||||
) {
|
||||
throw new EChartsFenceError(
|
||||
"UNSAFE_OPTION",
|
||||
`${optionPath(path)} 只允许不含 HTML 的模板字符串`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > limits.maxArrayLength) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${optionPath(path)} 数组长度不能超过 ${limits.maxArrayLength}`
|
||||
);
|
||||
}
|
||||
return value.map((item, index) =>
|
||||
normalizeValue(item, [...path, index], depth + 1, state, limits)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_OPTION",
|
||||
`${optionPath(path)} 只能包含 JSON 可序列化值`
|
||||
);
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_OPTION",
|
||||
`${optionPath(path)} 必须是普通对象`
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > limits.maxObjectKeys) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${optionPath(path)} 属性数量不能超过 ${limits.maxObjectKeys}`
|
||||
);
|
||||
}
|
||||
|
||||
const normalized: Record<string, JsonValue> = {};
|
||||
for (const [key, child] of entries) {
|
||||
assertSafeKey(key, [...path, key]);
|
||||
normalized[key] = normalizeValue(
|
||||
child,
|
||||
[...path, key],
|
||||
depth + 1,
|
||||
state,
|
||||
limits
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertSupportedTopLevelKeys(option: Record<string, JsonValue>) {
|
||||
for (const key of Object.keys(option)) {
|
||||
if (!allowedTopLevelOptionKeys.has(key)) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_OPTION",
|
||||
`option.${key} 暂不在首版支持范围内`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSupportedSeries(
|
||||
option: Record<string, JsonValue>,
|
||||
limits: EChartsSecurityLimits
|
||||
) {
|
||||
const series = option.series;
|
||||
if (!Array.isArray(series) || series.length === 0) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_OPTION",
|
||||
"option.series 必须是非空数组"
|
||||
);
|
||||
}
|
||||
if (series.length > limits.maxSeries) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`option.series 数量不能超过 ${limits.maxSeries}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const [index, item] of series.entries()) {
|
||||
if (
|
||||
item === null ||
|
||||
Array.isArray(item) ||
|
||||
typeof item !== "object"
|
||||
) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_OPTION",
|
||||
`option.series.${index} 必须是对象`
|
||||
);
|
||||
}
|
||||
const type = item.type;
|
||||
if (
|
||||
typeof type !== "string" ||
|
||||
!supportedEChartsSeriesTypes.some(
|
||||
(supportedType) => supportedType === type
|
||||
)
|
||||
) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_OPTION",
|
||||
`option.series.${index}.type 暂不支持 ${String(type)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeToolbox(option: Record<string, JsonValue>) {
|
||||
if (option.toolbox === undefined) {
|
||||
return;
|
||||
}
|
||||
const toolboxes = Array.isArray(option.toolbox)
|
||||
? option.toolbox
|
||||
: [option.toolbox];
|
||||
|
||||
for (const toolbox of toolboxes) {
|
||||
if (
|
||||
toolbox === null ||
|
||||
Array.isArray(toolbox) ||
|
||||
typeof toolbox !== "object"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const feature = toolbox.feature;
|
||||
if (
|
||||
feature === undefined ||
|
||||
feature === null ||
|
||||
Array.isArray(feature) ||
|
||||
typeof feature !== "object"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(feature)) {
|
||||
if (!allowedToolboxFeatures.has(key)) {
|
||||
throw new EChartsFenceError(
|
||||
"UNSAFE_OPTION",
|
||||
`option.toolbox.feature.${key} 不允许使用`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeTransforms(value: JsonValue, path: string[] = []) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => assertSafeTransforms(item, path));
|
||||
return;
|
||||
}
|
||||
if (value === null || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const childPath = [...path, key];
|
||||
if (key === "transform") {
|
||||
const transforms = Array.isArray(child) ? child : [child];
|
||||
for (const transform of transforms) {
|
||||
if (
|
||||
transform === null ||
|
||||
Array.isArray(transform) ||
|
||||
typeof transform !== "object" ||
|
||||
(transform.type !== "filter" && transform.type !== "sort")
|
||||
) {
|
||||
throw new EChartsFenceError(
|
||||
"UNSAFE_OPTION",
|
||||
`${optionPath(childPath)} 只允许内置 filter 或 sort`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertSafeTransforms(child, childPath);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAndValidateEChartsOption(
|
||||
value: unknown,
|
||||
limits: EChartsSecurityLimits = defaultEChartsSecurityLimits
|
||||
): Record<string, JsonValue> {
|
||||
const normalized = normalizeValue(
|
||||
value,
|
||||
[],
|
||||
0,
|
||||
{ nodes: 0 },
|
||||
limits
|
||||
);
|
||||
if (
|
||||
normalized === null ||
|
||||
Array.isArray(normalized) ||
|
||||
typeof normalized !== "object"
|
||||
) {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_OPTION",
|
||||
"option 必须是普通对象"
|
||||
);
|
||||
}
|
||||
|
||||
assertSupportedTopLevelKeys(normalized);
|
||||
assertSupportedSeries(normalized, limits);
|
||||
assertSafeToolbox(normalized);
|
||||
assertSafeTransforms(normalized);
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
EChartsFenceError,
|
||||
type JsonValue,
|
||||
type SupportedEChartsSeriesType
|
||||
} from "./protocol.js";
|
||||
|
||||
type JsonObject = Record<string, JsonValue>;
|
||||
|
||||
export interface EChartsSeriesValidationContext {
|
||||
option: JsonObject;
|
||||
series: JsonObject;
|
||||
seriesIndex: number;
|
||||
hasDataset: boolean;
|
||||
}
|
||||
|
||||
export type EChartsSeriesValidator = (
|
||||
context: EChartsSeriesValidationContext
|
||||
) => void;
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
throw new EChartsFenceError(
|
||||
"INVALID_OPTION",
|
||||
`${path} ${message}`
|
||||
);
|
||||
}
|
||||
|
||||
function isObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
!Array.isArray(value) &&
|
||||
typeof value === "object"
|
||||
);
|
||||
}
|
||||
|
||||
function asObjects(value: JsonValue | undefined): JsonObject[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(isObject);
|
||||
}
|
||||
return isObject(value) ? [value] : [];
|
||||
}
|
||||
|
||||
function isNonEmptyData(value: JsonValue | undefined) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return isObject(value) && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
function datasetHasRows(dataset: JsonObject) {
|
||||
const source = dataset.source;
|
||||
if (Array.isArray(source)) {
|
||||
if (source.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const firstRow = source[0];
|
||||
const hasHeader =
|
||||
dataset.sourceHeader === true ||
|
||||
(Array.isArray(firstRow) &&
|
||||
firstRow.length > 0 &&
|
||||
firstRow.every((value) => typeof value === "string"));
|
||||
return hasHeader ? source.length > 1 : true;
|
||||
}
|
||||
if (isObject(source)) {
|
||||
return Object.values(source).some(
|
||||
(column) => Array.isArray(column) && column.length > 0
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function datasets(option: JsonObject) {
|
||||
return asObjects(option.dataset);
|
||||
}
|
||||
|
||||
function datasetHasSource(
|
||||
option: JsonObject,
|
||||
datasetIndex: number,
|
||||
visited = new Set<number>()
|
||||
): boolean {
|
||||
const allDatasets = datasets(option);
|
||||
const dataset = allDatasets[datasetIndex];
|
||||
if (!dataset || visited.has(datasetIndex)) {
|
||||
return false;
|
||||
}
|
||||
visited.add(datasetIndex);
|
||||
|
||||
if (datasetHasRows(dataset)) {
|
||||
return true;
|
||||
}
|
||||
if (dataset.transform === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const upstream = dataset.fromDatasetIndex;
|
||||
if (
|
||||
typeof upstream === "number" &&
|
||||
Number.isInteger(upstream) &&
|
||||
upstream >= 0
|
||||
) {
|
||||
return datasetHasSource(option, upstream, visited);
|
||||
}
|
||||
return allDatasets.some(
|
||||
(candidate, index) =>
|
||||
index !== datasetIndex && datasetHasRows(candidate)
|
||||
);
|
||||
}
|
||||
|
||||
function seriesHasDataset(option: JsonObject, series: JsonObject) {
|
||||
const datasetIndex =
|
||||
typeof series.datasetIndex === "number" &&
|
||||
Number.isInteger(series.datasetIndex)
|
||||
? series.datasetIndex
|
||||
: 0;
|
||||
return datasetHasSource(option, datasetIndex);
|
||||
}
|
||||
|
||||
function requireData(context: EChartsSeriesValidationContext) {
|
||||
if (
|
||||
!isNonEmptyData(context.series.data) &&
|
||||
!context.hasDataset
|
||||
) {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.data`,
|
||||
"必须是非空数组,或通过 dataset.source 提供数据"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCartesianAxes(option: JsonObject) {
|
||||
option.xAxis ??= {};
|
||||
option.yAxis ??= {};
|
||||
}
|
||||
|
||||
function requireComponent(
|
||||
option: JsonObject,
|
||||
name: string,
|
||||
seriesIndex: number
|
||||
) {
|
||||
if (option[name] === undefined) {
|
||||
fail(
|
||||
`option.${name}`,
|
||||
`是 option.series.${seriesIndex} 所用坐标系的必填组件`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function coordinateSystem(series: JsonObject) {
|
||||
return typeof series.coordinateSystem === "string"
|
||||
? series.coordinateSystem
|
||||
: "cartesian2d";
|
||||
}
|
||||
|
||||
function validateCartesianOrPolar(
|
||||
context: EChartsSeriesValidationContext
|
||||
) {
|
||||
const coordinate = coordinateSystem(context.series);
|
||||
if (coordinate === "cartesian2d") {
|
||||
ensureCartesianAxes(context.option);
|
||||
return;
|
||||
}
|
||||
if (coordinate === "polar") {
|
||||
requireComponent(
|
||||
context.option,
|
||||
"polar",
|
||||
context.seriesIndex
|
||||
);
|
||||
requireComponent(
|
||||
context.option,
|
||||
"angleAxis",
|
||||
context.seriesIndex
|
||||
);
|
||||
requireComponent(
|
||||
context.option,
|
||||
"radiusAxis",
|
||||
context.seriesIndex
|
||||
);
|
||||
return;
|
||||
}
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||
`暂不支持 ${coordinate}`
|
||||
);
|
||||
}
|
||||
|
||||
function dataValues(series: JsonObject): JsonValue[] {
|
||||
return Array.isArray(series.data) ? series.data : [];
|
||||
}
|
||||
|
||||
function itemValue(item: JsonValue): JsonValue {
|
||||
return isObject(item) && item.value !== undefined
|
||||
? item.value
|
||||
: item;
|
||||
}
|
||||
|
||||
function requireTupleData(
|
||||
context: EChartsSeriesValidationContext,
|
||||
minimumLength: number,
|
||||
description: string
|
||||
) {
|
||||
if (context.hasDataset) {
|
||||
return;
|
||||
}
|
||||
for (const [dataIndex, item] of dataValues(
|
||||
context.series
|
||||
).entries()) {
|
||||
const value = itemValue(item);
|
||||
if (!Array.isArray(value) || value.length < minimumLength) {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.data.${dataIndex}`,
|
||||
`必须是${description}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateLineOrBar(
|
||||
context: EChartsSeriesValidationContext
|
||||
) {
|
||||
requireData(context);
|
||||
validateCartesianOrPolar(context);
|
||||
}
|
||||
|
||||
function validateScatter(
|
||||
context: EChartsSeriesValidationContext
|
||||
) {
|
||||
requireData(context);
|
||||
const coordinate = coordinateSystem(context.series);
|
||||
if (coordinate === "calendar") {
|
||||
requireComponent(
|
||||
context.option,
|
||||
"calendar",
|
||||
context.seriesIndex
|
||||
);
|
||||
requireTupleData(context, 2, "至少包含日期和值的数组");
|
||||
return;
|
||||
}
|
||||
validateCartesianOrPolar(context);
|
||||
requireTupleData(context, 2, "至少包含两个坐标值的数组");
|
||||
}
|
||||
|
||||
function validatePie(context: EChartsSeriesValidationContext) {
|
||||
requireData(context);
|
||||
if (context.hasDataset) {
|
||||
return;
|
||||
}
|
||||
for (const [dataIndex, item] of dataValues(
|
||||
context.series
|
||||
).entries()) {
|
||||
const value = itemValue(item);
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value)
|
||||
) {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.data.${dataIndex}.value`,
|
||||
"必须是有限数值"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function radarIndicators(option: JsonObject) {
|
||||
return asObjects(option.radar).flatMap((radar) =>
|
||||
Array.isArray(radar.indicator)
|
||||
? radar.indicator.filter(isObject)
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
function validateRadar(context: EChartsSeriesValidationContext) {
|
||||
requireData(context);
|
||||
const indicators = radarIndicators(context.option);
|
||||
if (indicators.length === 0) {
|
||||
fail(
|
||||
"option.radar.indicator",
|
||||
"是雷达图的必填非空数组"
|
||||
);
|
||||
}
|
||||
if (context.hasDataset) {
|
||||
return;
|
||||
}
|
||||
requireTupleData(
|
||||
context,
|
||||
indicators.length,
|
||||
`至少包含 ${indicators.length} 个指标值的数组`
|
||||
);
|
||||
}
|
||||
|
||||
function validateHeatmap(context: EChartsSeriesValidationContext) {
|
||||
requireData(context);
|
||||
const coordinate = coordinateSystem(context.series);
|
||||
if (coordinate === "calendar") {
|
||||
requireComponent(
|
||||
context.option,
|
||||
"calendar",
|
||||
context.seriesIndex
|
||||
);
|
||||
requireTupleData(context, 2, "包含日期和值的数组");
|
||||
return;
|
||||
}
|
||||
if (coordinate !== "cartesian2d") {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||
`暂不支持 ${coordinate}`
|
||||
);
|
||||
}
|
||||
ensureCartesianAxes(context.option);
|
||||
requireTupleData(context, 3, "包含 x、y、值的三元数组");
|
||||
}
|
||||
|
||||
function validateBoxplot(
|
||||
context: EChartsSeriesValidationContext
|
||||
) {
|
||||
requireData(context);
|
||||
ensureCartesianAxes(context.option);
|
||||
requireTupleData(
|
||||
context,
|
||||
5,
|
||||
"包含最小值、Q1、中位数、Q3、最大值的五元数组"
|
||||
);
|
||||
}
|
||||
|
||||
function validateCandlestick(
|
||||
context: EChartsSeriesValidationContext
|
||||
) {
|
||||
requireData(context);
|
||||
ensureCartesianAxes(context.option);
|
||||
requireTupleData(
|
||||
context,
|
||||
4,
|
||||
"包含开盘、收盘、最低、最高值的四元数组"
|
||||
);
|
||||
}
|
||||
|
||||
export const echartsSeriesValidators: ReadonlyMap<
|
||||
SupportedEChartsSeriesType,
|
||||
EChartsSeriesValidator
|
||||
> = new Map([
|
||||
["line", validateLineOrBar],
|
||||
["bar", validateLineOrBar],
|
||||
["pie", validatePie],
|
||||
["scatter", validateScatter],
|
||||
["radar", validateRadar],
|
||||
["heatmap", validateHeatmap],
|
||||
["boxplot", validateBoxplot],
|
||||
["candlestick", validateCandlestick]
|
||||
]);
|
||||
|
||||
export function validateAndApplyEChartsOptionDefaults(
|
||||
option: JsonObject
|
||||
) {
|
||||
const series = option.series;
|
||||
if (!Array.isArray(series)) {
|
||||
return option;
|
||||
}
|
||||
|
||||
for (const [seriesIndex, value] of series.entries()) {
|
||||
if (!isObject(value) || typeof value.type !== "string") {
|
||||
continue;
|
||||
}
|
||||
const type = value.type as SupportedEChartsSeriesType;
|
||||
const validator = echartsSeriesValidators.get(type);
|
||||
validator?.({
|
||||
option,
|
||||
series: value,
|
||||
seriesIndex,
|
||||
hasDataset: seriesHasDataset(option, value)
|
||||
});
|
||||
}
|
||||
return option;
|
||||
}
|
||||
Reference in New Issue
Block a user