fix: 修复外部资源、图标模式与嵌套交互(#30)
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` 默认值
This commit is contained in:
78
README.md
78
README.md
@@ -6,8 +6,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
[](https://www.gnu.org/licenses/agpl-3.0.html)
|
||||
[](https://github.com/rbetree/menav/stargazers)
|
||||
[](https://github.com/rbetree/menav/network/members)
|
||||
@@ -45,11 +43,33 @@
|
||||
<details>
|
||||
<summary>点击查看/隐藏更新日志</summary>
|
||||
|
||||
### 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
|
||||
```
|
||||
@@ -435,11 +487,12 @@ server {
|
||||
> 如果需要使用书签自动推送功能,必须先在 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/` (默认配置)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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` 等)
|
||||
|
||||
@@ -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'
|
||||
|
||||
102
config/update-instructions-20260102.md
Normal file
102
config/update-instructions-20260102.md
Normal file
@@ -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/`)。
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
data-icon="{{icon}}"
|
||||
data-level="{{#if level}}{{level}}{{else}}3{{/if}}">
|
||||
|
||||
<div class="group-header">
|
||||
<div class="group-header"{{#ifCond subgroups '||' sites}} data-toggle="group"{{/ifCond}}>
|
||||
<h{{#if level}}{{add level 1}}{{else}}4{{/if}} data-editable="group-name">
|
||||
<i class="{{icon}}"></i>
|
||||
{{name}}
|
||||
{{#ifCond subgroups '||' sites}}
|
||||
<span class="toggle-icon">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</span>
|
||||
{{/ifCond}}
|
||||
</h{{#if level}}{{add level 1}}{{else}}4{{/if}}>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,42 +1,83 @@
|
||||
{{#if url}}
|
||||
<a href="{{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}}"
|
||||
data-description="{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}"
|
||||
{{#if publishedAt}}data-published-at="{{publishedAt}}"{{/if}}
|
||||
{{#if source}}data-source="{{source}}"{{/if}}>
|
||||
<a href="{{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}}
|
||||
data-description="{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}"
|
||||
{{#if publishedAt}}data-published-at="{{publishedAt}}"{{/if}}
|
||||
{{#if source}}data-source="{{source}}"{{/if}}>
|
||||
{{!-- articles:首行图标+标题;下方“时间/来源 + 简介”全宽对齐,不被图标列缩进 --}}
|
||||
{{#ifEquals type "article"}}
|
||||
<div class="article-card-header">
|
||||
<div class="site-card-icon" aria-hidden="true">
|
||||
{{#ifEquals @root.icons.mode "favicon"}}
|
||||
{{#ifHttpUrl url}}
|
||||
<div class="icon-container">
|
||||
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
|
||||
<img
|
||||
class="favicon-icon"
|
||||
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
|
||||
alt="{{name}} favicon"
|
||||
loading="lazy"
|
||||
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
|
||||
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
|
||||
/>
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
|
||||
</div>
|
||||
{{else}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{/ifHttpUrl}}
|
||||
{{else}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{/ifEquals}}
|
||||
</div>
|
||||
<div class="article-card-title">
|
||||
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card-header">
|
||||
<div class="site-card-icon" aria-hidden="true">
|
||||
{{!-- 站点图标优先级:faviconUrl > forceIconMode > 全局 icons.mode --}}
|
||||
{{#if faviconUrl}}
|
||||
<div class="icon-container">
|
||||
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
|
||||
<img
|
||||
class="favicon-icon"
|
||||
src="{{faviconUrl}}"
|
||||
alt="{{name}} favicon"
|
||||
loading="lazy"
|
||||
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
|
||||
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
|
||||
/>
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#ifEquals forceIconMode "manual"}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{else}}
|
||||
{{#ifEquals forceIconMode "favicon"}}
|
||||
{{#ifHttpUrl url}}
|
||||
<div class="icon-container">
|
||||
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
|
||||
<img
|
||||
class="favicon-icon"
|
||||
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
|
||||
alt="{{name}} favicon"
|
||||
loading="lazy"
|
||||
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
|
||||
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
|
||||
/>
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
|
||||
</div>
|
||||
{{else}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{/ifHttpUrl}}
|
||||
{{else}}
|
||||
{{#ifEquals @root.icons.mode "favicon"}}
|
||||
{{#ifHttpUrl url}}
|
||||
<div class="icon-container">
|
||||
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
|
||||
<img
|
||||
class="favicon-icon"
|
||||
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
|
||||
alt="{{name}} favicon"
|
||||
loading="lazy"
|
||||
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
|
||||
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
|
||||
/>
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
|
||||
</div>
|
||||
{{else}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{/ifHttpUrl}}
|
||||
{{else}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{/ifEquals}}
|
||||
{{/ifEquals}}
|
||||
{{/ifEquals}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="article-card-title">
|
||||
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-card-body">
|
||||
{{#ifCond publishedAt '||' source}}
|
||||
@@ -89,29 +130,68 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/ifCond}}
|
||||
{{else}}
|
||||
<div class="site-card-icon" aria-hidden="true">
|
||||
{{#ifEquals @root.icons.mode "favicon"}}
|
||||
{{#ifHttpUrl url}}
|
||||
<div class="icon-container">
|
||||
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
|
||||
<img
|
||||
class="favicon-icon"
|
||||
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
|
||||
alt="{{name}} favicon"
|
||||
loading="lazy"
|
||||
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
|
||||
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
|
||||
/>
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
|
||||
</div>
|
||||
{{else}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{/ifHttpUrl}}
|
||||
{{else}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{/ifEquals}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="site-card-icon" aria-hidden="true">
|
||||
{{!-- 站点图标优先级:faviconUrl > forceIconMode > 全局 icons.mode --}}
|
||||
{{#if faviconUrl}}
|
||||
<div class="icon-container">
|
||||
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
|
||||
<img
|
||||
class="favicon-icon"
|
||||
src="{{faviconUrl}}"
|
||||
alt="{{name}} favicon"
|
||||
loading="lazy"
|
||||
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
|
||||
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
|
||||
/>
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#ifEquals forceIconMode "manual"}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{else}}
|
||||
{{#ifEquals forceIconMode "favicon"}}
|
||||
{{#ifHttpUrl url}}
|
||||
<div class="icon-container">
|
||||
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
|
||||
<img
|
||||
class="favicon-icon"
|
||||
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
|
||||
alt="{{name}} favicon"
|
||||
loading="lazy"
|
||||
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
|
||||
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
|
||||
/>
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
|
||||
</div>
|
||||
{{else}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{/ifHttpUrl}}
|
||||
{{else}}
|
||||
{{#ifEquals @root.icons.mode "favicon"}}
|
||||
{{#ifHttpUrl url}}
|
||||
<div class="icon-container">
|
||||
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
|
||||
<img
|
||||
class="favicon-icon"
|
||||
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
|
||||
alt="{{name}} favicon"
|
||||
loading="lazy"
|
||||
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
|
||||
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
|
||||
/>
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
|
||||
</div>
|
||||
{{else}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{/ifHttpUrl}}
|
||||
{{else}}
|
||||
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
|
||||
{{/ifEquals}}
|
||||
{{/ifEquals}}
|
||||
{{/ifEquals}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="site-card-content">
|
||||
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.7.2/css/all.min.css">
|
||||
</head>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
|
||||
</head>
|
||||
<body class="loading">
|
||||
<!-- 滚动进度指示条 -->
|
||||
<div class="scroll-progress"></div>
|
||||
|
||||
49
test/icons-mode-from-site-yml.node-test.js
Normal file
49
test/icons-mode-from-site-yml.node-test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
70
test/multi-level-sites-external-default.node-test.js
Normal file
70
test/multi-level-sites-external-default.node-test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user