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
+160
View File
@@ -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;
}