feat: 增加 ECharts 渲染与预览交互增强
- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染 - 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略 - 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步 - 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放 - 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user