53 lines
3.9 KiB
TypeScript
53 lines
3.9 KiB
TypeScript
/**
|
|
* 公开图标资源的分片定位、格式校验与共享读取。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<string, string | number>][];
|
|
/** 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<string, IconPaths> {
|
|
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<string, IconPaths>;
|
|
}
|
|
/** 单例加载器以 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;
|
|
}
|