/** * 公开图标资源的分片定位、格式校验与共享读取。manifest给出构建产物路径,模块级资源加载器 * 合并同分片请求并缓存解析结果;它不保存画布节点状态或私人资源授权。 */ import manifest from "./canvas-icon-assets.json" with { type: "json" }; import { createResourceLoader } from "./resource-loader.ts"; /** React绘制可用的SVG基础形状及属性列表,不接收任意HTML/SVG字符串。 */ export type IconPaths = [string, Record][]; /** Tabler统一分片,其它名字用稳定31进制散列落入96个Lucide分片;散列仅用于定位而非安全校验。 */ export function iconAsset(name: string) { // 每步 >>>0 约束为无符号32位;名称大小写参与散列,不能擅自归一化后再查分片。 let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; const key = name.startsWith("Tabler") ? "tabler" : `lucide-${hash % 96}`; return `${manifest.root}/${manifest.assets[key as keyof typeof manifest.assets]}`; } // 限定基础图形标签,不提供script、foreignObject或任意节点构造能力。 const allowedTags = new Set(["path", "circle", "ellipse", "rect", "line", "polyline", "polygon"]); // 属性名白名单配合值类型与外部引用模式检查,拒绝任意事件处理/URL内容。 const allowedAttributes = new Set(["key", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "x2", "y1", "y2", "width", "height", "points", "fill", "fillRule", "clipRule", "stroke", "strokeWidth", "strokeLinecap", "strokeLinejoin", "opacity", "transform"]); /** 校验图标名、非空且最多100项的路径数组、标签与属性形状,并拒绝url()/javascript:/data:值。 * 此处是特定分片结构验证,不宣称具有全文件字节预算或任意SVG净化能力。 */ export function validateIconShard(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid icon data"); for (const [id, paths] of Object.entries(value)) { if (!/^[A-Z][A-Za-z0-9]{0,63}$/.test(id) || !Array.isArray(paths) || !paths.length || paths.length > 100) throw new Error("Invalid icon paths"); for (const path of paths) { if (!Array.isArray(path) || path.length !== 2 || !allowedTags.has(path[0]) || !path[1] || typeof path[1] !== "object" || Array.isArray(path[1])) throw new Error("Invalid icon shape"); for (const [key, attribute] of Object.entries(path[1])) { if (!allowedAttributes.has(key) || !["string", "number"].includes(typeof attribute) || /url\s*\(|javascript:|data:/i.test(String(attribute))) throw new Error("Invalid icon attribute"); } } } // 校验通过后原对象直接返回,不复制/冻结路径和属性;消费者必须把共享结果当只读数据。 return value as Record; } /** 单例加载器以 force-cache 请求浏览器缓存,并在内存缓存校验后的分片;不保证服务端缓存期限。 * 响应失败或校验失败不写入成功缓存;先整体读取 JSON,不限制下载字节数或分片图标总数。 */ const resources = createResourceLoader(async (url, signal) => { const response = await fetch(url, { signal, cache: "force-cache" }); if (!response.ok) throw new Error("图标加载失败"); return validateIconShard(await response.json()); }); /** 缓存命中只取指定图标路径,不发请求;未加载或分片没有该名均返回undefined。 */ export function peekIcon(name: string) { return resources.peek(iconAsset(name))?.[name]; } /** 加入分片共享读取,取消只撤销本调用者;分片就绪但缺指定图标时给出明确错误。 */ export async function loadIcon(name: string, signal?: AbortSignal) { const paths = (await resources.request(iconAsset(name), signal))[name]; if (!paths) throw new Error("未找到图标"); return paths; }