/** * Handlebars通用工具类助手函数 * 提供数组处理、字符串处理等实用功能 */ /** * 数组或字符串切片操作 * @param {Array|string} array 要处理的数组或字符串 * @param {number} start 起始索引 * @param {number} [end] 结束索引(可选) * @returns {Array|string} 切片结果 * @example {{slice array 0 5}} */ function slice(array, start, end) { if (!array) return []; if (typeof array === 'string') { return end ? array.slice(start, end) : array.slice(start); } if (Array.isArray(array)) { return end ? array.slice(start, end) : array.slice(start); } return []; } /** * 合并数组 * @param {...Array} arrays 要合并的数组 * @returns {Array} 合并后的数组 * @example {{concat array1 array2 array3}} */ function concat() { const args = Array.from(arguments); const options = args.pop(); // 最后一个参数是Handlebars的options对象 // 过滤掉非数组参数 const validArrays = args.filter((arg) => Array.isArray(arg)); if (validArrays.length === 0) { return []; } return Array.prototype.concat.apply([], validArrays); } /** * 获取数组或对象的长度/大小 * @param {Array|Object|string} value 要计算长度的值 * @returns {number} 长度或大小 * @example {{size array}} */ function size(value) { if (!value) return 0; if (Array.isArray(value) || typeof value === 'string') { return value.length; } if (typeof value === 'object') { return Object.keys(value).length; } return 0; } /** * 获取数组的第一个元素 * @param {Array} array 数组 * @returns {any} 第一个元素 * @example {{first items}} */ function first(array) { if (!array || !Array.isArray(array) || array.length === 0) { return undefined; } return array[0]; } /** * 获取数组的最后一个元素 * @param {Array} array 数组 * @returns {any} 最后一个元素 * @example {{last items}} */ function last(array) { if (!array || !Array.isArray(array) || array.length === 0) { return undefined; } return array[array.length - 1]; } /** * 创建一个连续范围的数组(用于循环) * @param {number} start 起始值 * @param {number} end 结束值 * @param {number} [step=1] 步长 * @returns {Array} 范围数组 * @example {{#each (range 1 5)}}{{this}}{{/each}} */ function range(start, end, step = 1) { const result = []; if (typeof start !== 'number' || typeof end !== 'number') { return result; } if (step <= 0) { step = 1; } for (let i = start; i <= end; i += step) { result.push(i); } return result; } /** * 从对象中选择指定的属性(创建新对象) * @param {Object} object 源对象 * @param {...string} keys 要选择的属性键 * @returns {Object} 包含选定属性的新对象 * @example {{json (pick user "name" "email")}} */ function pick() { const args = Array.from(arguments); const options = args.pop(); // 最后一个参数是Handlebars的options对象 if (args.length < 1) { return {}; } const obj = args[0]; const keys = args.slice(1); if (!obj || typeof obj !== 'object') { return {}; } const result = {}; keys.forEach((key) => { if (obj.hasOwnProperty(key)) { result[key] = obj[key]; } }); return result; } /** * 将对象的所有键转换为数组 * @param {Object} object 输入对象 * @returns {Array} 键数组 * @example {{#each (keys obj)}}{{this}}{{/each}} */ function keys(object) { if (!object || typeof object !== 'object') { return []; } return Object.keys(object); } /** * 对字符串进行URL组件编码(encodeURIComponent) * @param {string} text 输入文本 * @returns {string} 编码后的字符串 * @example {{encodeURIComponent url}} */ function encodeURIComponentHelper(text) { if (text === undefined || text === null) return ''; try { return encodeURIComponent(String(text)); } catch (e) { return ''; } } /** * 数学加法运算助手函数 * @param {number} a 第一个数 * @param {number} b 第二个数 * @returns {number} 两数之和 * @example {{add level 1}} */ function add(a, b) { const numA = parseInt(a, 10) || 0; const numB = parseInt(b, 10) || 0; return numA + numB; } /** * 根据 icons.region 配置生成 favicon URL * @param {string} url 站点 URL * @param {Object} options Handlebars options 对象 * @returns {string} favicon URL * @example {{faviconV2Url url}} */ function faviconV2Url(url, options) { if (!url) return ''; const region = options.data.root.icons?.region || 'com'; const domain = region === 'cn' ? 't3.gstatic.cn' : 't3.gstatic.com'; try { const encodedUrl = encodeURIComponent(String(url)); // drop_404_icon=true:缺失 favicon 时返回空 404,避免“小地球”占位图并可靠触发 回退 return `https://${domain}/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodedUrl}&size=32&drop_404_icon=true`; } catch (e) { return ''; } } /** * 根据 icons.region 配置生成 favicon 回退 URL * @param {string} url 站点 URL * @param {Object} options Handlebars options 对象 * @returns {string} favicon 回退 URL * @example {{faviconFallbackUrl url}} */ function faviconFallbackUrl(url, options) { if (!url) return ''; const region = options.data.root.icons?.region || 'com'; const domain = region === 'cn' ? 't3.gstatic.com' : 't3.gstatic.cn'; try { const encodedUrl = encodeURIComponent(String(url)); // drop_404_icon=true:缺失 favicon 时返回空 404,避免“小地球”占位图并可靠触发 回退 return `https://${domain}/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodedUrl}&size=32&drop_404_icon=true`; } catch (e) { return ''; } } /** * 安全 URL 输出:用于 href 等场景,防止 javascript: 等危险 scheme 变成可点击链接 * - 默认允许:http/https/mailto/tel + 相对链接(# / ./ ../ ?) * - 允许通过 site.security.allowedSchemes 扩展白名单(例如 obsidian/vscode) * @param {string} url 输入 URL * @param {Object} options Handlebars options 对象 * @returns {string} 安全的 URL(不安全时返回 #) * @example ... */ function safeUrl(url, options) { const raw = String(url || '').trim(); if (!raw) return '#'; // 允许相对链接 if ( raw.startsWith('#') || raw.startsWith('/') || raw.startsWith('./') || raw.startsWith('../') || raw.startsWith('?') ) { return raw; } // 拒绝协议相对 URL(//example.com),避免绕过策略 if (raw.startsWith('//')) { console.warn(`[WARN] 已拦截不安全 URL(协议相对形式):${raw}`); return '#'; } const allowedFromConfig = options && options.data && options.data.root && options.data.root.site && options.data.root.site.security && options.data.root.site.security.allowedSchemes; const allowedSchemes = Array.isArray(allowedFromConfig) && allowedFromConfig.length > 0 ? allowedFromConfig .map((s) => String(s || '') .trim() .toLowerCase() .replace(/:$/, '') ) .filter(Boolean) : ['http', 'https', 'mailto', 'tel']; try { const parsed = new URL(raw); const scheme = String(parsed.protocol || '') .toLowerCase() .replace(/:$/, ''); if (allowedSchemes.includes(scheme)) return raw; console.warn(`[WARN] 已拦截不安全 URL scheme:${raw}`); return '#'; } catch (e) { console.warn(`[WARN] 已拦截无法解析的 URL:${raw}`); return '#'; } } // 导出所有工具类助手函数 module.exports = { slice, concat, size, first, last, range, pick, keys, encodeURIComponent: encodeURIComponentHelper, add, faviconV2Url, faviconFallbackUrl, safeUrl, };