fix: 加固链接/图标安全与版本一致性;sync-articles 对齐 best-effort
- 模板与运行时统一做 URL scheme 白名单校验(不安全降级为 #),并清洗 icon class;分类标题/新增分类改用 DOM API 避免 innerHTML 注入 - sync-articles 主入口异常不再返回非 0 退出码,避免阻断 build/deploy - window.MeNav.version 改为从 meta menav-version/配置自动读取,避免写死版本 - 文档/配置:新增 security.allowedSchemes 配置说明
This commit is contained in:
@@ -45,13 +45,20 @@
|
||||
|
||||
### 2026/01/04
|
||||
|
||||
**1. PageSpeed 首屏性能优化**
|
||||
**1. 首屏性能优化**
|
||||
|
||||
- 移除首页副标题固定 Quicksand 外链字体,改为跟随全站字体
|
||||
- 字体外链 CSS 支持 `fonts.preload: true`(`preload + onload` 非阻塞加载,含 `<noscript>` 回退)
|
||||
- Font Awesome CSS 改为 `preload + onload` 非阻塞加载,降低 render-blocking 影响
|
||||
- 构建阶段压缩 `style.css` / `script.js` / `pinyin-match.js`,减少传输体积
|
||||
|
||||
**2. 安全与部署稳定性**
|
||||
|
||||
- 链接安全加固:模板与运行时统一校验 URL scheme(不安全链接降级为 `#`),新增 `security.allowedSchemes` 支持显式放行自定义协议
|
||||
- 去除外部输入的 `innerHTML` 拼接:分类标题更新/新增分类改用 DOM API 构建,降低注入风险
|
||||
- `sync-articles` 对齐 best-effort:同步失败不再以非 0 退出码阻断构建/部署
|
||||
- 版本号来源统一:`window.MeNav.version` 不再写死,自动读取构建注入版本(用于扩展/调试识别)
|
||||
|
||||
### 2026/01/03
|
||||
|
||||
关联 Issue:[#31](https://github.com/rbetree/menav/issues/31)
|
||||
|
||||
@@ -126,24 +126,29 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优
|
||||
faviconUrl: "https://example.com/favicon.png" # 单站点自定义 favicon
|
||||
```
|
||||
|
||||
3. **字体**
|
||||
3. **安全策略(链接白名单)**
|
||||
- `security.allowedSchemes`:允许在页面中渲染为可点击链接的 URL scheme 白名单
|
||||
- 默认仅允许:`http/https/mailto/tel` + 所有相对链接(`#`、`/`、`./`、`../`、`?` 开头)
|
||||
- 其他 scheme 会被安全降级为 `#` 并输出告警;如需支持 `obsidian://`、`vscode://` 等协议,可在此显式放行
|
||||
|
||||
4. **字体**
|
||||
- `fonts`:单一字体配置项,用于设置全站基础字体(`body` 等)
|
||||
- 支持 `source: css | google | system`(分别表示第三方 CSS、Google Fonts、系统字体)
|
||||
- 可选 `fonts.preload: true`:用 `preload + onload` 的方式非阻塞加载外链字体 CSS(更利于首屏性能)
|
||||
- 首页副标题(渐变发光样式)使用全站基础字体(跟随 `fonts` 配置)
|
||||
|
||||
4. **顶部欢迎信息与社交链接**
|
||||
5. **顶部欢迎信息与社交链接**
|
||||
- `profile`:首页顶部欢迎信息
|
||||
- `social`:侧边栏底部社交链接
|
||||
- `profile.title` / `profile.subtitle`:分别对应首页顶部主标题与副标题
|
||||
|
||||
5. **导航**
|
||||
6. **导航**
|
||||
- `navigation[]`:页面入口列表,`id` 需唯一,并与 `pages/<id>.yml` 对应(例如 `id: common` 对应 `pages/common.yml`)
|
||||
- 默认首页由 `navigation` 数组顺序决定:**第一项即为首页(默认打开页)**,不再使用 `active` 字段
|
||||
- 图标使用 Font Awesome 类名字符串(例如 `fas fa-home`、`fab fa-github`)
|
||||
- 导航显示顺序与数组顺序一致,可通过调整数组顺序改变导航顺序
|
||||
|
||||
6. **RSS(articles Phase 2)**
|
||||
7. **RSS(articles Phase 2)**
|
||||
- `rss.*`:仅用于 `npm run sync-articles`(联网抓取 RSS/Atom 并写入缓存)
|
||||
- `npm run build` 默认不联网;无缓存时 `articles` 页面会回退到 Phase 1 的站点入口展示
|
||||
- articles 页面会按 `articles.yml` 的分类进行聚合展示:某分类下配置的来源站点,其文章会显示在该分类下
|
||||
@@ -151,7 +156,7 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优
|
||||
- 默认配置已将 `rss.cacheDir` 设为 `dev`(仓库默认 gitignore),避免误提交缓存文件;可按需改为自定义目录
|
||||
- GitHub Pages 部署工作流会在构建前自动执行 `npm run sync-articles`,并支持定时触发(默认每天 UTC 02:00;可在 `.github/workflows/deploy.yml` 调整)
|
||||
|
||||
7. **GitHub(projects 热力图,可选)**
|
||||
8. **GitHub(projects 热力图,可选)**
|
||||
- `github.username`:你的 GitHub 用户名(用于 projects 页面标题栏右侧贡献热力图)
|
||||
- `github.heatmapColor`:热力图主题色(不带 `#`,例如 `339af0`)
|
||||
- `github.cacheDir`:projects 仓库元信息缓存目录(默认 `dev`,仓库默认 gitignore)
|
||||
|
||||
@@ -21,6 +21,19 @@ icons:
|
||||
# 说明:如果你在中国大陆且访问 gstatic.com 较慢,建议设置为 cn 以提升图标加载速度
|
||||
region: cn # 可选: com | cn(默认 com)
|
||||
|
||||
# 安全策略(可选):链接 URL scheme 白名单
|
||||
# - 默认允许:http/https/mailto/tel + 所有相对链接(# / ./ ../ ?)
|
||||
# - 其他 scheme 会在页面中安全降级为 # 并输出告警(避免 javascript: 等危险链接变成可点击)
|
||||
# - 如需支持 obsidian://、vscode:// 等自定义协议,可在此显式放行
|
||||
security:
|
||||
allowedSchemes:
|
||||
- http
|
||||
- https
|
||||
- mailto
|
||||
- tel
|
||||
# 示例:
|
||||
# allowedSchemes: [http, https, mailto, tel, obsidian, vscode]
|
||||
|
||||
# 字体设置:全站基础字体
|
||||
# - source: css | google | system
|
||||
# - css: 通过 cssUrl 引入第三方字体 CSS
|
||||
|
||||
@@ -672,8 +672,8 @@ async function main() {
|
||||
if (require.main === module) {
|
||||
main().catch(err => {
|
||||
console.error('[ERROR] sync-articles 执行失败:', err);
|
||||
// best-effort:除非是非常规异常,否则不阻断 CI;此处仍保留非 0 退出码便于本地排查
|
||||
process.exitCode = 1;
|
||||
// best-effort:不阻断后续 build/deploy(错误已输出到日志,便于排查)
|
||||
process.exitCode = 0;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -236,6 +236,62 @@ function faviconFallbackUrl(url, options) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全 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 <a href="{{safeUrl url}}">...</a>
|
||||
*/
|
||||
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,
|
||||
@@ -249,5 +305,6 @@ module.exports = {
|
||||
encodeURIComponent: encodeURIComponentHelper,
|
||||
add,
|
||||
faviconUrl,
|
||||
faviconFallbackUrl
|
||||
faviconFallbackUrl,
|
||||
safeUrl
|
||||
};
|
||||
|
||||
188
src/script.js
188
src/script.js
@@ -17,6 +17,99 @@ function menavExtractDomain(url) {
|
||||
}
|
||||
}
|
||||
|
||||
// URL 安全策略:默认仅允许 http/https(可加 mailto/tel)与相对链接;其他 scheme 降级为 '#'
|
||||
function menavGetAllowedUrlSchemes() {
|
||||
try {
|
||||
const cfg =
|
||||
window.MeNav && typeof window.MeNav.getConfig === 'function'
|
||||
? window.MeNav.getConfig()
|
||||
: null;
|
||||
const fromConfig =
|
||||
cfg && cfg.data && cfg.data.site && cfg.data.site.security && cfg.data.site.security.allowedSchemes;
|
||||
if (Array.isArray(fromConfig) && fromConfig.length > 0) {
|
||||
return fromConfig
|
||||
.map(s => String(s || '').trim().toLowerCase().replace(/:$/, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略,回退默认
|
||||
}
|
||||
return ['http', 'https', 'mailto', 'tel'];
|
||||
}
|
||||
|
||||
function menavIsRelativeUrl(url) {
|
||||
const s = String(url || '').trim();
|
||||
return s.startsWith('#') || s.startsWith('/') || s.startsWith('./') || s.startsWith('../') || s.startsWith('?');
|
||||
}
|
||||
|
||||
function menavSanitizeUrl(rawUrl, contextLabel) {
|
||||
if (rawUrl === undefined || rawUrl === null) return '#';
|
||||
const url = String(rawUrl).trim();
|
||||
if (!url) return '#';
|
||||
|
||||
if (menavIsRelativeUrl(url)) return url;
|
||||
|
||||
// 明确拒绝协议相对 URL(//example.com),避免意外绕过策略
|
||||
if (url.startsWith('//')) {
|
||||
console.warn(`[MeNav][安全] 已拦截不安全 URL(协议相对形式):${contextLabel || ''}`, url);
|
||||
return '#';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const scheme = String(parsed.protocol || '').toLowerCase().replace(/:$/, '');
|
||||
const allowed = menavGetAllowedUrlSchemes();
|
||||
if (allowed.includes(scheme)) return url;
|
||||
console.warn(`[MeNav][安全] 已拦截不安全 URL scheme:${contextLabel || ''}`, url);
|
||||
return '#';
|
||||
} catch (e) {
|
||||
// 既不是可识别的绝对 URL,也不是允许的相对 URL
|
||||
console.warn(`[MeNav][安全] 已拦截无法解析的 URL:${contextLabel || ''}`, url);
|
||||
return '#';
|
||||
}
|
||||
}
|
||||
|
||||
// class token 清洗:仅允许字母/数字/下划线/中划线与空格分隔,避免属性/事件注入
|
||||
function menavSanitizeClassList(rawClassList, contextLabel) {
|
||||
const input = String(rawClassList || '').trim();
|
||||
if (!input) return '';
|
||||
|
||||
const tokens = input
|
||||
.split(/\s+/g)
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
.map(t => t.replace(/[^\w-]/g, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
const sanitized = tokens.join(' ');
|
||||
if (sanitized !== input) {
|
||||
console.warn(`[MeNav][安全] 已清洗不安全的 icon class:${contextLabel || ''}`, rawClassList);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// 版本号统一来源:优先读取 meta[menav-version],回退到 menav-config-data.version
|
||||
function menavDetectVersion() {
|
||||
try {
|
||||
const meta = document.querySelector('meta[name="menav-version"]');
|
||||
const v = meta ? String(meta.getAttribute('content') || '').trim() : '';
|
||||
if (v) return v;
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
try {
|
||||
const configData = document.getElementById('menav-config-data');
|
||||
const raw = configData ? String(configData.textContent || '').trim() : '';
|
||||
if (!raw) return '1.0.0';
|
||||
const parsed = JSON.parse(raw);
|
||||
const v = parsed && parsed.version ? String(parsed.version).trim() : '';
|
||||
return v || '1.0.0';
|
||||
} catch (e) {
|
||||
return '1.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
// 修复移动端 `100vh` 视口高度问题:用实际可视高度驱动布局,避免侧边栏/内容区底部被浏览器 UI 遮挡
|
||||
function menavUpdateAppHeight() {
|
||||
const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
||||
@@ -36,7 +129,7 @@ let menavConfigCacheValue = null;
|
||||
|
||||
// 全局MeNav对象 - 用于浏览器扩展
|
||||
window.MeNav = {
|
||||
version: "1.0.0",
|
||||
version: menavDetectVersion(),
|
||||
|
||||
// 获取配置数据
|
||||
getConfig: function(options) {
|
||||
@@ -93,8 +186,10 @@ window.MeNav = {
|
||||
if (type === 'site') {
|
||||
// 更新站点卡片
|
||||
if (newData.url) {
|
||||
element.href = newData.url;
|
||||
element.setAttribute('data-url', newData.url);
|
||||
const safeUrl = menavSanitizeUrl(newData.url, 'updateElement(site).url');
|
||||
element.setAttribute('href', safeUrl);
|
||||
// 保留原始 URL 供扩展/调试读取,但点击行为以 href 的安全降级为准
|
||||
element.setAttribute('data-url', String(newData.url).trim());
|
||||
}
|
||||
if (newData.name) {
|
||||
element.querySelector('h3').textContent = newData.name;
|
||||
@@ -112,7 +207,7 @@ window.MeNav = {
|
||||
element.querySelector('i');
|
||||
|
||||
if (iconElement) {
|
||||
const nextIconClass = String(newData.icon || '').trim();
|
||||
const nextIconClass = menavSanitizeClassList(newData.icon, 'updateElement(site).icon');
|
||||
const preservedClasses = [];
|
||||
|
||||
if (iconElement.classList.contains('icon-fallback')) {
|
||||
@@ -127,7 +222,7 @@ window.MeNav = {
|
||||
preservedClasses.forEach(cls => iconElement.classList.add(cls));
|
||||
}
|
||||
}
|
||||
element.setAttribute('data-icon', newData.icon);
|
||||
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(site).data-icon'));
|
||||
}
|
||||
if (newData.title) element.title = newData.title;
|
||||
|
||||
@@ -144,15 +239,21 @@ window.MeNav = {
|
||||
if (newData.name) {
|
||||
const titleElement = element.querySelector('h2');
|
||||
if (titleElement) {
|
||||
// 保留图标
|
||||
const iconElement = titleElement.querySelector('i');
|
||||
const iconClass = iconElement ? iconElement.className : '';
|
||||
titleElement.innerHTML = `<i class="${newData.icon || iconClass}"></i> ${newData.name}`;
|
||||
const nextIcon = menavSanitizeClassList(newData.icon || iconClass, 'updateElement(category).icon');
|
||||
|
||||
// 用 DOM API 重建标题,避免 innerHTML 注入
|
||||
titleElement.textContent = '';
|
||||
const nextIconEl = document.createElement('i');
|
||||
if (nextIcon) nextIconEl.className = nextIcon;
|
||||
titleElement.appendChild(nextIconEl);
|
||||
titleElement.appendChild(document.createTextNode(' ' + String(newData.name)));
|
||||
}
|
||||
element.setAttribute('data-name', newData.name);
|
||||
}
|
||||
if (newData.icon) {
|
||||
element.setAttribute('data-icon', newData.icon);
|
||||
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(category).data-icon'));
|
||||
}
|
||||
|
||||
// 触发元素更新事件
|
||||
@@ -175,9 +276,9 @@ window.MeNav = {
|
||||
if (newData.icon) {
|
||||
const iconElement = element.querySelector('i');
|
||||
if (iconElement) {
|
||||
iconElement.className = newData.icon;
|
||||
iconElement.className = menavSanitizeClassList(newData.icon, 'updateElement(nav-item).icon');
|
||||
}
|
||||
element.setAttribute('data-icon', newData.icon);
|
||||
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(nav-item).data-icon'));
|
||||
}
|
||||
|
||||
// 触发元素更新事件
|
||||
@@ -191,8 +292,10 @@ window.MeNav = {
|
||||
} else if (type === 'social-link') {
|
||||
// 更新社交链接
|
||||
if (newData.url) {
|
||||
element.href = newData.url;
|
||||
element.setAttribute('data-url', newData.url);
|
||||
const safeUrl = menavSanitizeUrl(newData.url, 'updateElement(social-link).url');
|
||||
element.setAttribute('href', safeUrl);
|
||||
// 保留原始 URL 供扩展/调试读取,但点击行为以 href 的安全降级为准
|
||||
element.setAttribute('data-url', String(newData.url).trim());
|
||||
}
|
||||
if (newData.name) {
|
||||
const textElement = element.querySelector('.nav-text');
|
||||
@@ -204,9 +307,9 @@ window.MeNav = {
|
||||
if (newData.icon) {
|
||||
const iconElement = element.querySelector('i');
|
||||
if (iconElement) {
|
||||
iconElement.className = newData.icon;
|
||||
iconElement.className = menavSanitizeClassList(newData.icon, 'updateElement(social-link).icon');
|
||||
}
|
||||
element.setAttribute('data-icon', newData.icon);
|
||||
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(social-link).data-icon'));
|
||||
}
|
||||
|
||||
// 触发元素更新事件
|
||||
@@ -272,10 +375,13 @@ window.MeNav = {
|
||||
? siteForceIconModeRaw
|
||||
: '';
|
||||
|
||||
newSite.href = siteUrl;
|
||||
const safeSiteUrl = menavSanitizeUrl(siteUrl, 'addElement(site).url');
|
||||
const safeSiteIcon = menavSanitizeClassList(siteIcon, 'addElement(site).icon');
|
||||
|
||||
newSite.setAttribute('href', safeSiteUrl);
|
||||
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
|
||||
newSite.setAttribute('data-tooltip', siteName + (siteDescription ? ' - ' + siteDescription : '')); // 添加自定义 tooltip
|
||||
if (/^https?:\/\//i.test(siteUrl)) {
|
||||
if (/^https?:\/\//i.test(safeSiteUrl)) {
|
||||
newSite.target = '_blank';
|
||||
newSite.rel = 'noopener';
|
||||
}
|
||||
@@ -283,8 +389,9 @@ window.MeNav = {
|
||||
// 设置数据属性
|
||||
newSite.setAttribute('data-type', 'site');
|
||||
newSite.setAttribute('data-name', siteName);
|
||||
newSite.setAttribute('data-url', data.url || '');
|
||||
newSite.setAttribute('data-icon', siteIcon);
|
||||
// 保留原始 URL(data-url)供扩展/调试读取;href 仍会做安全降级
|
||||
newSite.setAttribute('data-url', String(data.url || '').trim());
|
||||
newSite.setAttribute('data-icon', safeSiteIcon);
|
||||
if (siteFaviconUrl) newSite.setAttribute('data-favicon-url', siteFaviconUrl);
|
||||
if (siteForceIconMode) newSite.setAttribute('data-force-icon-mode', siteForceIconMode);
|
||||
newSite.setAttribute('data-description', siteDescription);
|
||||
@@ -295,7 +402,7 @@ window.MeNav = {
|
||||
repoHeader.className = 'repo-header';
|
||||
|
||||
const repoIcon = document.createElement('i');
|
||||
repoIcon.className = `${siteIcon || 'fas fa-code'} repo-icon`;
|
||||
repoIcon.className = `${safeSiteIcon || 'fas fa-code'} repo-icon`;
|
||||
repoIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const repoTitle = document.createElement('div');
|
||||
@@ -418,7 +525,7 @@ window.MeNav = {
|
||||
placeholder.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const fallback = document.createElement('i');
|
||||
fallback.className = `${siteIcon} icon-fallback`;
|
||||
fallback.className = `${safeSiteIcon} icon-fallback`;
|
||||
fallback.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const favicon = document.createElement('img');
|
||||
@@ -452,14 +559,14 @@ window.MeNav = {
|
||||
iconContainer.appendChild(favicon);
|
||||
iconContainer.appendChild(fallback);
|
||||
iconWrapper.appendChild(iconContainer);
|
||||
} else if (effectiveIconsMode === 'favicon' && siteUrl && /^https?:\/\//i.test(siteUrl)) {
|
||||
} else if (effectiveIconsMode === 'favicon' && safeSiteUrl && /^https?:\/\//i.test(safeSiteUrl)) {
|
||||
// 根据 icons.region 配置决定优先使用哪个域名
|
||||
const faviconUrlPrimary = iconsRegion === 'cn'
|
||||
? `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`
|
||||
: `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`;
|
||||
? `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`
|
||||
: `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`;
|
||||
const faviconUrlFallback = iconsRegion === 'cn'
|
||||
? `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`
|
||||
: `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`;
|
||||
? `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`
|
||||
: `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`;
|
||||
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.className = 'icon-container';
|
||||
@@ -469,7 +576,7 @@ window.MeNav = {
|
||||
placeholder.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const fallback = document.createElement('i');
|
||||
fallback.className = `${siteIcon} icon-fallback`;
|
||||
fallback.className = `${safeSiteIcon} icon-fallback`;
|
||||
fallback.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const favicon = document.createElement('img');
|
||||
@@ -524,7 +631,7 @@ window.MeNav = {
|
||||
iconWrapper.appendChild(iconContainer);
|
||||
} else {
|
||||
const iconEl = document.createElement('i');
|
||||
iconEl.className = `${siteIcon} site-icon`;
|
||||
iconEl.className = `${safeSiteIcon} site-icon`;
|
||||
iconEl.setAttribute('aria-hidden', 'true');
|
||||
iconWrapper.appendChild(iconEl);
|
||||
}
|
||||
@@ -565,16 +672,27 @@ window.MeNav = {
|
||||
// 设置数据属性
|
||||
newCategory.setAttribute('data-type', 'category');
|
||||
newCategory.setAttribute('data-name', data.name || '未命名分类');
|
||||
newCategory.setAttribute('data-icon', data.icon || 'fas fa-folder');
|
||||
newCategory.setAttribute('data-icon', menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).data-icon'));
|
||||
newCategory.setAttribute('data-container', 'categories');
|
||||
|
||||
// 添加内容
|
||||
newCategory.innerHTML = `
|
||||
<h2 data-editable="category-name"><i class="${data.icon || 'fas fa-folder'}"></i> ${data.name || '未命名分类'}</h2>
|
||||
<div class="sites-grid" data-container="sites">
|
||||
<p class="empty-sites">暂无网站</p>
|
||||
</div>
|
||||
`;
|
||||
// 添加内容(用 DOM API 构建,避免 innerHTML 注入)
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.setAttribute('data-editable', 'category-name');
|
||||
const iconEl = document.createElement('i');
|
||||
iconEl.className = menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).icon');
|
||||
titleEl.appendChild(iconEl);
|
||||
titleEl.appendChild(document.createTextNode(' ' + String(data.name || '未命名分类')));
|
||||
|
||||
const sitesGrid = document.createElement('div');
|
||||
sitesGrid.className = 'sites-grid';
|
||||
sitesGrid.setAttribute('data-container', 'sites');
|
||||
const emptyEl = document.createElement('p');
|
||||
emptyEl.className = 'empty-sites';
|
||||
emptyEl.textContent = '暂无网站';
|
||||
sitesGrid.appendChild(emptyEl);
|
||||
|
||||
newCategory.appendChild(titleEl);
|
||||
newCategory.appendChild(sitesGrid);
|
||||
|
||||
// 添加到DOM
|
||||
parent.appendChild(newCategory);
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{{#if projectsMeta.heatmap}}
|
||||
<div class="welcome-section-side">
|
||||
<div class="heatmap-container" title="我的 GitHub 贡献热力图">
|
||||
<a href="{{projectsMeta.heatmap.profileUrl}}" target="_blank" rel="noopener">
|
||||
<a href="{{safeUrl projectsMeta.heatmap.profileUrl}}" target="_blank" rel="noopener">
|
||||
<img class="heatmap-img"
|
||||
src="{{projectsMeta.heatmap.imageUrl}}"
|
||||
alt="Github Chart"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{#if url}}
|
||||
<a href="{{url}}" class="site-card{{#if style}} site-card-{{style}}{{/if}}" {{#if external}}target="_blank"
|
||||
<a href="{{safeUrl url}}" class="site-card{{#if style}} site-card-{{style}}{{/if}}" {{#if external}}target="_blank"
|
||||
rel="noopener" {{/if}} data-type="{{#if type}}{{type}}{{else}}site{{/if}}" data-name="{{name}}" data-url="{{url}}"
|
||||
data-icon="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}}" {{#if faviconUrl}}data-favicon-url="{{faviconUrl}}"
|
||||
{{/if}} {{#if forceIconMode}}data-force-icon-mode="{{forceIconMode}}" {{/if}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{#if this}}
|
||||
{{#each this}}
|
||||
<a href="{{url}}" class="social-icon" target="_blank" rel="noopener" title="{{name}}" aria-label="{{name}}" data-type="social-link" data-name="{{name}}" data-url="{{url}}" data-icon="{{icon}}">
|
||||
<a href="{{safeUrl url}}" class="social-icon" target="_blank" rel="noopener" title="{{name}}" aria-label="{{name}}" data-type="social-link" data-name="{{name}}" data-url="{{url}}" data-icon="{{icon}}">
|
||||
<i class="{{icon}}" aria-hidden="true"></i>
|
||||
<span class="nav-text visually-hidden" data-editable="social-link-name">{{name}}</span>
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user