From 30d50ebcd788610ba932ae7d88e69d3e6c14e4b8 Mon Sep 17 00:00:00 2001 From: rbetree Date: Fri, 2 Jan 2026 14:58:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E3=80=81=E5=9B=BE=E6=A0=87=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E4=B8=8E=E5=B5=8C=E5=A5=97=E4=BA=A4=E4=BA=92=EF=BC=88#30?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: https://github.com/rbetree/menav/issues/30 - Font Awesome:bootcdn→Cloudflare cdnjs - favicon:gstatic `.com` 失败自动回退 `.cn` - `icons.mode`:修复 `site.yml` 配置未生效(提升到顶层) - 站点级图标覆盖:支持 `faviconUrl` / `forceIconMode`(优先级:`faviconUrl` > `forceIconMode` > `icons.mode`) - 折叠交互:恢复二级分组折叠按钮(桌面端悬停显示) - 新标签页:递归补齐多级 `sites.external` 默认值 --- README.md | 80 +++++-- assets/style.css | 13 ++ config/README.md | 15 ++ config/_default/pages/bookmarks.yml | 2 + config/update-instructions-20260102.md | 102 +++++++++ src/generator.js | 73 ++++++- src/script.js | 92 ++++++-- templates/README.md | 5 + templates/components/group.hbs | 7 +- templates/components/site-card.hbs | 198 ++++++++++++------ templates/layouts/default.hbs | 4 +- test/icons-mode-from-site-yml.node-test.js | 49 +++++ ...-level-sites-external-default.node-test.js | 70 +++++++ 13 files changed, 613 insertions(+), 97 deletions(-) create mode 100644 config/update-instructions-20260102.md create mode 100644 test/icons-mode-from-site-yml.node-test.js create mode 100644 test/multi-level-sites-external-default.node-test.js diff --git a/README.md b/README.md index 19edce0..40e0007 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@

- - [![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.html) [![GitHub stars](https://img.shields.io/github/stars/rbetree/menav)](https://github.com/rbetree/menav/stargazers) [![GitHub forks](https://img.shields.io/github/forks/rbetree/menav)](https://github.com/rbetree/menav/network/members) @@ -45,11 +43,33 @@
点击查看/隐藏更新日志 +### 2026/01/02 + +关联 Issue:[#30](https://github.com/rbetree/menav/issues/30) + +细节见:[`config/update-instructions-20260102.md`](config/update-instructions-20260102.md) + +**1. 外部资源可用性** + +- Font Awesome:bootcdn → cdnjs(Cloudflare),降低被拦截风险 +- favicon:`t3.gstatic.com` 失败自动回退 `t3.gstatic.cn`,提升国内网络可用性 + +**2. 图标模式与站点级覆盖** + +- 修复 `site.yml -> icons.mode` 配置未生效(构建期提升为顶层 `icons.mode`,供模板/运行时统一读取) +- 新增站点级图标覆盖:`faviconUrl` / `forceIconMode: favicon | manual`(优先级:`faviconUrl` > `forceIconMode` > 全局 `icons.mode`) + +**3. 嵌套交互与链接打开** + +- 恢复二级分组折叠入口(桌面端默认隐藏,悬停/收起态显示,避免界面过密) +- 多级结构下递归补齐 `sites[].external` 默认值,保证站点链接默认新标签页打开 + ### 2025/12/27 细节见:[`config/update-instructions-20251227.md`](config/update-instructions-20251227.md) **1. 页面模板差异化改进(Phase 1/Phase 2)** + - 首页判定规则调整:`site.yml -> navigation` 第一项即首页(不再依赖 `home` 页面/ID) - 模板体系整理:通用 `page` + 特殊页 `projects/articles/bookmarks` + 内置 `search-results` - `bookmarks` 标题后追加只读更新时间:`update: YYYY-MM-DD | from: git|mtime` @@ -57,16 +77,19 @@ - `projects`:repo 风格卡片(language/stars/forks 自动抓取)+ 可选 GitHub 贡献热力图 **2. 工作流与时效性数据刷新** + - GitHub Actions 构建前自动执行 `sync-projects` / `sync-articles` - 新增 `schedule` 定时触发刷新(cron 使用 UTC,可在 workflow 中调整) **3. 配置与兼容清理(Breaking)** + - 移除旧版单文件配置 `config.yml/config.yaml` 回退 - 移除独立 `navigation.yml` 回退 - 移除 `pages/home.yml -> 顶层 categories` 与 `home` 子菜单特例 - `navigation[].active` 不再生效(首页/默认打开页始终由 `navigation` 第一项决定) **4. 配置变更(字段新增/减少)** + - 新增: - `site.rss.*`:articles RSS 抓取与缓存配置(用于 `npm run sync-articles`) - `site.github.*`:projects 热力图与仓库元信息抓取缓存配置(用于 `npm run sync-projects`) @@ -74,18 +97,20 @@ - 说明: - “首页”始终由 `site.yml -> navigation` 第一项决定,不要求页面 id 为 `home` - ### 2025/12/23 **1. 侧边栏与导航交互优化** + - 高亮项有子菜单时会自动展开 - 侧边栏 `logo_text` 左侧展示站点 Logo(复用 `site.favicon`) **2. 卡片层级折叠规则调整** + - 仅 1 层分类:一级分类支持下拉/收起 - 2/3 层分类:仅二级标题支持下拉/收起(一级/三级不提供折叠按钮与交互) **3.页面细节** + - 主题蓝调整为 `#7694B9`,统一应用到高亮/渐变/阴影 - 搜索无结果红色状态图标对齐修复(避免图标位置偏移) - `menav.svg` 优化暗色背景可读性(字母颜色加深) @@ -93,17 +118,20 @@ ### 2025/11/09 **1. 默认配置与文档** + - 更新默认配置与项目 Logo,并同步完善 README ### 2025/10/31 **1. 书签导入与嵌套结构** + - 优化书签转换逻辑与分类嵌套结构 - 修复书签转换脚本问题,提升稳定性 ### 2025/10/24 - 2025/10/27 **1. 分类/卡片交互与细节修复** + - 为各结构补齐下拉指示与交互,并新增“分类展开/收起”按钮 - 修复侧边栏切换图标错位、站点卡片悬浮层级遮挡问题 - 调整卡片间距与 category/group 栏样式效果,移除废弃的 `restructure` 命令 @@ -111,13 +139,14 @@ ### 2025/10/18 **1. 图标模式默认行为变更** + - 默认启用 `icons.mode: favicon`,自动根据站点 URL 加载 favicon(失败回退为 Font Awesome 图标) - 如需关闭外部请求并完全使用手动图标,请在 `config/user/site.yml` 中设置: ```yaml # config/user/site.yml icons: - mode: manual # 关闭 favicon 请求,纯手动图标 + mode: manual # 关闭 favicon 请求,纯手动图标 ``` ### 2025/10/14 @@ -129,6 +158,7 @@ icons: ### 2025/07/30 **1. 链接打开行为一致性** + - 统一站点/导航外链为新标签页打开,改善导航体验 ### 2025/07/07 @@ -142,6 +172,7 @@ icons: ### 2025/05/22 **1. MeNav 浏览器扩展支持接口** + - 注入序列化的配置数据供扩展读取(`configJSON`) - 暴露 `window.MeNav` 基础能力与 DOM 数据属性,支持元素精准定位与更新 - 为扩展推送与页面联动打通基础能力 @@ -149,6 +180,7 @@ icons: ### 2025/05/16 **1. MarksVault 浏览器扩展集成** + - 支持与 [MarksVault](https://github.com/rbetree/MarksVault) 浏览器扩展集成 - 使用扩展自动推送书签文件到 MeNav - 自动处理推送的书签文件并更新网站 @@ -156,6 +188,7 @@ icons: ### 2025/05/09 **1. 搜索引擎集成功能** + - 集成Google、Bing、百度搜索引擎 - 通过搜索框图标一键切换不同搜索引擎 - 用户选择保存在本地,下次访问自动应用 @@ -163,6 +196,7 @@ icons: ### 2025/05/08 **1. Handlebars模板系统重构** + - 使用Handlebars模板引擎重构整个前端生成系统 - 实现模块化、组件化的模板结构,包含layouts、pages和components - 改进代码复用,提高可维护性和扩展性 @@ -171,22 +205,26 @@ icons: ### 2025/05/04 **1. 移除双文件配置支持** + - 完全移除了对双文件配置方法的支持 - 简化了配置加载逻辑,现在仅支持模块化配置 ### 2025/05/03 **1. 侧边栏收回功能** + - 添加侧边栏折叠/展开按钮,位于Logo文本右侧 - 侧边栏平滑折叠/展开过渡 **2. 移动端UI优化** + - 修复搜索按钮和侧边栏按钮遮挡问题 - 点击侧边栏导航项后自动收起侧边栏 ### 2025/05/02 **1. 模块化配置** + - 支持将配置拆分为多个文件,便于管理和维护 - 引入配置目录结构,分离页面配置 - 配置统一采用模块化目录结构(`config/user/` / `config/_default/`) @@ -194,22 +232,26 @@ icons: ### 2025/05/01 **1. 页面布局优化** + - 优化了内容区域和侧边栏的间距,确保各种分辨率下内容不会贴近边缘 - 卡片与边框始终保持合理间距,避免在窄屏设备上与滚动条贴边 - 调整了搜索结果区域的边距,与常规分类保持样式一致性 **2. 网站卡片文本优化** + - 为站点卡片标题添加单行文本截断,过长标题显示省略号 - 为站点描述添加两行限制和省略号,保持卡片布局整洁 - 添加卡片悬停提示,方便查看完整信息 **3. 移动端显示增强** + - 优化了移动端卡片尺寸,一屏可显示更多网址 - 图标大小自适应,在小屏幕上更加紧凑 - 为不同尺寸移动设备(768px、480px、400px)提供递进式UI优化 - 减小卡片内边距和元素间距,增加屏幕利用率 **4. 书签导入功能** + - 支持从Chrome、Firefox和Edge浏览器导入HTML格式书签 - 自动处理书签文件,解析文件夹结构和链接 - 图标处理:默认加载站点 favicon;在 manual 模式下保留 Font Awesome 匹配 @@ -257,35 +299,43 @@ menav/ 通过以下步骤快速设置您的个人导航站: 1. 克隆仓库 + ```bash git clone https://github.com/rbetree/menav.git cd menav ``` 2. 安装依赖 + ```bash # 安装依赖 npm install ``` + (本仓库的 GitHub Actions/CI 已改为使用 `npm ci`,以获得更稳定、可复现的依赖安装(基于 `package-lock.json`);本地开发可继续使用 `npm install`,也可直接使用 `npm ci`。) 3. 完成配置(见[设置配置文件](#设置配置文件)) - 4. 导入书签(可选) - 将浏览器导出的HTML格式书签文件放入`bookmarks`目录 - 运行书签处理命令: + ```bash npm run import-bookmarks ``` + - 若希望生成结果保持确定性(便于版本管理,减少时间戳导致的无意义 diff): + ```bash MENAV_BOOKMARKS_DETERMINISTIC=1 npm run import-bookmarks ``` + - 系统会自动将书签转换为配置文件保存到`config/user/pages/bookmarks.yml` - - **注意**:`npm run dev`命令不会自动处理书签文件,必须先手动运行上述命令 - - `npm run dev` 默认会刷新 `articles/projects` 的联网缓存(若你希望离线启动,请使用 `npm run dev:offline`) + +- **注意**:`npm run dev`命令不会自动处理书签文件,必须先手动运行上述命令 +- `npm run dev` 默认会刷新 `articles/projects` 的联网缓存(若你希望离线启动,请使用 `npm run dev:offline`) 5. 构建 + ```bash # 启动开发服务器 npm run dev @@ -345,6 +395,7 @@ npm run format #### 第二步:自定义配置 创建个人配置文件: + - **重要:** 始终创建自己的用户配置文件,不要直接修改默认配置文件 - 完成配置文件(见[设置配置文件](#设置配置文件)) - 提交您的配置文件到仓库 @@ -376,6 +427,7 @@ npm run format 如果您想部署到自己的Web服务器,只需要以下几个步骤: 1. 构建静态网站: + ```bash npm run build ``` @@ -433,13 +485,14 @@ server { > **书签转换依赖 GitHub Actions** > 如果需要使用书签自动推送功能,必须先在 GitHub 仓库中启用 GitHub Actions -> +> > **部署流程**: -> ``` -> 1. 上传书签 → 2. GitHub Actions 处理 → 3. 使用处理完成的代码在 GitHub Pages 自动部署 -> ↓ -> 4. 其他 CI/CD 托管平台检测到变化 → 5. 使用处理完成的代码自动部署 -> ``` +> +> ``` +> 1. 上传书签 → 2. GitHub Actions 处理 → 3. 使用处理完成的代码在 GitHub Pages 自动部署 +> ↓ +> 4. 其他 CI/CD 托管平台检测到变化 → 5. 使用处理完成的代码自动部署 +> ``` 无论选择哪种部署方式,请确保创建并使用您自己的配置文件,而不是直接修改默认配置。 @@ -454,6 +507,7 @@ MeNav 使用模块化配置方式,将配置分散到多个 YAML 文件中, > **🔔 重要提示:** 请务必在`config/user/`目录下创建并使用您自己的配置文件,不要直接修改默认配置文件,以便后续更新项目时不会丢失您的个性化设置。 在加载配置时遵循以下优先级顺序: + 1. `config/user/` (用户配置)(优先级最高) 2. `config/_default/` (默认配置) diff --git a/assets/style.css b/assets/style.css index 3f45969..ef7c5d4 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1275,6 +1275,19 @@ body .content.expanded { color: var(--accent-color); } +/* 分组折叠图标:桌面端默认隐藏,悬停/收起时显示,避免按钮过多 */ +@media (hover: hover) and (pointer: fine) { + .group-header .toggle-icon { + opacity: 0; + transition: opacity 0.2s ease; + } + + .group-header[data-toggle="group"]:hover .toggle-icon, + .group.collapsed > .group-header .toggle-icon { + opacity: 1; + } +} + /* 展开/折叠动画 */ .category-content, .group-content { overflow: visible; diff --git a/config/README.md b/config/README.md index 2fea5af..94b086a 100644 --- a/config/README.md +++ b/config/README.md @@ -106,6 +106,21 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优 - `icons.mode: favicon | manual` - `favicon`:会请求第三方服务(Google)获取站点 favicon,失败自动回退到 Font Awesome 图标 - `manual`:完全使用手动 Font Awesome 图标,不发起外部请求(适合内网/离线/隐私敏感场景) + - 站点级覆盖(可选,写在 `pages/*.yml` 的每个 `sites[]` 节点上): + - `faviconUrl`:为单个站点指定图标链接(可远程或本地相对路径;本地建议以 `assets/` 开头,构建会复制到 `dist/` 同路径),优先级最高 + - `forceIconMode: favicon | manual`:强制该站点使用指定模式(不设置则跟随全局 `icons.mode`) + - 优先级:`faviconUrl` > `forceIconMode` > 全局 `icons.mode` + - 示例: + ```yml + sites: + - name: "Ant Design" + url: "https://ant.design/" + icon: "fas fa-th" + forceIconMode: manual # 强制使用手动图标,绕过 favicon 默认“地球”图标 + - name: "Example" + url: "https://example.com/" + faviconUrl: "https://example.com/favicon.png" # 单站点自定义 favicon + ``` 3. **字体** - `fonts`:单一字体配置项,用于设置全站基础字体(`body` 等) diff --git a/config/_default/pages/bookmarks.yml b/config/_default/pages/bookmarks.yml index 9242563..23d72be 100644 --- a/config/_default/pages/bookmarks.yml +++ b/config/_default/pages/bookmarks.yml @@ -123,6 +123,7 @@ categories: - name: 'Koa' url: 'https://koajs.com/' icon: 'fas fa-leaf' + forceIconMode: manual description: '下一代Node.js框架' - name: 'NestJS' url: 'https://nestjs.com/' @@ -171,6 +172,7 @@ categories: - name: 'Ant Design' url: 'https://ant.design/' icon: 'fas fa-th' + forceIconMode: manual description: '企业级UI设计语言' - name: 'Material Design' url: 'https://material.io/design' diff --git a/config/update-instructions-20260102.md b/config/update-instructions-20260102.md new file mode 100644 index 0000000..b1f7737 --- /dev/null +++ b/config/update-instructions-20260102.md @@ -0,0 +1,102 @@ +# 更新说明(2026-01-02) + +本文档用于说明 Issue #30(外部资源/图标/嵌套交互)相关改动中,**配置层面的新增字段、行为变更与迁移要点**。 + +关联 Issue:https://github.com/rbetree/menav/issues/30 + +最后更新:2026-01-02 + +--- + +## 1. 新增/扩展的配置字段 + +### 1.1 `site.yml -> icons.mode`(站点卡片图标模式 / 隐私) + +用途:控制“站点卡片图标”的全局渲染方式。 + +取值: + +- `favicon`:根据站点 URL 通过第三方服务加载站点 favicon(失败时回退到 Font Awesome 图标) +- `manual`:始终使用配置中的 Font Awesome 图标类名(不发起 favicon 外部请求) + +注意: + +- 该配置位于 `site.yml` 的 `icons:` 节点下(默认示例见 `config/_default/site.yml`)。 +- 配置目录采用“完全替换”策略:若启用 `config/user/`,需要在 `config/user/site.yml` 中设置该字段才会生效。 +- 切换后需要重新生成页面(`npm run build` / `npm run dev`)才能影响生成的 HTML。 + +示例: + +```yml +# config/user/site.yml +icons: + mode: manual +``` + +--- + +### 1.2 `pages/*.yml -> sites[].faviconUrl`(站点级自定义图标链接) + +用途:为单个站点指定图标链接(可远程或本地相对路径),用于兜底“favicon 服务返回默认图标/网络不可达”等情况。 + +说明: + +- `faviconUrl` 优先级最高:一旦设置,将直接使用该图片链接渲染图标。 +- 本地路径建议以 `assets/` 开头;构建时会复制到 `dist/` 同路径,便于离线/内网使用。 + +示例: + +```yml +sites: + - name: '内部系统' + url: 'https://intranet.example/' + faviconUrl: 'assets/icons/intranet.png' +``` + +--- + +### 1.3 `pages/*.yml -> sites[].forceIconMode`(站点级强制图标模式) + +用途:强制该站点使用指定模式(不设置则跟随全局 `icons.mode`)。 + +取值: + +- `favicon`:强制走 favicon(外部请求) +- `manual`:强制走手动图标(不发起 favicon 外部请求) + +优先级: + +- `faviconUrl` > `forceIconMode` > 全局 `icons.mode` + +示例: + +```yml +sites: + - name: 'Ant Design' + url: 'https://ant.design/' + icon: 'fas fa-th' + forceIconMode: manual +``` + +--- + +## 2. 行为变更与修复要点(无需迁移字段) + +### 2.1 `icons.mode` 全局切换修复 + +修复点:此前 `site.yml` 中的 `icons` 没有被提升为顶层 `icons`,导致模板与运行时读取到的仍是默认 `favicon`。本次已修复,`site.yml -> icons.mode` 会被模板/运行时统一读取生效。 + +### 2.2 favicon 双域名回退(`.com` → `.cn`) + +修复点:favicon 默认使用 `t3.gstatic.com`,失败时自动切换 `t3.gstatic.cn` 重试一次,提升国内网络可用性。 + +### 2.3 多级结构站点新标签页打开一致性 + +修复点:多级结构(`subcategories/groups/subgroups`)下站点默认值未递归补齐,导致 `external` 为 `undefined` 时不输出 `target="_blank"`。本次已在生成阶段递归补齐 `sites[].external` 默认 `true`(显式 `external: false` 保持同页打开)。 + +--- + +## 3. 迁移建议(从旧版本升级) + +1. 若使用 `config/user/`:请在 `config/user/site.yml` 中设置 `icons.mode`,然后执行 `npm run build` 重新生成页面。 +2. 若遇到某些站点 favicon 始终显示默认图标:建议对该站点配置 `forceIconMode: manual`(使用 Font Awesome)或提供 `faviconUrl` 指向可靠图片(远程或本地 `assets/`)。 diff --git a/src/generator.js b/src/generator.js index e29d984..004263e 100644 --- a/src/generator.js +++ b/src/generator.js @@ -248,6 +248,8 @@ function loadModularConfig(dirPath) { if (siteConfig.fonts) config.fonts = siteConfig.fonts; if (siteConfig.profile) config.profile = siteConfig.profile; if (siteConfig.social) config.social = siteConfig.social; + // 图标配置(icons.mode)需要作为顶层字段供模板/运行时读取 + if (siteConfig.icons) config.icons = siteConfig.icons; // 优先使用site.yml中的navigation配置 if (siteConfig.navigation) { @@ -334,11 +336,24 @@ function ensureConfigDefaults(config) { site.external = typeof site.external === 'boolean' ? site.external : true; } + // 递归处理多级结构(categories/subcategories/groups/subgroups)下的 sites 默认值 + function processNodeSitesRecursively(node) { + if (!node || typeof node !== 'object') return; + + if (Array.isArray(node.sites)) { + node.sites.forEach(processSiteDefaults); + } + + if (Array.isArray(node.subcategories)) node.subcategories.forEach(processNodeSitesRecursively); + if (Array.isArray(node.groups)) node.groups.forEach(processNodeSitesRecursively); + if (Array.isArray(node.subgroups)) node.subgroups.forEach(processNodeSitesRecursively); + } + // 处理分类默认值的辅助函数 function processCategoryDefaults(category) { category.name = category.name || '未命名分类'; category.sites = category.sites || []; - category.sites.forEach(processSiteDefaults); + processNodeSitesRecursively(category); } // 为所有页面配置中的类别和站点设置默认值 @@ -1465,6 +1480,62 @@ function copyStaticFiles(config) { console.error('Error copying script.js:', e); } + // faviconUrl(站点级自定义图标):若使用本地路径(建议以 assets/ 开头),则复制到 dist 下同路径 + try { + const copied = new Set(); + + const copyLocalAsset = (rawUrl) => { + const raw = String(rawUrl || '').trim(); + if (!raw) return; + if (/^https?:\/\//i.test(raw)) return; + + const rel = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, ''); + if (!rel.startsWith('assets/')) return; + + const normalized = path.posix.normalize(rel); + if (!normalized.startsWith('assets/')) return; + if (copied.has(normalized)) return; + copied.add(normalized); + + const srcPath = path.join(process.cwd(), normalized); + const destPath = path.join(process.cwd(), 'dist', normalized); + if (!fs.existsSync(srcPath)) { + console.warn(`[WARN] faviconUrl 本地文件不存在:${normalized}`); + return; + } + + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.copyFileSync(srcPath, destPath); + }; + + if (config && Array.isArray(config.navigation)) { + config.navigation.forEach(navItem => { + const pageId = navItem && navItem.id ? String(navItem.id) : ''; + if (!pageId) return; + const pageConfig = config[pageId]; + if (!pageConfig || typeof pageConfig !== 'object') return; + + if (Array.isArray(pageConfig.sites)) { + pageConfig.sites.forEach(site => { + if (!site || typeof site !== 'object') return; + copyLocalAsset(site.faviconUrl); + }); + } + + if (Array.isArray(pageConfig.categories)) { + const sites = []; + pageConfig.categories.forEach(category => collectSitesRecursively(category, sites)); + sites.forEach(site => { + if (!site || typeof site !== 'object') return; + copyLocalAsset(site.faviconUrl); + }); + } + }); + } + } catch (e) { + console.error('Error copying faviconUrl assets:', e); + } + // 如果配置了favicon,确保文件存在并复制 if (config.site.favicon) { try { diff --git a/src/script.js b/src/script.js index e097a9d..389be78 100644 --- a/src/script.js +++ b/src/script.js @@ -261,10 +261,16 @@ window.MeNav = { const newSite = document.createElement('a'); newSite.className = siteCardStyle ? `site-card site-card-${siteCardStyle}` : 'site-card'; - const siteName = data.name || '未命名站点'; - const siteUrl = data.url || '#'; - const siteIcon = data.icon || 'fas fa-link'; - const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : ''); + const siteName = data.name || '未命名站点'; + const siteUrl = data.url || '#'; + const siteIcon = data.icon || 'fas fa-link'; + const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : ''); + const siteFaviconUrl = data && data.faviconUrl ? String(data.faviconUrl).trim() : ''; + const siteForceIconModeRaw = data && data.forceIconMode ? String(data.forceIconMode).trim() : ''; + const siteForceIconMode = + siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon' + ? siteForceIconModeRaw + : ''; newSite.href = siteUrl; newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : ''); @@ -276,9 +282,11 @@ window.MeNav = { // 设置数据属性 newSite.setAttribute('data-type', 'site'); newSite.setAttribute('data-name', siteName); - newSite.setAttribute('data-url', data.url || ''); - newSite.setAttribute('data-icon', siteIcon); - newSite.setAttribute('data-description', siteDescription); + newSite.setAttribute('data-url', data.url || ''); + newSite.setAttribute('data-icon', siteIcon); + if (siteFaviconUrl) newSite.setAttribute('data-favicon-url', siteFaviconUrl); + if (siteForceIconMode) newSite.setAttribute('data-force-icon-mode', siteForceIconMode); + newSite.setAttribute('data-description', siteDescription); // projects repo 风格:与模板中的 repo 结构保持一致(不走 favicon 逻辑) if (siteCardStyle === 'repo') { @@ -394,11 +402,46 @@ window.MeNav = { iconsMode = 'favicon'; } - if (iconsMode === 'favicon' && data.url && /^https?:\/\//i.test(data.url)) { - const faviconUrl = `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(data.url)}&size=32`; + const shouldUseCustomFavicon = Boolean(siteFaviconUrl); + const effectiveIconsMode = siteForceIconMode || iconsMode; - const iconContainer = document.createElement('div'); - iconContainer.className = 'icon-container'; + if (shouldUseCustomFavicon) { + const iconContainer = document.createElement('div'); + iconContainer.className = 'icon-container'; + + const placeholder = document.createElement('i'); + placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder'; + placeholder.setAttribute('aria-hidden', 'true'); + + const fallback = document.createElement('i'); + fallback.className = `${siteIcon} icon-fallback`; + fallback.setAttribute('aria-hidden', 'true'); + + const favicon = document.createElement('img'); + favicon.className = 'favicon-icon'; + favicon.src = siteFaviconUrl; + favicon.alt = `${siteName} favicon`; + favicon.loading = 'lazy'; + favicon.addEventListener('load', () => { + favicon.classList.add('loaded'); + placeholder.classList.add('hidden'); + }); + favicon.addEventListener('error', () => { + favicon.classList.add('error'); + placeholder.classList.add('hidden'); + fallback.classList.add('visible'); + }); + + iconContainer.appendChild(placeholder); + iconContainer.appendChild(favicon); + iconContainer.appendChild(fallback); + iconWrapper.appendChild(iconContainer); + } else if (effectiveIconsMode === 'favicon' && siteUrl && /^https?:\/\//i.test(siteUrl)) { + const faviconUrlPrimary = `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`; + const faviconUrlFallback = `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`; + + const iconContainer = document.createElement('div'); + iconContainer.className = 'icon-container'; const placeholder = document.createElement('i'); placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder'; @@ -410,14 +453,21 @@ window.MeNav = { const favicon = document.createElement('img'); favicon.className = 'favicon-icon'; - favicon.src = faviconUrl; + favicon.src = faviconUrlPrimary; favicon.alt = `${siteName} favicon`; - favicon.loading = 'lazy'; + favicon.loading = 'lazy'; + let faviconFallbackTried = false; favicon.addEventListener('load', () => { favicon.classList.add('loaded'); placeholder.classList.add('hidden'); }); favicon.addEventListener('error', () => { + if (!faviconFallbackTried) { + faviconFallbackTried = true; + favicon.src = faviconUrlFallback; + return; + } + favicon.classList.add('error'); placeholder.classList.add('hidden'); fallback.classList.add('visible'); @@ -425,14 +475,14 @@ window.MeNav = { iconContainer.appendChild(placeholder); iconContainer.appendChild(favicon); - iconContainer.appendChild(fallback); - iconWrapper.appendChild(iconContainer); - } else { - const iconEl = document.createElement('i'); - iconEl.className = `${siteIcon} site-icon`; - iconEl.setAttribute('aria-hidden', 'true'); - iconWrapper.appendChild(iconEl); - } + iconContainer.appendChild(fallback); + iconWrapper.appendChild(iconContainer); + } else { + const iconEl = document.createElement('i'); + iconEl.className = `${siteIcon} site-icon`; + iconEl.setAttribute('aria-hidden', 'true'); + iconWrapper.appendChild(iconEl); + } newSite.appendChild(iconWrapper); newSite.appendChild(contentWrapper); diff --git a/templates/README.md b/templates/README.md index eac00cc..2a17619 100644 --- a/templates/README.md +++ b/templates/README.md @@ -300,6 +300,11 @@ categories: 当启用 `icons.mode: favicon`(默认)时,站点卡片会优先显示站点 favicon;当 URL 非 http/https、加载失败或网络受限,则自动回退到 Font Awesome 图标。相关助手:`ifHttpUrl`(条件)与 `encodeURIComponent`(工具)。 +站点级覆盖(可选,写在每个 `sites[]` 节点上): +- `faviconUrl`:为单站点指定图标链接(优先级最高,失败回退到手动图标;本地路径建议以 `assets/` 开头,构建会复制到 `dist/` 同路径) +- `forceIconMode: favicon | manual`:强制该站点使用指定模式(不设置则跟随全局 `icons.mode`) +- 优先级:`faviconUrl` > `forceIconMode` > 全局 `icons.mode` + 示例(与内置组件实现保持一致): ```handlebars diff --git a/templates/components/group.hbs b/templates/components/group.hbs index cf3e076..aafe4b5 100644 --- a/templates/components/group.hbs +++ b/templates/components/group.hbs @@ -4,10 +4,15 @@ data-icon="{{icon}}" data-level="{{#if level}}{{level}}{{else}}3{{/if}}"> -
+
{{name}} + {{#ifCond subgroups '||' sites}} + + + + {{/ifCond}}
diff --git a/templates/components/site-card.hbs b/templates/components/site-card.hbs index 866eb22..52c3435 100644 --- a/templates/components/site-card.hbs +++ b/templates/components/site-card.hbs @@ -1,42 +1,83 @@ {{#if url}} - + {{!-- articles:首行图标+标题;下方“时间/来源 + 简介”全宽对齐,不被图标列缩进 --}} {{#ifEquals type "article"}} -
- -
-

{{#if name}}{{name}}{{else}}未命名站点{{/if}}

-
-
+
+ +
+

{{#if name}}{{name}}{{else}}未命名站点{{/if}}

+
+
{{#ifCond publishedAt '||' source}} @@ -89,29 +130,68 @@ {{/if}}
{{/ifCond}} - {{else}} - + {{else}} +

{{#if name}}{{name}}{{else}}未命名站点{{/if}}

diff --git a/templates/layouts/default.hbs b/templates/layouts/default.hbs index ff89bfd..22e268d 100644 --- a/templates/layouts/default.hbs +++ b/templates/layouts/default.hbs @@ -34,8 +34,8 @@ })(); - - + +
diff --git a/test/icons-mode-from-site-yml.node-test.js b/test/icons-mode-from-site-yml.node-test.js new file mode 100644 index 0000000..3dc1c42 --- /dev/null +++ b/test/icons-mode-from-site-yml.node-test.js @@ -0,0 +1,49 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { loadConfig } = require('../src/generator.js'); + +function withTempCwd(callback) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-icons-mode-test-')); + const originalCwd = process.cwd(); + + try { + process.chdir(tmpDir); + callback(tmpDir); + } finally { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +test('site.yml 的 icons.mode 应提升为顶层 icons.mode(manual 不应被默认 favicon 覆盖)', () => { + withTempCwd((tmpDir) => { + const defaultConfigDir = path.join(tmpDir, 'config', '_default'); + fs.mkdirSync(defaultConfigDir, { recursive: true }); + + fs.writeFileSync( + path.join(defaultConfigDir, 'site.yml'), + ['title: Test', 'icons:', ' mode: manual', ''].join('\n'), + 'utf8' + ); + + const config = loadConfig(); + assert.equal(config.icons.mode, 'manual'); + }); +}); + +test('未配置 icons.mode 时应回退为默认 favicon', () => { + withTempCwd((tmpDir) => { + const defaultConfigDir = path.join(tmpDir, 'config', '_default'); + fs.mkdirSync(defaultConfigDir, { recursive: true }); + + fs.writeFileSync(path.join(defaultConfigDir, 'site.yml'), 'title: Test\n', 'utf8'); + + const config = loadConfig(); + assert.equal(config.icons.mode, 'favicon'); + }); +}); + diff --git a/test/multi-level-sites-external-default.node-test.js b/test/multi-level-sites-external-default.node-test.js new file mode 100644 index 0000000..2464a05 --- /dev/null +++ b/test/multi-level-sites-external-default.node-test.js @@ -0,0 +1,70 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { loadConfig } = require('../src/generator.js'); + +function withTempCwd(callback) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-multilevel-external-test-')); + const originalCwd = process.cwd(); + + try { + process.chdir(tmpDir); + callback(tmpDir); + } finally { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +test('多级结构下 sites.external 未配置时应默认 true,且 external:false 不应被覆盖', () => { + withTempCwd((tmpDir) => { + const defaultConfigDir = path.join(tmpDir, 'config', '_default'); + const defaultPagesDir = path.join(defaultConfigDir, 'pages'); + fs.mkdirSync(defaultPagesDir, { recursive: true }); + + fs.writeFileSync(path.join(defaultConfigDir, 'site.yml'), 'title: Test\n', 'utf8'); + + fs.writeFileSync( + path.join(defaultPagesDir, 'bookmarks.yml'), + [ + 'title: 书签', + 'subtitle: bookmarks', + 'template: bookmarks', + 'categories:', + ' - name: 技术资源', + ' groups:', + ' - name: 组内站点', + ' sites:', + ' - name: GroupSite', + ' url: https://example.com/group', + ' subcategories:', + ' - name: 前端开发', + ' groups:', + ' - name: 框架库', + ' subgroups:', + ' - name: 深层分组', + ' sites:', + ' - name: DeepDefaultExternal', + ' url: https://example.com/deep-default', + ' - name: DeepExternalFalse', + ' url: https://example.com/deep-false', + ' external: false', + '' + ].join('\n'), + 'utf8' + ); + + const config = loadConfig(); + + const groupSite = config.bookmarks.categories[0].groups[0].sites[0]; + assert.equal(groupSite.external, true); + + const deepSites = config.bookmarks.categories[0].subcategories[0].groups[0].subgroups[0].sites; + assert.equal(deepSites[0].external, true); + assert.equal(deepSites[1].external, false); + }); +}); +