feat: 增加 ECharts 渲染与预览交互增强

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