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
|
### 2026/01/04
|
||||||
|
|
||||||
**1. PageSpeed 首屏性能优化**
|
**1. 首屏性能优化**
|
||||||
|
|
||||||
- 移除首页副标题固定 Quicksand 外链字体,改为跟随全站字体
|
- 移除首页副标题固定 Quicksand 外链字体,改为跟随全站字体
|
||||||
- 字体外链 CSS 支持 `fonts.preload: true`(`preload + onload` 非阻塞加载,含 `<noscript>` 回退)
|
- 字体外链 CSS 支持 `fonts.preload: true`(`preload + onload` 非阻塞加载,含 `<noscript>` 回退)
|
||||||
- Font Awesome CSS 改为 `preload + onload` 非阻塞加载,降低 render-blocking 影响
|
- Font Awesome CSS 改为 `preload + onload` 非阻塞加载,降低 render-blocking 影响
|
||||||
- 构建阶段压缩 `style.css` / `script.js` / `pinyin-match.js`,减少传输体积
|
- 构建阶段压缩 `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
|
### 2026/01/03
|
||||||
|
|
||||||
关联 Issue:[#31](https://github.com/rbetree/menav/issues/31)
|
关联 Issue:[#31](https://github.com/rbetree/menav/issues/31)
|
||||||
|
|||||||
@@ -126,24 +126,29 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优
|
|||||||
faviconUrl: "https://example.com/favicon.png" # 单站点自定义 favicon
|
faviconUrl: "https://example.com/favicon.png" # 单站点自定义 favicon
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **字体**
|
3. **安全策略(链接白名单)**
|
||||||
|
- `security.allowedSchemes`:允许在页面中渲染为可点击链接的 URL scheme 白名单
|
||||||
|
- 默认仅允许:`http/https/mailto/tel` + 所有相对链接(`#`、`/`、`./`、`../`、`?` 开头)
|
||||||
|
- 其他 scheme 会被安全降级为 `#` 并输出告警;如需支持 `obsidian://`、`vscode://` 等协议,可在此显式放行
|
||||||
|
|
||||||
|
4. **字体**
|
||||||
- `fonts`:单一字体配置项,用于设置全站基础字体(`body` 等)
|
- `fonts`:单一字体配置项,用于设置全站基础字体(`body` 等)
|
||||||
- 支持 `source: css | google | system`(分别表示第三方 CSS、Google Fonts、系统字体)
|
- 支持 `source: css | google | system`(分别表示第三方 CSS、Google Fonts、系统字体)
|
||||||
- 可选 `fonts.preload: true`:用 `preload + onload` 的方式非阻塞加载外链字体 CSS(更利于首屏性能)
|
- 可选 `fonts.preload: true`:用 `preload + onload` 的方式非阻塞加载外链字体 CSS(更利于首屏性能)
|
||||||
- 首页副标题(渐变发光样式)使用全站基础字体(跟随 `fonts` 配置)
|
- 首页副标题(渐变发光样式)使用全站基础字体(跟随 `fonts` 配置)
|
||||||
|
|
||||||
4. **顶部欢迎信息与社交链接**
|
5. **顶部欢迎信息与社交链接**
|
||||||
- `profile`:首页顶部欢迎信息
|
- `profile`:首页顶部欢迎信息
|
||||||
- `social`:侧边栏底部社交链接
|
- `social`:侧边栏底部社交链接
|
||||||
- `profile.title` / `profile.subtitle`:分别对应首页顶部主标题与副标题
|
- `profile.title` / `profile.subtitle`:分别对应首页顶部主标题与副标题
|
||||||
|
|
||||||
5. **导航**
|
6. **导航**
|
||||||
- `navigation[]`:页面入口列表,`id` 需唯一,并与 `pages/<id>.yml` 对应(例如 `id: common` 对应 `pages/common.yml`)
|
- `navigation[]`:页面入口列表,`id` 需唯一,并与 `pages/<id>.yml` 对应(例如 `id: common` 对应 `pages/common.yml`)
|
||||||
- 默认首页由 `navigation` 数组顺序决定:**第一项即为首页(默认打开页)**,不再使用 `active` 字段
|
- 默认首页由 `navigation` 数组顺序决定:**第一项即为首页(默认打开页)**,不再使用 `active` 字段
|
||||||
- 图标使用 Font Awesome 类名字符串(例如 `fas fa-home`、`fab fa-github`)
|
- 图标使用 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 并写入缓存)
|
- `rss.*`:仅用于 `npm run sync-articles`(联网抓取 RSS/Atom 并写入缓存)
|
||||||
- `npm run build` 默认不联网;无缓存时 `articles` 页面会回退到 Phase 1 的站点入口展示
|
- `npm run build` 默认不联网;无缓存时 `articles` 页面会回退到 Phase 1 的站点入口展示
|
||||||
- articles 页面会按 `articles.yml` 的分类进行聚合展示:某分类下配置的来源站点,其文章会显示在该分类下
|
- articles 页面会按 `articles.yml` 的分类进行聚合展示:某分类下配置的来源站点,其文章会显示在该分类下
|
||||||
@@ -151,7 +156,7 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优
|
|||||||
- 默认配置已将 `rss.cacheDir` 设为 `dev`(仓库默认 gitignore),避免误提交缓存文件;可按需改为自定义目录
|
- 默认配置已将 `rss.cacheDir` 设为 `dev`(仓库默认 gitignore),避免误提交缓存文件;可按需改为自定义目录
|
||||||
- GitHub Pages 部署工作流会在构建前自动执行 `npm run sync-articles`,并支持定时触发(默认每天 UTC 02:00;可在 `.github/workflows/deploy.yml` 调整)
|
- GitHub Pages 部署工作流会在构建前自动执行 `npm run sync-articles`,并支持定时触发(默认每天 UTC 02:00;可在 `.github/workflows/deploy.yml` 调整)
|
||||||
|
|
||||||
7. **GitHub(projects 热力图,可选)**
|
8. **GitHub(projects 热力图,可选)**
|
||||||
- `github.username`:你的 GitHub 用户名(用于 projects 页面标题栏右侧贡献热力图)
|
- `github.username`:你的 GitHub 用户名(用于 projects 页面标题栏右侧贡献热力图)
|
||||||
- `github.heatmapColor`:热力图主题色(不带 `#`,例如 `339af0`)
|
- `github.heatmapColor`:热力图主题色(不带 `#`,例如 `339af0`)
|
||||||
- `github.cacheDir`:projects 仓库元信息缓存目录(默认 `dev`,仓库默认 gitignore)
|
- `github.cacheDir`:projects 仓库元信息缓存目录(默认 `dev`,仓库默认 gitignore)
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ icons:
|
|||||||
# 说明:如果你在中国大陆且访问 gstatic.com 较慢,建议设置为 cn 以提升图标加载速度
|
# 说明:如果你在中国大陆且访问 gstatic.com 较慢,建议设置为 cn 以提升图标加载速度
|
||||||
region: cn # 可选: com | cn(默认 com)
|
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
|
# - source: css | google | system
|
||||||
# - css: 通过 cssUrl 引入第三方字体 CSS
|
# - css: 通过 cssUrl 引入第三方字体 CSS
|
||||||
|
|||||||
@@ -672,8 +672,8 @@ async function main() {
|
|||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
main().catch(err => {
|
main().catch(err => {
|
||||||
console.error('[ERROR] sync-articles 执行失败:', err);
|
console.error('[ERROR] sync-articles 执行失败:', err);
|
||||||
// best-effort:除非是非常规异常,否则不阻断 CI;此处仍保留非 0 退出码便于本地排查
|
// best-effort:不阻断后续 build/deploy(错误已输出到日志,便于排查)
|
||||||
process.exitCode = 1;
|
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 = {
|
module.exports = {
|
||||||
slice,
|
slice,
|
||||||
@@ -249,5 +305,6 @@ module.exports = {
|
|||||||
encodeURIComponent: encodeURIComponentHelper,
|
encodeURIComponent: encodeURIComponentHelper,
|
||||||
add,
|
add,
|
||||||
faviconUrl,
|
faviconUrl,
|
||||||
faviconFallbackUrl
|
faviconFallbackUrl,
|
||||||
|
safeUrl
|
||||||
};
|
};
|
||||||
|
|||||||
256
src/script.js
256
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 遮挡
|
// 修复移动端 `100vh` 视口高度问题:用实际可视高度驱动布局,避免侧边栏/内容区底部被浏览器 UI 遮挡
|
||||||
function menavUpdateAppHeight() {
|
function menavUpdateAppHeight() {
|
||||||
const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
||||||
@@ -36,7 +129,7 @@ let menavConfigCacheValue = null;
|
|||||||
|
|
||||||
// 全局MeNav对象 - 用于浏览器扩展
|
// 全局MeNav对象 - 用于浏览器扩展
|
||||||
window.MeNav = {
|
window.MeNav = {
|
||||||
version: "1.0.0",
|
version: menavDetectVersion(),
|
||||||
|
|
||||||
// 获取配置数据
|
// 获取配置数据
|
||||||
getConfig: function(options) {
|
getConfig: function(options) {
|
||||||
@@ -90,12 +183,14 @@ window.MeNav = {
|
|||||||
const element = this._findElement(type, id);
|
const element = this._findElement(type, id);
|
||||||
if (!element) return false;
|
if (!element) return false;
|
||||||
|
|
||||||
if (type === 'site') {
|
if (type === 'site') {
|
||||||
// 更新站点卡片
|
// 更新站点卡片
|
||||||
if (newData.url) {
|
if (newData.url) {
|
||||||
element.href = newData.url;
|
const safeUrl = menavSanitizeUrl(newData.url, 'updateElement(site).url');
|
||||||
element.setAttribute('data-url', newData.url);
|
element.setAttribute('href', safeUrl);
|
||||||
}
|
// 保留原始 URL 供扩展/调试读取,但点击行为以 href 的安全降级为准
|
||||||
|
element.setAttribute('data-url', String(newData.url).trim());
|
||||||
|
}
|
||||||
if (newData.name) {
|
if (newData.name) {
|
||||||
element.querySelector('h3').textContent = newData.name;
|
element.querySelector('h3').textContent = newData.name;
|
||||||
element.setAttribute('data-name', newData.name);
|
element.setAttribute('data-name', newData.name);
|
||||||
@@ -112,7 +207,7 @@ window.MeNav = {
|
|||||||
element.querySelector('i');
|
element.querySelector('i');
|
||||||
|
|
||||||
if (iconElement) {
|
if (iconElement) {
|
||||||
const nextIconClass = String(newData.icon || '').trim();
|
const nextIconClass = menavSanitizeClassList(newData.icon, 'updateElement(site).icon');
|
||||||
const preservedClasses = [];
|
const preservedClasses = [];
|
||||||
|
|
||||||
if (iconElement.classList.contains('icon-fallback')) {
|
if (iconElement.classList.contains('icon-fallback')) {
|
||||||
@@ -127,7 +222,7 @@ window.MeNav = {
|
|||||||
preservedClasses.forEach(cls => iconElement.classList.add(cls));
|
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;
|
if (newData.title) element.title = newData.title;
|
||||||
|
|
||||||
@@ -144,15 +239,21 @@ window.MeNav = {
|
|||||||
if (newData.name) {
|
if (newData.name) {
|
||||||
const titleElement = element.querySelector('h2');
|
const titleElement = element.querySelector('h2');
|
||||||
if (titleElement) {
|
if (titleElement) {
|
||||||
// 保留图标
|
|
||||||
const iconElement = titleElement.querySelector('i');
|
const iconElement = titleElement.querySelector('i');
|
||||||
const iconClass = iconElement ? iconElement.className : '';
|
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);
|
element.setAttribute('data-name', newData.name);
|
||||||
}
|
}
|
||||||
if (newData.icon) {
|
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) {
|
if (newData.icon) {
|
||||||
const iconElement = element.querySelector('i');
|
const iconElement = element.querySelector('i');
|
||||||
if (iconElement) {
|
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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发元素更新事件
|
// 触发元素更新事件
|
||||||
@@ -188,12 +289,14 @@ window.MeNav = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else if (type === 'social-link') {
|
} else if (type === 'social-link') {
|
||||||
// 更新社交链接
|
// 更新社交链接
|
||||||
if (newData.url) {
|
if (newData.url) {
|
||||||
element.href = newData.url;
|
const safeUrl = menavSanitizeUrl(newData.url, 'updateElement(social-link).url');
|
||||||
element.setAttribute('data-url', newData.url);
|
element.setAttribute('href', safeUrl);
|
||||||
}
|
// 保留原始 URL 供扩展/调试读取,但点击行为以 href 的安全降级为准
|
||||||
|
element.setAttribute('data-url', String(newData.url).trim());
|
||||||
|
}
|
||||||
if (newData.name) {
|
if (newData.name) {
|
||||||
const textElement = element.querySelector('.nav-text');
|
const textElement = element.querySelector('.nav-text');
|
||||||
if (textElement) {
|
if (textElement) {
|
||||||
@@ -204,9 +307,9 @@ window.MeNav = {
|
|||||||
if (newData.icon) {
|
if (newData.icon) {
|
||||||
const iconElement = element.querySelector('i');
|
const iconElement = element.querySelector('i');
|
||||||
if (iconElement) {
|
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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发元素更新事件
|
// 触发元素更新事件
|
||||||
@@ -267,36 +370,40 @@ window.MeNav = {
|
|||||||
const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : '');
|
const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : '');
|
||||||
const siteFaviconUrl = data && data.faviconUrl ? String(data.faviconUrl).trim() : '';
|
const siteFaviconUrl = data && data.faviconUrl ? String(data.faviconUrl).trim() : '';
|
||||||
const siteForceIconModeRaw = data && data.forceIconMode ? String(data.forceIconMode).trim() : '';
|
const siteForceIconModeRaw = data && data.forceIconMode ? String(data.forceIconMode).trim() : '';
|
||||||
const siteForceIconMode =
|
const siteForceIconMode =
|
||||||
siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon'
|
siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon'
|
||||||
? siteForceIconModeRaw
|
? siteForceIconModeRaw
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
newSite.href = siteUrl;
|
const safeSiteUrl = menavSanitizeUrl(siteUrl, 'addElement(site).url');
|
||||||
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
|
const safeSiteIcon = menavSanitizeClassList(siteIcon, 'addElement(site).icon');
|
||||||
newSite.setAttribute('data-tooltip', siteName + (siteDescription ? ' - ' + siteDescription : '')); // 添加自定义 tooltip
|
|
||||||
if (/^https?:\/\//i.test(siteUrl)) {
|
newSite.setAttribute('href', safeSiteUrl);
|
||||||
newSite.target = '_blank';
|
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
|
||||||
newSite.rel = 'noopener';
|
newSite.setAttribute('data-tooltip', siteName + (siteDescription ? ' - ' + siteDescription : '')); // 添加自定义 tooltip
|
||||||
}
|
if (/^https?:\/\//i.test(safeSiteUrl)) {
|
||||||
|
newSite.target = '_blank';
|
||||||
|
newSite.rel = 'noopener';
|
||||||
|
}
|
||||||
|
|
||||||
// 设置数据属性
|
// 设置数据属性
|
||||||
newSite.setAttribute('data-type', 'site');
|
newSite.setAttribute('data-type', 'site');
|
||||||
newSite.setAttribute('data-name', siteName);
|
newSite.setAttribute('data-name', siteName);
|
||||||
newSite.setAttribute('data-url', data.url || '');
|
// 保留原始 URL(data-url)供扩展/调试读取;href 仍会做安全降级
|
||||||
newSite.setAttribute('data-icon', siteIcon);
|
newSite.setAttribute('data-url', String(data.url || '').trim());
|
||||||
if (siteFaviconUrl) newSite.setAttribute('data-favicon-url', siteFaviconUrl);
|
newSite.setAttribute('data-icon', safeSiteIcon);
|
||||||
if (siteForceIconMode) newSite.setAttribute('data-force-icon-mode', siteForceIconMode);
|
if (siteFaviconUrl) newSite.setAttribute('data-favicon-url', siteFaviconUrl);
|
||||||
newSite.setAttribute('data-description', siteDescription);
|
if (siteForceIconMode) newSite.setAttribute('data-force-icon-mode', siteForceIconMode);
|
||||||
|
newSite.setAttribute('data-description', siteDescription);
|
||||||
|
|
||||||
// projects repo 风格:与模板中的 repo 结构保持一致(不走 favicon 逻辑)
|
// projects repo 风格:与模板中的 repo 结构保持一致(不走 favicon 逻辑)
|
||||||
if (siteCardStyle === 'repo') {
|
if (siteCardStyle === 'repo') {
|
||||||
const repoHeader = document.createElement('div');
|
const repoHeader = document.createElement('div');
|
||||||
repoHeader.className = 'repo-header';
|
repoHeader.className = 'repo-header';
|
||||||
|
|
||||||
const repoIcon = document.createElement('i');
|
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');
|
repoIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
const repoTitle = document.createElement('div');
|
const repoTitle = document.createElement('div');
|
||||||
repoTitle.className = 'repo-title';
|
repoTitle.className = 'repo-title';
|
||||||
@@ -417,8 +524,8 @@ window.MeNav = {
|
|||||||
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder';
|
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder';
|
||||||
placeholder.setAttribute('aria-hidden', 'true');
|
placeholder.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
const fallback = document.createElement('i');
|
const fallback = document.createElement('i');
|
||||||
fallback.className = `${siteIcon} icon-fallback`;
|
fallback.className = `${safeSiteIcon} icon-fallback`;
|
||||||
fallback.setAttribute('aria-hidden', 'true');
|
fallback.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
const favicon = document.createElement('img');
|
const favicon = document.createElement('img');
|
||||||
@@ -452,14 +559,14 @@ window.MeNav = {
|
|||||||
iconContainer.appendChild(favicon);
|
iconContainer.appendChild(favicon);
|
||||||
iconContainer.appendChild(fallback);
|
iconContainer.appendChild(fallback);
|
||||||
iconWrapper.appendChild(iconContainer);
|
iconWrapper.appendChild(iconContainer);
|
||||||
} else if (effectiveIconsMode === 'favicon' && siteUrl && /^https?:\/\//i.test(siteUrl)) {
|
} else if (effectiveIconsMode === 'favicon' && safeSiteUrl && /^https?:\/\//i.test(safeSiteUrl)) {
|
||||||
// 根据 icons.region 配置决定优先使用哪个域名
|
// 根据 icons.region 配置决定优先使用哪个域名
|
||||||
const faviconUrlPrimary = iconsRegion === 'cn'
|
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.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(siteUrl)}&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'
|
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.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(siteUrl)}&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');
|
const iconContainer = document.createElement('div');
|
||||||
iconContainer.className = 'icon-container';
|
iconContainer.className = 'icon-container';
|
||||||
@@ -468,8 +575,8 @@ window.MeNav = {
|
|||||||
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder';
|
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder';
|
||||||
placeholder.setAttribute('aria-hidden', 'true');
|
placeholder.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
const fallback = document.createElement('i');
|
const fallback = document.createElement('i');
|
||||||
fallback.className = `${siteIcon} icon-fallback`;
|
fallback.className = `${safeSiteIcon} icon-fallback`;
|
||||||
fallback.setAttribute('aria-hidden', 'true');
|
fallback.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
const favicon = document.createElement('img');
|
const favicon = document.createElement('img');
|
||||||
@@ -522,12 +629,12 @@ window.MeNav = {
|
|||||||
iconContainer.appendChild(favicon);
|
iconContainer.appendChild(favicon);
|
||||||
iconContainer.appendChild(fallback);
|
iconContainer.appendChild(fallback);
|
||||||
iconWrapper.appendChild(iconContainer);
|
iconWrapper.appendChild(iconContainer);
|
||||||
} else {
|
} else {
|
||||||
const iconEl = document.createElement('i');
|
const iconEl = document.createElement('i');
|
||||||
iconEl.className = `${siteIcon} site-icon`;
|
iconEl.className = `${safeSiteIcon} site-icon`;
|
||||||
iconEl.setAttribute('aria-hidden', 'true');
|
iconEl.setAttribute('aria-hidden', 'true');
|
||||||
iconWrapper.appendChild(iconEl);
|
iconWrapper.appendChild(iconEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
newSite.appendChild(iconWrapper);
|
newSite.appendChild(iconWrapper);
|
||||||
newSite.appendChild(contentWrapper);
|
newSite.appendChild(contentWrapper);
|
||||||
@@ -565,16 +672,27 @@ window.MeNav = {
|
|||||||
// 设置数据属性
|
// 设置数据属性
|
||||||
newCategory.setAttribute('data-type', 'category');
|
newCategory.setAttribute('data-type', 'category');
|
||||||
newCategory.setAttribute('data-name', data.name || '未命名分类');
|
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.setAttribute('data-container', 'categories');
|
||||||
|
|
||||||
// 添加内容
|
// 添加内容(用 DOM API 构建,避免 innerHTML 注入)
|
||||||
newCategory.innerHTML = `
|
const titleEl = document.createElement('h2');
|
||||||
<h2 data-editable="category-name"><i class="${data.icon || 'fas fa-folder'}"></i> ${data.name || '未命名分类'}</h2>
|
titleEl.setAttribute('data-editable', 'category-name');
|
||||||
<div class="sites-grid" data-container="sites">
|
const iconEl = document.createElement('i');
|
||||||
<p class="empty-sites">暂无网站</p>
|
iconEl.className = menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).icon');
|
||||||
</div>
|
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
|
// 添加到DOM
|
||||||
parent.appendChild(newCategory);
|
parent.appendChild(newCategory);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
{{#if projectsMeta.heatmap}}
|
{{#if projectsMeta.heatmap}}
|
||||||
<div class="welcome-section-side">
|
<div class="welcome-section-side">
|
||||||
<div class="heatmap-container" title="我的 GitHub 贡献热力图">
|
<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"
|
<img class="heatmap-img"
|
||||||
src="{{projectsMeta.heatmap.imageUrl}}"
|
src="{{projectsMeta.heatmap.imageUrl}}"
|
||||||
alt="Github Chart"
|
alt="Github Chart"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{{#if url}}
|
{{#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}}"
|
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}}"
|
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}}
|
{{/if}} {{#if forceIconMode}}data-force-icon-mode="{{forceIconMode}}" {{/if}}
|
||||||
data-description="{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}"
|
data-description="{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}"
|
||||||
data-tooltip="{{#if name}}{{name}}{{else}}未命名站点{{/if}}{{#if description}} - {{description}}{{else}} - {{extractDomain url}}{{/if}}"
|
data-tooltip="{{#if name}}{{name}}{{else}}未命名站点{{/if}}{{#if description}} - {{description}}{{else}} - {{extractDomain url}}{{/if}}"
|
||||||
{{#if publishedAt}}data-published-at="{{publishedAt}}" {{/if}} {{#if source}}data-source="{{source}}" {{/if}}>
|
{{#if publishedAt}}data-published-at="{{publishedAt}}" {{/if}} {{#if source}}data-source="{{source}}" {{/if}}>
|
||||||
{{!-- articles:首行图标+标题;下方“时间/来源 + 简介”全宽对齐,不被图标列缩进 --}}
|
{{!-- articles:首行图标+标题;下方“时间/来源 + 简介”全宽对齐,不被图标列缩进 --}}
|
||||||
@@ -170,4 +170,4 @@
|
|||||||
{{/ifEquals}}
|
{{/ifEquals}}
|
||||||
{{/ifEquals}}
|
{{/ifEquals}}
|
||||||
</a>
|
</a>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{{#if this}}
|
{{#if this}}
|
||||||
{{#each 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>
|
<i class="{{icon}}" aria-hidden="true"></i>
|
||||||
<span class="nav-text visually-hidden" data-editable="social-link-name">{{name}}</span>
|
<span class="nav-text visually-hidden" data-editable="social-link-name">{{name}}</span>
|
||||||
</a>
|
</a>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
Reference in New Issue
Block a user