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