/** * 主题 token 合并工具 * * 与 lang.js::applyOverrides 同构(都是"远端扁平覆写 map"按 key 路径合并), * 差异: * - 主题 token 是"扁平叶子"(如 main_color / bg_color),没有嵌套层级 * - 需要支持"三桶叠加":presetBase(本地预设) < brand(品牌色) < userMode(夜间/浅色) * - value 是色值字符串(也允许空串 = 显式清空) * * 危险 key 过滤:拦截 __proto__/constructor/prototype,防止恶意覆写污染对象原型链 */ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); /** * 把单层 map 的 keys 过滤掉危险段(白名单:值必须是 string,null/undefined/其它跳过) * - 输入: 远端扁平 map { 'main_color': '#FF4646', '...': null, ... } * - 输出: 净化后的 map(只保留 string 值,空串也是合法覆写) */ export function sanitizeFlatMap(flatMap) { const out = {}; if (!flatMap || typeof flatMap !== 'object') return out; for (const key in flatMap) { if (!Object.prototype.hasOwnProperty.call(flatMap, key)) continue; // 危险 key 过滤 if (FORBIDDEN_KEYS.has(String(key))) continue; const val = flatMap[key]; if (typeof val !== 'string') continue; out[key] = val; } return out; } /** * 合并多桶 map * @param {...object} maps 从低优先级到高优先级依次传入 * 例: mergeThemeBuckets(presetBase, brand, dark) = preset 被 brand 覆盖,brand 被 dark 覆盖 * @returns {object} */ export function mergeThemeBuckets(...maps) { const out = {}; for (const m of maps) { if (!m || typeof m !== 'object') continue; for (const k in m) { if (!Object.prototype.hasOwnProperty.call(m, k)) continue; if (FORBIDDEN_KEYS.has(String(k))) continue; const v = m[k]; if (typeof v !== 'string') continue; out[k] = v; } } return out; } /** * 一站式合并:本地预设(已选中的 theme 子集) + brand 桶 + userMode 桶 * @param {object} preset 本地 style_color.js 中选中的 theme(如 'default' 的所有 token) * @param {object} brandOverrides brand 桶(任何模式都生效) * @param {object} userModeOverrides userMode 桶(light/dark,叠加在 brand 之上) * @returns {object} { tokens, sources } * tokens: 最终扁平 map * sources: 每个 key 的来源(用于调试/可视化) 'preset' | 'brand' | 'userMode' */ export function composeFinalTokens(preset, brandOverrides, userModeOverrides) { const tokens = {}; const sources = {}; const presetMap = sanitizeFlatMap(preset); const brandMap = sanitizeFlatMap(brandOverrides); const modeMap = sanitizeFlatMap(userModeOverrides); for (const k in presetMap) { tokens[k] = presetMap[k]; sources[k] = 'preset'; } for (const k in brandMap) { tokens[k] = brandMap[k]; sources[k] = 'brand'; } for (const k in modeMap) { tokens[k] = modeMap[k]; sources[k] = 'userMode'; } return { tokens, sources }; } export default { sanitizeFlatMap, mergeThemeBuckets, composeFinalTokens, };