feat: 页面模板差异化改进 + 配置优化 + 兼容清理 (#29)

- 首页判定:navigation 第一项
- 模板:page/projects/articles/bookmarks/search-results
- bookmarks:update: YYYY-MM-DD | from: git|mtime
- articles:RSS 聚合只读条目 + 分类聚合 + 影子写回结构
- projects:repo 卡片 + 可选热力图 + 自动抓取元信息
- 工作流:构建前 sync + schedule 定时刷新
- 移除兼容:config.yml/config.yaml、navigation.yml、home 特例
- 迁移说明:config/update-instructions.md
This commit is contained in:
rbetree
2025-12-28 00:22:54 +08:00
committed by GitHub
parent 1475a8a0d3
commit 387cd2492e
35 changed files with 2927 additions and 851 deletions

View File

@@ -4,6 +4,9 @@ on:
push: push:
branches: [ main ] branches: [ main ]
workflow_dispatch: workflow_dispatch:
schedule:
# 定时刷新 RSS / projects 仓库元信息GitHub Actions 的 cron 使用 UTC 时区)
- cron: '0 2 * * *'
# 设置GITHUB_TOKEN的权限 # 设置GITHUB_TOKEN的权限
permissions: permissions:
@@ -34,7 +37,7 @@ jobs:
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm install run: npm ci
# --- 书签处理步骤 --- # --- 书签处理步骤 ---
- name: Check for bookmark HTML files - name: Check for bookmark HTML files
@@ -109,14 +112,6 @@ jobs:
fi fi
fi fi
# Also check for legacy navigation file changes
if [ -f config/user/navigation.yml ]; then
if ! git diff --quiet config/user/navigation.yml; then
echo "config/user/navigation.yml has changes, committing..."
git add config/user/navigation.yml
git commit -m "Update navigation to include bookmarks page"
fi
fi
else else
echo "ERROR: config/user/pages/bookmarks.yml does not exist! Bookmark processing may have failed." echo "ERROR: config/user/pages/bookmarks.yml does not exist! Bookmark processing may have failed."
echo "Current directory contents:" echo "Current directory contents:"
@@ -181,8 +176,18 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# --- 网站构建和部署步骤 --- # --- 网站构建和部署步骤 ---
- name: Generate site # 同步时效性数据best-effortprojects 仓库信息、articles RSS 聚合
run: npm run generate # 说明:
# - 同步结果写入 dev/(仓库默认 gitignore仅用于本次构建渲染
# - 同步脚本内部已做 best-effort失败不阻断后续 build
- name: Sync projects (best-effort)
run: npm run sync-projects
- name: Sync articles (best-effort)
run: npm run sync-articles
- name: Build site (clean dist + generate)
run: npm run build
- name: Check favicon - name: Check favicon
run: | run: |

View File

@@ -159,7 +159,7 @@ icons:
**1. 模块化配置** **1. 模块化配置**
- 支持将配置拆分为多个文件,便于管理和维护 - 支持将配置拆分为多个文件,便于管理和维护
- 引入配置目录结构,分离页面配置 - 引入配置目录结构,分离页面配置
- 保持向后兼容性,同时支持传统配置文件 - 配置统一采用模块化目录结构(`config/user/` / `config/_default/`
### 2025/05/01 ### 2025/05/01
@@ -236,6 +236,7 @@ cd menav
# 安装依赖 # 安装依赖
npm install npm install
``` ```
(本仓库的 GitHub Actions/CI 已改为使用 `npm ci`,以获得更稳定、可复现的依赖安装(基于 `package-lock.json`);本地开发可继续使用 `npm install`,也可直接使用 `npm ci`。)
3. 完成配置(见[设置配置文件](#设置配置文件) 3. 完成配置(见[设置配置文件](#设置配置文件)
@@ -250,7 +251,8 @@ npm install
MENAV_BOOKMARKS_DETERMINISTIC=1 npm run import-bookmarks MENAV_BOOKMARKS_DETERMINISTIC=1 npm run import-bookmarks
``` ```
- 系统会自动将书签转换为配置文件保存到`config/user/pages/bookmarks.yml` - 系统会自动将书签转换为配置文件保存到`config/user/pages/bookmarks.yml`
- **注意**`npm run dev`命令不会自动处理书签文件,必须先手动运行上述命令 - **注意**`npm run dev`命令不会自动处理书签文件,必须先手动运行上述命令
- `npm run dev` 默认会刷新 `articles/projects` 的联网缓存(若你希望离线启动,请使用 `npm run dev:offline`
5. 构建 5. 构建
```bash ```bash
@@ -258,6 +260,11 @@ npm install
npm run dev npm run dev
``` ```
```bash
# 离线启动开发服务器(不刷新联网缓存)
npm run dev:offline
```
```bash ```bash
# 生成静态HTML文件 # 生成静态HTML文件
npm run build npm run build
@@ -316,6 +323,8 @@ npm run format
- GitHub Actions会自动检测您的更改 - GitHub Actions会自动检测您的更改
- 构建并部署您的网站 - 构建并部署您的网站
- 部署完成后,您可以在 Settings -> Pages 中找到您的网站地址 - 部署完成后,您可以在 Settings -> Pages 中找到您的网站地址
- 站点内容的“时效性数据”RSS 文章聚合、projects 仓库统计)会由部署工作流在构建前自动刷新
- 也支持定时刷新:默认每天 UTC 02:00 触发一次GitHub Actions cron 使用 UTC北京时间=UTC+8可在 `.github/workflows/deploy.yml` 中调整 `schedule.cron`
**重要: Sync fork后需要手动触发工作流**: **重要: Sync fork后需要手动触发工作流**:
@@ -381,6 +390,16 @@ server {
- 设置构建命令为`npm run build` - 设置构建命令为`npm run build`
- 设置输出目录为`dist` - 设置输出目录为`dist`
> 如果你希望在构建时刷新“时效性数据”RSS 文章聚合、projects 仓库统计),请将构建命令改为:
>
> ```bash
> npm ci && npm run sync-projects && npm run sync-articles && npm run build
> ```
>
> 说明:`sync-*` 会联网抓取并写入 `dev/` 缓存(仓库默认 gitignore同步脚本为 best-effort失败不会阻断后续 `build`。
>
> 备注:`dev/` 只用于构建过程的中间缓存,默认不会被提交到仓库;部署时也只会上传 `dist/`,不会包含 `dev/`。
> **书签转换依赖 GitHub Actions** > **书签转换依赖 GitHub Actions**
> 如果需要使用书签自动推送功能,必须先在 GitHub 仓库中启用 GitHub Actions > 如果需要使用书签自动推送功能,必须先在 GitHub 仓库中启用 GitHub Actions
> >

View File

@@ -19,6 +19,7 @@
--scrollbar-hover-color: rgba(255, 255, 255, 0.25); --scrollbar-hover-color: rgba(255, 255, 255, 0.25);
--accent-color: #7694B9; --accent-color: #7694B9;
--accent-hover: #6684A9; --accent-hover: #6684A9;
--accent-rgb: 118, 148, 185;
--nav-item-color: #a1a2a5; --nav-item-color: #a1a2a5;
--success-color: #4caf50; --success-color: #4caf50;
--error-color: #f44336; --error-color: #f44336;
@@ -38,6 +39,9 @@
--spacing-xl: 2rem; --spacing-xl: 2rem;
--spacing-2xl: 3rem; --spacing-2xl: 3rem;
/* 页面内容最大宽度(用于各页边界一致性) */
--page-max-width: 1300px;
/* Border Radius */ /* Border Radius */
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 8px; --radius-md: 8px;
@@ -76,6 +80,7 @@ body.light-theme {
--scrollbar-hover-color: rgba(0, 0, 0, 0.2); --scrollbar-hover-color: rgba(0, 0, 0, 0.2);
--accent-color: #7694B9; --accent-color: #7694B9;
--accent-hover: #6684A9; --accent-hover: #6684A9;
--accent-rgb: 118, 148, 185;
--nav-item-color: #666666; --nav-item-color: #666666;
--success-color: #4caf50; --success-color: #4caf50;
--error-color: #f44336; --error-color: #f44336;
@@ -921,15 +926,38 @@ body .content.expanded {
display: flex; display: flex;
} }
/* 页面模板容器friends/articles/projects 等):
* .page 是 flex 且 align-items:center如果子元素未显式设置宽度会触发 shrink-to-fit
* 导致分类/网格布局变窄(只剩单列)。 */
.page-template {
width: 100%;
max-width: var(--page-max-width);
margin: 0 auto;
}
/* 欢迎区域 */ /* 欢迎区域 */
.welcome-section { .welcome-section {
width: 100%; width: 100%;
max-width: 1300px; max-width: var(--page-max-width);
margin: 0 auto 2.2rem auto; margin: 0 auto 2.2rem auto;
padding: 0 6rem 0 2rem; padding: 0 var(--spacing-lg);
text-align: left; text-align: left;
position: relative; position: relative;
z-index: 5; z-index: 5;
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--spacing-md);
}
.welcome-section-main {
flex: 1;
min-width: 220px;
}
.welcome-section-side {
flex: 0 0 auto;
} }
.welcome-section h2 { .welcome-section h2 {
@@ -978,6 +1006,26 @@ body .content.expanded {
transition: color 0.3s ease; transition: color 0.3s ease;
} }
/* bookmarks标题后追加“更新时间”小字灰色、只读展示 */
.welcome-title-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.welcome-title-row h2 {
margin: 0;
}
.page-updated-inline {
color: var(--text-muted);
font-size: 0.9rem;
opacity: 0.85;
white-space: nowrap;
}
@keyframes glow { @keyframes glow {
from { from {
filter: drop-shadow(0 0 2px rgba(118, 148, 185, 0.2)) filter: drop-shadow(0 0 2px rgba(118, 148, 185, 0.2))
@@ -994,9 +1042,9 @@ body .content.expanded {
background: linear-gradient(145deg, var(--card-bg-gradient-1), var(--card-bg-gradient-2)); background: linear-gradient(145deg, var(--card-bg-gradient-1), var(--card-bg-gradient-2));
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
padding: var(--spacing-lg); padding: var(--spacing-lg);
margin: 0 var(--spacing-md) var(--spacing-lg) var(--spacing-xl); margin: 0 auto var(--spacing-lg) auto;
width: calc(100% - 3rem); width: 100%;
max-width: 1300px; max-width: var(--page-max-width);
position: relative; position: relative;
z-index: 1; z-index: 1;
opacity: 1; opacity: 1;
@@ -1390,6 +1438,130 @@ body .content.expanded {
width: 100%; width: 100%;
} }
/* projectsGitHub 热力图(标题区右侧,可选) */
.page-template-projects .heatmap-container {
flex-shrink: 0;
opacity: 0.85;
transition: opacity 0.3s ease;
}
.page-template-projects .heatmap-container:hover {
opacity: 1;
}
.page-template-projects .heatmap-img {
display: block;
max-width: 100%;
height: auto;
}
.page-template-projects .sites-grid {
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
/* projects代码仓库风卡片 */
.site-card.site-card-repo {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 1.1rem 1.1rem 1rem;
gap: 0;
}
.site-card.site-card-repo:hover {
transform: translateY(-4px);
}
.site-card.site-card-repo .repo-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 12px;
min-width: 0;
}
.site-card.site-card-repo .repo-icon {
font-size: 1.15rem;
color: var(--nav-item-color);
opacity: 0.85;
transition: color 0.3s ease, opacity 0.3s ease;
flex: 0 0 auto;
}
.site-card.site-card-repo:hover .repo-icon {
color: var(--accent-color);
opacity: 1;
}
.site-card.site-card-repo .repo-title {
font-size: 1.05rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.site-card.site-card-repo .repo-desc {
font-size: 0.9rem;
color: var(--nav-item-color);
opacity: 0.85;
line-height: 1.5;
flex-grow: 1;
margin: 0 0 16px 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.site-card.site-card-repo .repo-stats {
display: flex;
align-items: center;
gap: 16px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
font-size: 0.8rem;
color: var(--nav-item-color);
opacity: 0.85;
}
.site-card.site-card-repo .stat-item {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.site-card.site-card-repo .lang-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
@media (max-width: 600px) {
.welcome-section-with-side {
align-items: flex-start;
}
.welcome-section-with-side .welcome-section-side {
width: 100%;
}
.page-template-projects .heatmap-container {
width: 100%;
overflow: hidden;
}
.page-template-projects .heatmap-img {
/* 移动端优先保证“缩小后完整显示” */
width: 100%;
max-width: 100%;
}
}
/* 网站卡片样式 */ /* 网站卡片样式 */
.site-card { .site-card {
background: linear-gradient(145deg, var(--site-card-bg-gradient-1), var(--site-card-bg-gradient-2)); background: linear-gradient(145deg, var(--site-card-bg-gradient-1), var(--site-card-bg-gradient-2));
@@ -1413,6 +1585,107 @@ body .content.expanded {
overflow: hidden; overflow: hidden;
} }
/* 网站卡片变体projects 大卡片 */
.site-card.site-card-large {
padding: 1.1rem 1.2rem;
gap: 0.9rem;
}
.site-card.site-card-large .site-card-icon {
width: 3.1rem;
height: 3.1rem;
}
.site-card.site-card-large h3 {
font-size: 1.05rem;
font-weight: 600;
}
.site-card.site-card-large p {
font-size: 0.88rem;
}
/* Phase 2articles 页面隐藏“扩展写回结构”,避免与文章条目渲染混淆 */
.menav-extension-shadow {
display: none;
}
/* articles文章元信息日期 + 来源) */
.site-card[data-type="article"] .site-card-meta {
margin: 0 0 8px 0;
font-size: 0.75rem;
color: var(--nav-item-color);
opacity: 0.9;
display: flex;
align-items: center;
gap: 0.4rem;
}
.site-card[data-type="article"] .site-card-meta-sep {
opacity: 0.8;
}
/* articles文章卡片布局首行图标+标题;下方:时间/来源 + 简介 全宽对齐) */
.site-card[data-type="article"] {
display: block;
padding: 1rem 1.1rem;
}
.site-card[data-type="article"] .article-card-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.site-card[data-type="article"] .article-card-title {
min-width: 0;
}
.site-card[data-type="article"] .article-card-body {
margin-top: 0.55rem;
}
.site-card[data-type="article"] h3 {
margin: 0;
}
.site-card[data-type="article"] p {
margin: 0;
}
/* articles桌面端网格固定 4 列(避免 auto-fill 在大屏上过多列导致阅读密度过高) */
.page-template-articles .sites-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1024px) {
.page-template-articles .sites-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
@media (max-width: 480px) {
.page-template-articles .sites-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
}
/* articles标题/描述允许两行显示(更适合多列宽卡片,也适用于搜索结果页) */
.site-card[data-type="article"] h3 {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.site-card[data-type="article"] p {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.site-card:hover { .site-card:hover {
transform: translateY(-3px); transform: translateY(-3px);
background: var(--site-card-hover-bg); background: var(--site-card-hover-bg);
@@ -1778,16 +2051,13 @@ body .content.expanded {
/* 响应式设计 */ /* 响应式设计 */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.welcome-section { .welcome-section {
padding: 0 4rem 0 1.5rem; padding: 0 var(--spacing-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.category { .category {
max-width: 1100px; max-width: 1100px;
margin-bottom: 2.5rem; margin: 0 auto 2.5rem auto;
margin-left: 1.5rem; /* 增加左边距 */
margin-right: 1.5rem; /* 增加右边距 */
width: calc(100% - 3rem); /* 适应新的左右边距 */
} }
} }
@@ -1933,15 +2203,15 @@ body .content.expanded {
/* 分类样式优化 */ /* 分类样式优化 */
.category { .category {
margin: 0 var(--spacing-xs) var(--spacing-lg) var(--spacing-xs); margin: 0 auto var(--spacing-lg) auto;
padding: var(--spacing-md); padding: var(--spacing-md);
width: calc(100% - var(--spacing-sm)); width: 100%;
} }
.sites-grid { .sites-grid {
gap: var(--spacing-sm); gap: var(--spacing-sm);
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.site-card { .site-card {
flex-direction: column; flex-direction: column;
@@ -2029,9 +2299,9 @@ body .content.expanded {
} }
.category { .category {
margin: 0 0.1rem 1.3rem 0.1rem; margin: 0 auto 1.3rem auto;
padding: 0.95rem; padding: 0.95rem;
width: calc(100% - 0.2rem); width: 100%;
} }
.search-container { .search-container {
@@ -2044,10 +2314,10 @@ body .content.expanded {
padding-right: 0.1rem; padding-right: 0.1rem;
} }
.sites-grid { .sites-grid {
gap: 0.5rem; gap: 0.5rem;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.site-card { .site-card {
padding: 0.75rem 0.5rem; padding: 0.75rem 0.5rem;
@@ -2184,9 +2454,9 @@ body .content.expanded {
/* 搜索结果区域 */ /* 搜索结果区域 */
.search-section { .search-section {
width: calc(100% - 2rem); width: 100%;
max-width: 1100px; max-width: var(--page-max-width);
margin: 0 1rem 2.5rem 1rem; margin: 0 auto 2.5rem auto;
position: relative; position: relative;
z-index: 1; z-index: 1;
transform: none !important; transform: none !important;
@@ -2195,12 +2465,32 @@ body .content.expanded {
/* 确保搜索结果中的网格有正确的间距 */ /* 确保搜索结果中的网格有正确的间距 */
.search-section .sites-grid { .search-section .sites-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
gap: 1.2rem;
margin-top: 1rem; margin-top: 1rem;
} }
/* 搜索结果页:按来源页面复用对应网格规则(方案 2复用原卡片 DOM */
#search-results [data-section="projects"] .sites-grid {
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
#search-results [data-section="articles"] .sites-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1024px) {
#search-results [data-section="articles"] .sites-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
@media (max-width: 480px) {
#search-results [data-section="articles"] .sites-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
}
/* 确保搜索结果中的卡片样式一致 */ /* 确保搜索结果中的卡片样式一致 */
.search-section .site-card { .search-section .site-card {
max-width: 100%; max-width: 100%;

View File

@@ -30,15 +30,14 @@ config/
├── _default/ # 默认配置目录 ├── _default/ # 默认配置目录
│ ├── site.yml # 默认网站基础配置(含导航配置) │ ├── site.yml # 默认网站基础配置(含导航配置)
│ └── pages/ # 默认页面配置 │ └── pages/ # 默认页面配置
│ ├── home.yml # 首页默认配置 │ ├── common.yml # 示例默认首页navigation 第一项)
│ ├── projects.yml │ ├── projects.yml # 项目页
│ ├── articles.yml │ ├── articles.yml # 文章页
── friends.yml ── bookmarks.yml # 书签页
│ └── bookmarks.yml
└── user/ # 用户配置目录(覆盖默认配置) └── user/ # 用户配置目录(覆盖默认配置)
├── site.yml # 用户自定义网站配置(含导航配置) ├── site.yml # 用户自定义网站配置(含导航配置)
└── pages/ # 用户自定义页面配置 └── pages/ # 用户自定义页面配置
├── home.yml # 首页用户配置 ├── common.yml # 示例:与 navigation 第一项对应
└── ... └── ...
``` ```
@@ -74,18 +73,17 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优
- 个人资料和社交媒体链接 - 个人资料和社交媒体链接
- 导航菜单配置(侧边栏导航项、页面标题和图标、页面顺序和可见性) - 导航菜单配置(侧边栏导航项、页面标题和图标、页面顺序和可见性)
> **注意**从 v1.x 版本开始,导航配置已合并到 `site.yml` 文件中,不再使用独立的 `navigation.yml` 文件。如果您从旧版本迁移,请将原 `navigation.yml` 的内容移至 `site.yml` 的 `navigation` 字段 > **注意**导航配置仅支持写在 `site.yml` 的 `navigation` 字段
### 页面配置 ### 页面配置
`pages/` 目录下的配置文件定义各个页面的内容: `pages/` 目录下的配置文件定义各个页面的内容:
- `home.yml`: 首页分类和站点列表 - `common.yml`: 示例首页(本质上是普通页面;首页由 navigation 第一项决定,不要求必须叫 home
- `projects.yml`: 项目展示配置 - `projects.yml`: 项目展示配置
- `articles.yml`: 文章列表配置 - `articles.yml`: 文章列表配置
- `friends.yml`: 友情链接配置
- `bookmarks.yml`: 书签页面配置 - `bookmarks.yml`: 书签页面配置
- 自定义页面配置 - 其他自定义页面配置(可按需新增/删除;与 `site.yml -> navigation[].id` 对应)
## 配置详解 ## 配置详解
@@ -120,23 +118,63 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优
- `profile.title` / `profile.subtitle`:分别对应首页顶部主标题与副标题 - `profile.title` / `profile.subtitle`:分别对应首页顶部主标题与副标题
5. **导航** 5. **导航**
- `navigation[]`:页面入口列表,`id` 需唯一,并与 `pages/` 中配置文件名对应(例如 `id: home` 对应 `pages/home.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. **RSSarticles Phase 2**
- `rss.*`:仅用于 `npm run sync-articles`(联网抓取 RSS/Atom 并写入缓存)
- `npm run build` 默认不联网;无缓存时 `articles` 页面会回退到 Phase 1 的站点入口展示
- articles 页面会按 `articles.yml` 的分类进行聚合展示:某分类下配置的来源站点,其文章会显示在该分类下
- 抓取条数默认:每个来源站点抓取最新 8 篇(可通过 `site.yml -> rss.articles.perSite``RSS_ARTICLES_PER_SITE` 调整)
- 默认配置已将 `rss.cacheDir` 设为 `dev`(仓库默认 gitignore避免误提交缓存文件可按需改为自定义目录
- GitHub Pages 部署工作流会在构建前自动执行 `npm run sync-articles`,并支持定时触发(默认每天 UTC 02:00可在 `.github/workflows/deploy.yml` 调整)
7. **GitHubprojects 热力图,可选)**
- `github.username`:你的 GitHub 用户名(用于 projects 页面标题栏右侧贡献热力图)
- `github.heatmapColor`:热力图主题色(不带 `#`,例如 `339af0`
- `github.cacheDir`projects 仓库元信息缓存目录(默认 `dev`,仓库默认 gitignore
- projects 仓库统计信息language/stars/forks`npm run sync-projects` 自动抓取并写入缓存;`npm run build` 默认不联网
- GitHub Pages 部署工作流会在构建前自动执行 `npm run sync-projects`,并支持定时触发(默认每天 UTC 02:00可在 `.github/workflows/deploy.yml` 调整)
### pages/ 页面配置 ### pages/ 页面配置
页面配置位于 `pages/*.yml`,每个文件对应一个页面内容,文件名与导航 `id` 对应: 页面配置位于 `pages/*.yml`,每个文件对应一个页面内容,文件名与导航 `id` 对应:
- `pages/home.yml`:首页(通常是 `categories -> sites` - `pages/common.yml`示例首页(通常是 `categories -> sites`
- `pages/projects.yml` / `articles.yml` / `friends.yml`:示例页面(可按需删改) - `pages/projects.yml` / `articles.yml`:示例页面(可按需删改)
- `pages/bookmarks.yml`:书签页(通常由导入脚本生成,也可以手动维护) - `pages/bookmarks.yml`:书签页(通常由导入脚本生成,也可以手动维护)
> 提示:自定义页面时,先在 `site.yml` 的 `navigation` 中增加一个 `id`,再创建同名的 `pages/<id>.yml`。 > 提示:自定义页面时,先在 `site.yml` 的 `navigation` 中增加一个 `id`,再创建同名的 `pages/<id>.yml`。
> >
> 支持“可删除”:如果 `navigation` 中存在某个页面 `id`,但 `pages/<id>.yml` 不存在,构建仍会生成该页面(标题回退为导航名称、分类为空、模板默认使用通用 `page`)。
>
> 站点描述建议简洁(例如不超过 30 个字符),以保证卡片展示更美观。 > 站点描述建议简洁(例如不超过 30 个字符),以保证卡片展示更美观。
#### 通用 page 页面配置(推荐,用于 friends 等普通页面)
对不需要特殊渲染的页面(例如“友链/朋友”页),建议使用通用 `page` 模板,并保持 `categories -> sites`(可选更深层级):
```yaml
title: 示例页面
subtitle: 示例副标题
template: page
categories:
- name: 示例分类
icon: fas fa-folder
sites:
- name: 示例站点
url: https://example.com
icon: fas fa-link
description: 示例描述
```
兼容说明:
- 若历史配置仍使用顶层 `sites`(旧结构),系统会自动映射为一个分类容器以保持页面结构一致(当前仅对 friends/articles 提供该兼容)。
### 多层级嵌套配置2-4层 ### 多层级嵌套配置2-4层
书签与分类支持 2~4 层嵌套,用于更好组织大量站点。建议直接参考默认示例: 书签与分类支持 2~4 层嵌套,用于更好组织大量站点。建议直接参考默认示例:
@@ -184,7 +222,6 @@ MeNav 配置系统采用“完全替换”策略:只会选择一套目录加
- `site.yml`:站点全局配置(包含 `navigation` 等) - `site.yml`:站点全局配置(包含 `navigation` 等)
- `pages/*.yml`:各页面配置(文件名需与 `navigation.id` 对应) - `pages/*.yml`:各页面配置(文件名需与 `navigation.id` 对应)
- `navigation.yml`:仅在 `site.yml` 未提供 `navigation` 时回退使用(兼容旧版本;推荐迁移到 `site.yml`
## 配置示例 ## 配置示例
@@ -227,21 +264,24 @@ social:
# 导航配置 # 导航配置
navigation: navigation:
- name: "首页" - name: "常用"
icon: "fas fa-home" icon: "fas fa-star"
id: "home" id: "common"
- name: "项目" - name: "项目"
icon: "fas fa-project-diagram" icon: "fas fa-project-diagram"
id: "projects" id: "projects"
- name: "文章" - name: "文章"
icon: "fas fa-book" icon: "fas fa-book"
id: "articles" id: "articles"
- name: "书签"
icon: "fas fa-bookmark"
id: "bookmarks"
``` ```
### 首页配置示例 (home.yml) ### 通用页面配置示例(例如 common.yml
```yaml ```yaml
# 页分类配置 # 页分类配置
categories: categories:
- name: "常用工具" - name: "常用工具"
icon: "fas fa-tools" icon: "fas fa-tools"

View File

@@ -1,40 +1,43 @@
# 默认页面配置(请勿直接修改)。 # 默认页面配置(请勿直接修改)。
# 建议复制到 config/user/pages/articles.yml 并按需调整。 # 建议复制到 config/user/pages/articles.yml 并按需调整。
title: 技术文章 # 页面标题 title: 技术文章 # 页面标题
subtitle: 分享我的技术文章和学习笔记 # 页面副标题 subtitle: RSS 聚合文章列表 # 页面副标题
# 指定使用的模板文件名,现有页面模板可见 templates/pages不含 .hbs # 指定使用的模板文件名,现有页面模板可见 templates/pages不含 .hbs
template: articles template: articles
# 页面分类与站点列表 # 当存在 RSS 缓存时,页面将优先渲染“文章条目卡片”(只读)。
# - 本处的站点列表作为“来源站点”输入url 填站点首页)
# - 显示时会将“该分类下配置的站点”抓取到的文章聚合展示在该分类下
# 重要url 应填写“站点首页 URL”不是某一篇文章链接系统会自动发现 RSS/Atom。
categories: categories:
- name: 最新文章 - name: 个人博客
icon: fas fa-pen # 分类图标 icon: fas fa-rss
sites: sites:
- name: Vue3最佳实践 # 站点名称 - name: 阮一峰的网络日志
icon: fab fa-vuejs # 文章/站点图标 icon: fas fa-pen
description: Vue3组合式API的使用技巧 # 摘要 description: 技术文章与随笔
url: "#" # 链接(示例) url: https://www.ruanyifeng.com/blog/
- name: JavaScript进阶 - name: Coolzr's Blog
icon: fab fa-js icon: fas fa-pen
description: JavaScript高级特性解析 description: 偶尔会写点什么
url: "#" url: https://blog.rzlnb.top/
- name: Git使用技巧 - name: 天仙子
icon: fab fa-git-alt icon: fas fa-pen
description: Git常用命令和工作流 description: tianxianzi
url: "#" url: https://www.tianxianzi.me/
- name: Docker入门 - name: pseudoyu
icon: fab fa-docker icon: fas fa-pen
description: Docker基础知识和实践 description: pseudoyu
url: "#" url: https://www.pseudoyu.com/
- name: 学习笔记 - name: 官方博客
icon: fas fa-book icon: fas fa-rss
sites: sites:
- name: React Hooks - name: GitHub Blog
icon: fab fa-react icon: fab fa-github
description: React Hooks最佳实践 description: GitHub 官方博客(工程/产品/安全)
url: "#" url: https://github.blog/
- name: Node.js实战 - name: Cloudflare Blog
icon: fab fa-node-js icon: fas fa-cloud
description: Node.js服务端开发笔记 description: Cloudflare 工程与安全博客
url: "#" url: https://blog.cloudflare.com/

View File

@@ -1,10 +1,11 @@
# 默认页面配置(请勿直接修改)。 # 默认页面配置(请勿直接修改)。
# 建议复制到 config/user/pages/bookmarks.yml 并按需调整。 # 建议复制到 config/user/pages/bookmarks.yml 并按需调整。
# 说明:该页面通常由“书签导入工具”自动生成,手工修改时请保持字段结构一致。 # 说明:该页面通常由“书签导入工具”自动生成,手工修改时请保持字段结构一致。
title: 我的书签 title: 书签
subtitle: 网页书签收藏 subtitle: bookmarks
# 指定使用的模板文件名,现有页面模板可见 templates/pages不含 .hbs # 指定使用的模板文件名,现有页面模板可见 templates/pages不含 .hbs
# 提示bookmarks 模板页面标题区会自动显示“内容更新YYYY-MM-DDgit|mtime无需额外配置
template: bookmarks template: bookmarks
categories: categories:

View File

@@ -1,15 +1,16 @@
# 默认页面配置(请勿直接修改)。 # 默认页面配置(请勿直接修改)。
# 建议复制到 config/user/pages/home.yml 并按需调整。 # 建议复制到 config/user/pages/common.yml 并按需调整。
title: 欢迎使用 # 页面标题 title: 常用网站 # 页面标题
subtitle: 个人导航站点 # 页面副标题 subtitle: Common website # 页面副标题
# 指定使用的模板文件名,现有页面模板可见 templates/pages不含 .hbs # 指定使用的模板文件名,现有页面模板可见 templates/pages不含 .hbs
template: home # 说明:推荐使用通用模板 page首页由“导航第一项”决定
template: page
# 页面分类与站点列表(可按需增删顺序) # 页面分类与站点列表
categories: categories:
- name: 常用网站 - name: 置顶
icon: fas fa-star # 分类图标Font Awesome icon: fas fa-star # 分类图标
sites: sites:
- name: Linux.do # 站点名称 - name: Linux.do # 站点名称
url: https://linux.do/ # http/https URLfavicon 模式将尝试加载站点图标) url: https://linux.do/ # http/https URLfavicon 模式将尝试加载站点图标)
@@ -50,10 +51,6 @@ categories:
url: https://www.bilibili.com url: https://www.bilibili.com
icon: fas fa-play-circle icon: fas fa-play-circle
description: 视频学习平台 description: 视频学习平台
- name: 知乎
url: https://www.zhihu.com
icon: fas fa-question-circle
description: 问答社区
- name: 掘金 - name: 掘金
url: https://juejin.cn url: https://juejin.cn
icon: fas fa-book icon: fas fa-book
@@ -62,57 +59,6 @@ categories:
url: https://leetcode.cn url: https://leetcode.cn
icon: fas fa-code icon: fas fa-code
description: 算法刷题平台 description: 算法刷题平台
- name: Coursera
url: https://www.coursera.org
icon: fas fa-university
description: 在线课程平台
- name: edX
url: https://www.edx.org
icon: fas fa-graduation-cap
description: 高质量在线教育
- name: Udemy
url: https://www.udemy.com
icon: fas fa-chalkboard-teacher
description: 技能学习平台
- name: MDN Web Docs
url: https://developer.mozilla.org
icon: fas fa-file-code
description: Web开发文档
- name: 开发工具
icon: fas fa-tools
sites:
- name: VS Code
url: https://code.visualstudio.com
icon: fas fa-code
description: 强大的代码编辑器
- name: Postman
url: https://www.postman.com
icon: fas fa-paper-plane
description: API调试工具
- name: Git
url: https://git-scm.com
icon: fab fa-git-alt
description: 版本控制工具
- name: Docker
url: https://www.docker.com
icon: fab fa-docker
description: 容器化平台
- name: JetBrains
url: https://www.jetbrains.com
icon: fas fa-laptop-code
description: 专业开发IDE
- name: npm
url: https://www.npmjs.com
icon: fab fa-npm
description: Node.js包管理器
- name: Webpack
url: https://webpack.js.org
icon: fas fa-box-open
description: 前端打包工具
- name: GitHub Copilot
url: https://github.com/features/copilot
icon: fas fa-robot
description: AI编程助手
- name: 设计资源 - name: 设计资源
icon: fas fa-palette icon: fas fa-palette
sites: sites:
@@ -124,18 +70,10 @@ categories:
url: https://dribbble.com url: https://dribbble.com
icon: fab fa-dribbble icon: fab fa-dribbble
description: 设计师社区 description: 设计师社区
- name: Behance
url: https://www.behance.net
icon: fab fa-behance
description: 创意设计平台
- name: IconFont - name: IconFont
url: https://www.iconfont.cn url: https://www.iconfont.cn
icon: fas fa-icons icon: fas fa-icons
description: 图标资源库 description: 图标资源库
- name: Unsplash
url: https://unsplash.com
icon: fas fa-camera
description: 免费高质量图片
- name: Adobe XD - name: Adobe XD
url: https://www.adobe.com/products/xd.html url: https://www.adobe.com/products/xd.html
icon: fab fa-adobe icon: fab fa-adobe
@@ -167,37 +105,13 @@ categories:
url: https://carbon.now.sh url: https://carbon.now.sh
icon: fas fa-code icon: fas fa-code
description: 代码图片生成器 description: 代码图片生成器
- name: RegExr
url: https://regexr.com
icon: fas fa-search
description: 正则表达式测试
- name: Excalidraw - name: Excalidraw
url: https://excalidraw.com url: https://excalidraw.com
icon: fas fa-pencil-alt icon: fas fa-pencil-alt
description: 手绘风格图表工具 description: 手绘风格图表工具
- name: Notion
url: https://www.notion.so
icon: fas fa-sticky-note
description: 多功能笔记工具
- name: Grammarly
url: https://www.grammarly.com
icon: fas fa-spell-check
description: 英文语法检查工具
- name: 云服务平台 - name: 云服务平台
icon: fas fa-cloud icon: fas fa-cloud
sites: sites:
- name: AWS
url: https://aws.amazon.com
icon: fab fa-aws
description: 亚马逊云服务
- name: Azure
url: https://azure.microsoft.com
icon: fab fa-microsoft
description: 微软云平台
- name: Google Cloud
url: https://cloud.google.com
icon: fab fa-google
description: 谷歌云平台
- name: Cloudflare - name: Cloudflare
url: https://www.cloudflare.com url: https://www.cloudflare.com
icon: fas fa-cloud icon: fas fa-cloud
@@ -210,11 +124,15 @@ categories:
url: https://www.netlify.com url: https://www.netlify.com
icon: fas fa-globe icon: fas fa-globe
description: 静态网站托管 description: 静态网站托管
- name: DigitalOcean - name: AWS
url: https://www.digitalocean.com url: https://aws.amazon.com
icon: fab fa-digital-ocean icon: fab fa-aws
description: 简单云服务 description: 亚马逊云服务
- name: Heroku - name: Azure
url: https://www.heroku.com url: https://azure.microsoft.com
icon: fas fa-h-square icon: fab fa-microsoft
description: 应用部署平台 description: 微软云平台
- name: Google Cloud
url: https://cloud.google.com
icon: fab fa-google
description: 谷歌云平台

View File

@@ -1,36 +0,0 @@
# 默认页面配置(请勿直接修改)。
# 建议复制到 config/user/pages/friends.yml 并按需调整。
title: 友情链接 # 页面标题
subtitle: 优秀的博主和朋友们 # 页面副标题
# 指定使用的模板文件名,现有页面模板可见 templates/pages不含 .hbs
template: friends
# 页面分类与站点列表
categories:
- name: 技术博主
icon: fas fa-user-friends # 分类图标
sites:
- name: 小明的博客 # 站点名称
icon: fas fa-code # 站点图标
description: 全栈开发工程师,分享技术心得 # 描述
url: "#" # 链接(示例)
- name: 小红的前端
icon: fas fa-paint-brush
description: 专注前端开发与设计
url: "#"
- name: 小张的后端
icon: fas fa-server
description: 分享后端开发经验
url: "#"
- name: 技术社区
icon: fas fa-laptop-code
sites:
- name: GitHub
icon: fab fa-github
description: 开源代码托管平台
url: https://github.com
- name: Stack Overflow
icon: fab fa-stack-overflow
description: 程序员问答社区
url: https://stackoverflow.com

View File

@@ -1,36 +1,33 @@
# 默认页面配置(请勿直接修改)。 # 默认页面配置(请勿直接修改)。
# 建议复制到 config/user/pages/projects.yml 并按需调整。 # 建议复制到 config/user/pages/projects.yml 并按需调整。
title: 我的项目 # 页面标题 title: 项目 # 页面标题
subtitle: 这里展示了我的一些个人项目和开源贡献 # 页面副标题 subtitle: 项目展示 # 页面副标题
# 指定使用的模板文件名,现有页面模板可见 templates/pages不含 .hbs # 指定使用的模板文件名,现有页面模板可见 templates/pages不含 .hbs
template: projects template: projects
# 页面分类与站点列表 # 页面分类与站点列表
#
# projects 模板采用“代码仓库风”卡片repo 风格)。
# 统计信息language/stars/forks为自动获取数据
# - 运行 `npm run sync-projects` 会联网抓取 GitHub 仓库信息,并写入 dev/ 缓存(仓库默认 gitignore
# - `npm run build` 默认不联网;缓存缺失时卡片仅展示标题与描述
categories: categories:
- name: 个人项目 - name: 个人项目
icon: fas fa-code # 分类图标Font Awesome icon: fas fa-code # 分类图标Font Awesome
sites: sites:
- name: 个人导航站 # 站点名称 - name: MeNav
icon: fas fa-compass # 手动图标manual 模式显示favicon 模式下作为回退) icon: fab fa-github # 手动图标manual 模式显示favicon 模式下作为回退)
description: 个简洁美观的个人导航页面 # 站点描述 description: 键部署的个人导航站生成器,支持书签导入与自动构建,轻松整理展示您的网络收藏 # 站点描述
url: "#" # 链接(示例) url: https://github.com/rbetree/menav
- name: Todo List - name: MarksVault
icon: fas fa-tasks icon: fab fa-github
description: 基于Vue3的待办事项管理器 description: 一个强大的浏览器扩展,用于智能管理、整理和安全备份您的书签数据
url: "#" url: "https://github.com/rbetree/MarksVault"
- name: 个人博客 - name: star
icon: fas fa-blog icon: fas fa-star
description: 使用Hexo搭建的技术博客
url: "#"
- name: 开源贡献
icon: fas fa-code-branch
sites: sites:
- name: Project A - name: CLIProxyAPI
icon: fab fa-github icon: fab fa-github
description: 开源项目贡献 description: Wrap Gemini CLI, Antigravity, ChatGPT Codex, Claude Code, Qwen Code, iFlow as an OpenAI/Gemini/Claude/Codex compatible API service, allowing you to enjoy the free Gemini 2.5 Pro, GPT 5, Claude, Qwen model through API
url: "#" url: "https://github.com/router-for-me/CLIProxyAPI"
- name: Project B
icon: fab fa-github
description: 开源项目贡献
url: "#"

View File

@@ -32,9 +32,37 @@ fonts:
# 个人资料:显示在首页顶部的欢迎信息 # 个人资料:显示在首页顶部的欢迎信息
profile: profile:
# 注意:首页(导航第一项)标题区优先使用 profile.title/profile.subtitle
# 因此建议把首页希望展示的文案写在这里,避免“改了 pages/<首页id>.yml 但首页不生效”的误会
title: Hello, title: Hello,
subtitle: Welcome to My Navigation subtitle: Welcome to My Navigation
# RSSPhase 2用于 articles 页面文章聚合
# 说明:
# - `npm run build` 默认不联网;仅 `npm run sync-articles` 会联网抓取并写入缓存
# - 缓存目录建议放在 dev/(仓库默认 gitignore避免误提交
rss:
enabled: true
cacheDir: dev
fetch:
timeoutMs: 10000 # 单请求超时(毫秒)
totalTimeoutMs: 60000 # 全流程总超时(毫秒)
concurrency: 5 # 并发抓取站点数
maxRetries: 1 # 单站点重试次数best-effort
maxRedirects: 3 # 最大重定向次数
articles:
perSite: 8 # 单站点最多抓取条数
total: 50 # 全站聚合上限
summaryMaxLength: 200 # 摘要最大长度(字符)
# GitHub用于 projects 页面右侧“贡献热力图”(可选)
# - username你的 GitHub 用户名(例如 torvalds
# - heatmapColor热力图主题色不带 #,例如 339af0
github:
username: "rbetree" # 你的 GitHub 用户名(例如 torvalds为空则 projects 页不展示热力图)
heatmapColor: 339af0
cacheDir: dev # projects 仓库元信息缓存目录(默认 dev仓库默认 gitignore
# 社交媒体链接:显示在侧边栏底部;可按需增删 # 社交媒体链接:显示在侧边栏底部;可按需增删
social: social:
- name: GitHub - name: GitHub
@@ -54,16 +82,13 @@ social:
navigation: navigation:
- name: 常用 # 菜单名称 - name: 常用 # 菜单名称
icon: fas fa-star # Font Awesome 图标类 icon: fas fa-star # Font Awesome 图标类
id: home # 页面标识符(唯一,需与 pages/<id>.yml 对应) id: common # 页面标识符(唯一,需与 pages/<id>.yml 对应)
- name: 项目 - name: 项目
icon: fas fa-project-diagram icon: fas fa-project-diagram
id: projects id: projects
- name: 文章 - name: 文章
icon: fas fa-book icon: fas fa-book
id: articles id: articles
- name: 朋友
icon: fas fa-users
id: friends
- name: 书签 - name: 书签
icon: fas fa-bookmark icon: fas fa-bookmark
id: bookmarks id: bookmarks

View File

@@ -0,0 +1,59 @@
# 更新说明(兼容性移除 / 迁移指南)
本文档说明本次“页面模板差异化改进”阶段,在配置/构建链路上**移除的历史兼容行为**以及如何迁移。
最后更新2025-12-27
---
## 1. 已移除的兼容行为Breaking
### 1.1 不再支持旧版单文件配置 `config.yml` / `config.yaml`
- 旧版本在未发现 `config/user/``config/_default/` 时,会回退读取根目录的 `config.yml` / `config.yaml`
- 当前版本:仅支持模块化配置目录:
- `config/user/`(优先级最高,完全替换)
- `config/_default/`(默认示例)
迁移要点:
- 如果你只有 `config.yml`/`config.yaml`:请将其内容拆分到 `config/user/site.yml``config/user/pages/*.yml`
- 推荐做法:先复制一份默认示例,再按需替换字段:
1) 复制 `config/_default/``config/user/`
2) 修改 `config/user/site.yml``config/user/pages/*.yml`
---
### 1.2 不再支持独立 `navigation.yml`
- 旧版本可能存在 `config/user/navigation.yml`(或 `_default/navigation.yml`)作为导航配置来源。
- 当前版本:导航仅从 `site.yml -> navigation` 读取;不再回退读取独立 `navigation.yml`
迁移要点:
- 把原 `navigation.yml` 的数组内容移动到 `config/user/site.yml``navigation:` 字段下。
- 书签导入流程也只会更新 `config/user/site.yml`,不会再尝试写入/更新 `navigation.yml`
---
### 1.3 不再支持 `pages/home.yml -> 顶层 config.categories` 与 `home` 子菜单特例
- 旧版本存在“首页写死叫 `home`”的遗留逻辑:
- 若存在 `pages/home.yml`,会把其 `categories` 复制到顶层 `config.categories`
- 生成导航子菜单时,若 `nav.id === 'home'`,会优先从 `config.categories` 取分类
- 当前版本:不再维护上述特殊字段/特例;子菜单统一从 `pages/<id>.yml``categories` 读取。
迁移要点:
- 不要依赖 `home` 这个固定 id。
- 首页始终由 `site.yml -> navigation` 的**第一项**决定;其分类应写在对应的 `pages/<homePageId>.yml` 中。
---
## 2. 快速迁移清单(建议按顺序执行)
1. 确保存在 `config/user/site.yml`
2. 确保 `config/user/site.yml` 内包含 `navigation:`(数组)
3. 确保每个 `navigation[].id`(除内置 `search-results`)都有对应的 `config/user/pages/<id>.yml`(可缺省,但缺省时页面内容为空)
4. 若你曾使用 `config.yml/config.yaml`:将其内容迁移到模块化目录
5. 若你曾使用 `navigation.yml`:迁移到 `site.yml -> navigation`,并删除 `navigation.yml`(可选)
> 提示:配置采用“完全替换”策略,一旦存在 `config/user/` 就不会回退到 `config/_default/` 补齐缺失项。

48
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"has-flag": "^5.0.1", "has-flag": "^5.0.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"mime-db": "^1.52.0", "mime-db": "^1.52.0",
"rss-parser": "^3.13.0",
"supports-color": "^9.4.0" "supports-color": "^9.4.0"
}, },
"devDependencies": { "devDependencies": {
@@ -476,6 +477,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/execa": { "node_modules/execa": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -881,6 +891,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/rss-parser": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz",
"integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==",
"license": "MIT",
"dependencies": {
"entities": "^2.0.3",
"xml2js": "^0.5.0"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -902,6 +922,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/sax": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/serve": { "node_modules/serve": {
"version": "14.2.5", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz", "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz",
@@ -1184,6 +1210,28 @@
"funding": { "funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
} }
} }
} }

View File

@@ -6,9 +6,12 @@
"homepage": "https://rbetree.github.io/menav", "homepage": "https://rbetree.github.io/menav",
"scripts": { "scripts": {
"generate": "node src/generator.js", "generate": "node src/generator.js",
"dev": "node src/generator.js && serve dist -l 5173", "dev": "npm run sync-projects && npm run sync-articles && node src/generator.js && serve dist -l 5173",
"dev:offline": "node src/generator.js && serve dist -l 5173",
"clean": "node ./scripts/clean.js", "clean": "node ./scripts/clean.js",
"build": "npm run clean && npm run generate", "build": "npm run clean && npm run generate",
"sync-articles": "node ./scripts/sync-articles.js",
"sync-projects": "node ./scripts/sync-projects.js",
"import-bookmarks": "node src/bookmark-processor.js", "import-bookmarks": "node src/bookmark-processor.js",
"test": "node --test test/*.js", "test": "node --test test/*.js",
"lint": "node --check \"src/generator.js\" && node --check \"src/bookmark-processor.js\" && node --check \"src/script.js\"", "lint": "node --check \"src/generator.js\" && node --check \"src/bookmark-processor.js\" && node --check \"src/script.js\"",
@@ -32,7 +35,8 @@
"has-flag": "^5.0.1", "has-flag": "^5.0.1",
"color-convert": "^2.0.1", "color-convert": "^2.0.1",
"color-name": "^2.0.0", "color-name": "^2.0.0",
"mime-db": "^1.52.0" "mime-db": "^1.52.0",
"rss-parser": "^3.13.0"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.4.2", "prettier": "^3.4.2",

687
scripts/sync-articles.js Normal file
View File

@@ -0,0 +1,687 @@
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const dns = require('node:dns').promises;
const net = require('node:net');
const Parser = require('rss-parser');
const { loadConfig } = require('../src/generator.js');
const DEFAULT_RSS_SETTINGS = {
enabled: true,
cacheDir: 'dev',
fetch: {
timeoutMs: 10_000,
maxRetries: 1,
concurrency: 5,
totalTimeoutMs: 60_000,
maxRedirects: 3,
userAgent: 'MeNavRSSSync/1.0',
htmlMaxBytes: 512 * 1024,
feedMaxBytes: 1024 * 1024
},
articles: {
perSite: 8,
total: 50,
summaryMaxLength: 200
}
};
function parseBooleanEnv(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
const v = String(value).trim().toLowerCase();
if (v === '1' || v === 'true' || v === 'yes' || v === 'y') return true;
if (v === '0' || v === 'false' || v === 'no' || v === 'n') return false;
return fallback;
}
function parseIntegerEnv(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
const n = Number.parseInt(String(value), 10);
return Number.isFinite(n) ? n : fallback;
}
function getRssSettings(config) {
const fromConfig = (config && config.site && config.site.rss && typeof config.site.rss === 'object')
? config.site.rss
: {};
const merged = {
...DEFAULT_RSS_SETTINGS,
...fromConfig,
fetch: {
...DEFAULT_RSS_SETTINGS.fetch,
...(fromConfig.fetch || {})
},
articles: {
...DEFAULT_RSS_SETTINGS.articles,
...(fromConfig.articles || {})
}
};
// 环境变量覆盖(主要给 CI 调试/降级用)
merged.enabled = parseBooleanEnv(process.env.RSS_ENABLED, merged.enabled);
merged.cacheDir = process.env.RSS_CACHE_DIR ? String(process.env.RSS_CACHE_DIR) : merged.cacheDir;
merged.fetch.timeoutMs = parseIntegerEnv(process.env.RSS_FETCH_TIMEOUT, merged.fetch.timeoutMs);
merged.fetch.maxRetries = parseIntegerEnv(process.env.RSS_FETCH_MAX_RETRIES, merged.fetch.maxRetries);
merged.fetch.concurrency = parseIntegerEnv(process.env.RSS_FETCH_CONCURRENCY, merged.fetch.concurrency);
merged.fetch.totalTimeoutMs = parseIntegerEnv(process.env.RSS_TOTAL_TIMEOUT, merged.fetch.totalTimeoutMs);
merged.fetch.maxRedirects = parseIntegerEnv(process.env.RSS_FETCH_MAX_REDIRECTS, merged.fetch.maxRedirects);
merged.articles.perSite = parseIntegerEnv(process.env.RSS_ARTICLES_PER_SITE, merged.articles.perSite);
merged.articles.total = parseIntegerEnv(process.env.RSS_ARTICLES_TOTAL, merged.articles.total);
merged.articles.summaryMaxLength = parseIntegerEnv(
process.env.RSS_SUMMARY_MAX_LENGTH,
merged.articles.summaryMaxLength
);
// 兜底约束:避免奇怪配置导致卡死/爆内存
merged.fetch.timeoutMs = Math.max(1_000, merged.fetch.timeoutMs);
merged.fetch.totalTimeoutMs = Math.max(5_000, merged.fetch.totalTimeoutMs);
merged.fetch.concurrency = Math.max(1, Math.min(20, merged.fetch.concurrency));
merged.fetch.maxRetries = Math.max(0, Math.min(3, merged.fetch.maxRetries));
merged.fetch.maxRedirects = Math.max(0, Math.min(10, merged.fetch.maxRedirects));
merged.articles.perSite = Math.max(1, Math.min(50, merged.articles.perSite));
merged.articles.total = Math.max(1, Math.min(500, merged.articles.total));
merged.articles.summaryMaxLength = Math.max(0, Math.min(2_000, merged.articles.summaryMaxLength));
return merged;
}
function isHttpUrl(url) {
if (!url) return false;
try {
const u = new URL(String(url));
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
function isPrivateIp(ip) {
if (!ip) return true;
if (net.isIP(ip) === 4) {
const parts = ip.split('.').map(n => Number.parseInt(n, 10));
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n) || n < 0 || n > 255)) return true;
const [a, b] = parts;
if (a === 10) return true;
if (a === 127) return true;
if (a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a >= 224) return true; // 组播/保留
return false;
}
if (net.isIP(ip) === 6) {
const normalized = String(ip).toLowerCase();
if (normalized === '::1') return true;
if (normalized.startsWith('fe80:')) return true; // link-local
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // ULA
return false;
}
return true;
}
async function withTimeout(promise, timeoutMs, label) {
let timer;
try {
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} 超时(${timeoutMs}ms`)), timeoutMs);
});
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function assertSafeToFetch(url, timeoutMs) {
const u = new URL(String(url));
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new Error(`仅允许 http/https${u.protocol}`);
}
if (u.username || u.password) {
throw new Error('禁止包含用户名/密码的 URL');
}
const hostname = u.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '0.0.0.0' || hostname === '127.0.0.1' || hostname === '::1') {
throw new Error('禁止访问本机地址');
}
if (hostname.endsWith('.local')) {
throw new Error('禁止访问 .local 域名');
}
if (net.isIP(hostname)) {
if (isPrivateIp(hostname)) throw new Error('禁止访问内网/保留 IP');
return;
}
// 解析域名阻断解析到内网的情况best-effort
const records = await withTimeout(
dns.lookup(hostname, { all: true, verbatim: true }),
Math.min(2_000, timeoutMs),
`DNS 解析 ${hostname}`
);
if (!Array.isArray(records) || records.length === 0) {
throw new Error('DNS 解析失败或无结果');
}
const hasPrivate = records.some(r => isPrivateIp(r.address));
if (hasPrivate) throw new Error('DNS 解析到内网/保留地址,已阻断');
}
function buildHeaders(userAgent) {
return {
'user-agent': userAgent,
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
};
}
async function fetchWithRedirects(url, { timeoutMs, maxRedirects, headers, maxBytes }) {
let current = String(url);
for (let i = 0; i <= maxRedirects; i += 1) {
await assertSafeToFetch(current, timeoutMs);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let response;
try {
response = await fetch(current, {
method: 'GET',
redirect: 'manual',
headers,
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
const status = response.status;
if (status >= 300 && status < 400) {
const location = response.headers.get('location');
if (!location) throw new Error(`重定向缺少 Location${status}`);
current = new URL(location, current).toString();
continue;
}
if (!response.ok) {
throw new Error(`HTTP ${status}`);
}
const text = await readResponseTextWithLimit(response, maxBytes);
return { url: current, response, text };
}
throw new Error(`重定向次数超过上限(${maxRedirects}`);
}
async function readResponseTextWithLimit(response, maxBytes) {
if (!response.body || typeof response.body.getReader !== 'function') {
const text = await response.text();
if (Buffer.byteLength(text, 'utf8') > maxBytes) {
throw new Error('响应体过大');
}
return text;
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let received = 0;
let text = '';
while (true) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > maxBytes) {
try {
reader.cancel();
} catch {
// ignore
}
throw new Error('响应体过大');
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
return text;
}
function extractFeedLinksFromHtml(html, baseUrl) {
const candidates = [];
if (!html) return candidates;
const linkTags = String(html).match(/<link\b[^>]*>/gi) || [];
for (const tag of linkTags) {
const rel = /rel\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1] || '';
if (!/alternate/i.test(rel)) continue;
const type = /type\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1] || '';
const isFeedType = /application\/(rss|atom)\+xml/i.test(type) || /(rss|atom)/i.test(type);
if (!isFeedType) continue;
const href = /href\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1];
if (!href) continue;
try {
const resolved = new URL(href, baseUrl).toString();
if (isHttpUrl(resolved)) candidates.push(resolved);
} catch {
// ignore bad url
}
}
// 简单排序:优先 RSS其次 Atom
const rank = url => (url.includes('atom') ? 2 : 1);
return [...new Set(candidates)].sort((a, b) => rank(a) - rank(b));
}
function buildCommonFeedUrls(siteUrl) {
const common = ['/feed', '/rss.xml', '/rss', '/atom.xml', '/atom', '/feed.xml'];
const out = [];
for (const p of common) {
try {
const u = new URL(p, siteUrl).toString();
out.push(u);
} catch {
// ignore
}
}
return out;
}
async function discoverFeedUrl(siteUrl, settings, deadlineTs) {
const timeRemaining = deadlineTs - Date.now();
if (timeRemaining <= 0) throw new Error('总超时:无法继续发现 RSS');
const homepage = await fetchWithRedirects(siteUrl, {
timeoutMs: Math.min(settings.fetch.timeoutMs, timeRemaining),
maxRedirects: settings.fetch.maxRedirects,
headers: buildHeaders(settings.fetch.userAgent),
maxBytes: settings.fetch.htmlMaxBytes
});
const contentType = homepage.response.headers.get('content-type') || '';
if (/text\/html/i.test(contentType) || /application\/xhtml\+xml/i.test(contentType) || !contentType) {
const candidates = extractFeedLinksFromHtml(homepage.text, homepage.url);
if (candidates.length > 0) {
return candidates[0];
}
}
return null;
}
function stripHtmlToText(input) {
const raw = String(input || '');
const withoutTags = raw.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ');
const decoded = withoutTags
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#0?39;/g, "'")
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, num) => String.fromCodePoint(Number.parseInt(num, 10)));
return decoded.replace(/\s+/g, ' ').trim();
}
function truncateText(text, maxLen) {
if (!maxLen || maxLen <= 0) return '';
const s = String(text || '');
if (s.length <= maxLen) return s;
return s.slice(0, maxLen) + '...';
}
function normalizePublishedAt(item) {
const iso = item && typeof item.isoDate === 'string' ? item.isoDate : '';
if (iso) return iso;
const pub = item && typeof item.pubDate === 'string' ? item.pubDate : '';
if (pub) {
const d = new Date(pub);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
return '';
}
function normalizeArticle(item, sourceSite, settings) {
const title = (item && item.title !== undefined) ? String(item.title).trim() : '';
if (!title) return null;
const link = item && item.link ? String(item.link).trim() : '';
if (!isHttpUrl(link)) return null;
const summaryRaw =
(item && item.contentSnippet) ||
(item && item.summary) ||
(item && item.content) ||
'';
const summaryText = stripHtmlToText(summaryRaw);
const summary = settings.articles.summaryMaxLength
? truncateText(summaryText, settings.articles.summaryMaxLength)
: summaryText;
const publishedAt = normalizePublishedAt(item);
const source = sourceSite && sourceSite.name ? String(sourceSite.name) : '';
const sourceUrl = sourceSite && sourceSite.url ? String(sourceSite.url) : '';
const icon = sourceSite && sourceSite.icon ? String(sourceSite.icon) : 'fas fa-pen';
return {
title,
url: link,
summary,
publishedAt,
source,
// 站点首页 URL用于生成端按分类聚合展示文章 url 为具体文章链接)
sourceUrl,
icon
};
}
async function fetchAndParseFeed(feedUrl, settings, parser, deadlineTs) {
const timeRemaining = deadlineTs - Date.now();
if (timeRemaining <= 0) throw new Error('总超时:无法继续抓取 Feed');
const feed = await fetchWithRedirects(feedUrl, {
timeoutMs: Math.min(settings.fetch.timeoutMs, timeRemaining),
maxRedirects: settings.fetch.maxRedirects,
headers: {
...buildHeaders(settings.fetch.userAgent),
accept: 'application/rss+xml,application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8'
},
maxBytes: settings.fetch.feedMaxBytes
});
const parsed = await parser.parseString(feed.text);
return { feedUrl: feed.url, feedTitle: parsed.title || '', items: Array.isArray(parsed.items) ? parsed.items : [] };
}
async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
const url = sourceSite && sourceSite.url ? String(sourceSite.url) : '';
if (!isHttpUrl(url)) {
return {
site: {
name: sourceSite && sourceSite.name ? String(sourceSite.name) : '',
url,
feedUrl: '',
status: 'skipped',
error: '无效 URL需为 http/https',
fetchedAt: new Date().toISOString()
},
articles: []
};
}
let lastError = null;
const tryOnce = async feedUrl => {
const parsed = await fetchAndParseFeed(feedUrl, settings, parser, deadlineTs);
const normalized = parsed.items
.map(item => normalizeArticle(item, sourceSite, settings))
.filter(Boolean)
.slice(0, settings.articles.perSite);
return { feedUrl: parsed.feedUrl, articles: normalized };
};
const attempt = async () => {
const discovered = await discoverFeedUrl(url, settings, deadlineTs);
const candidates = discovered ? [discovered, ...buildCommonFeedUrls(url)] : buildCommonFeedUrls(url);
for (const candidate of [...new Set(candidates)]) {
try {
// eslint-disable-next-line no-await-in-loop
const res = await tryOnce(candidate);
return res;
} catch (e) {
lastError = e;
}
}
throw lastError || new Error('未找到可用 Feed');
};
const startedAt = Date.now();
for (let i = 0; i <= settings.fetch.maxRetries; i += 1) {
try {
// eslint-disable-next-line no-await-in-loop
const res = await attempt();
return {
site: {
name: sourceSite && sourceSite.name ? String(sourceSite.name) : '',
url,
feedUrl: res.feedUrl,
status: 'success',
error: '',
fetchedAt: new Date().toISOString(),
durationMs: Date.now() - startedAt
},
articles: res.articles
};
} catch (e) {
lastError = e;
}
}
return {
site: {
name: sourceSite && sourceSite.name ? String(sourceSite.name) : '',
url,
feedUrl: '',
status: 'failed',
error: lastError ? String(lastError.message || lastError) : '未知错误',
fetchedAt: new Date().toISOString(),
durationMs: Date.now() - startedAt
},
articles: []
};
}
async function mapWithConcurrency(items, concurrency, worker) {
const results = new Array(items.length);
let nextIndex = 0;
async function runOne() {
while (nextIndex < items.length) {
const currentIndex = nextIndex;
nextIndex += 1;
try {
// eslint-disable-next-line no-await-in-loop
results[currentIndex] = await worker(items[currentIndex], currentIndex);
} catch (e) {
results[currentIndex] = { error: e };
}
}
}
const runners = [];
const count = Math.max(1, Math.min(concurrency, items.length));
for (let i = 0; i < count; i += 1) {
runners.push(runOne());
}
await Promise.all(runners);
return results;
}
function collectSitesRecursively(node, output) {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node.subcategories)) node.subcategories.forEach(child => collectSitesRecursively(child, output));
if (Array.isArray(node.groups)) node.groups.forEach(child => collectSitesRecursively(child, output));
if (Array.isArray(node.subgroups)) node.subgroups.forEach(child => collectSitesRecursively(child, output));
if (Array.isArray(node.sites)) {
node.sites.forEach(site => {
if (site && typeof site === 'object') output.push(site);
});
}
}
function buildFlatSitesFromCategories(categories) {
const out = [];
if (!Array.isArray(categories)) return out;
categories.forEach(category => collectSitesRecursively(category, out));
return out;
}
async function syncArticlesForPage(pageId, pageConfig, config, settings) {
const sourceSites = Array.isArray(pageConfig && pageConfig.sites)
? pageConfig.sites
: buildFlatSitesFromCategories(pageConfig && Array.isArray(pageConfig.categories) ? pageConfig.categories : []);
const startedAt = Date.now();
const deadlineTs = startedAt + settings.fetch.totalTimeoutMs;
const parser = new Parser({
timeout: settings.fetch.timeoutMs
});
const results = await mapWithConcurrency(
sourceSites,
settings.fetch.concurrency,
async site => processSourceSite(site, settings, parser, deadlineTs)
);
const sites = [];
const articles = [];
const seen = new Set();
for (const r of results) {
if (!r || r.error) continue;
if (r.site) sites.push(r.site);
if (Array.isArray(r.articles)) {
for (const a of r.articles) {
if (!a || !a.url) continue;
if (seen.has(a.url)) continue;
seen.add(a.url);
articles.push(a);
}
}
}
articles.sort((a, b) => {
const ta = a.publishedAt ? new Date(a.publishedAt).getTime() : 0;
const tb = b.publishedAt ? new Date(b.publishedAt).getTime() : 0;
return tb - ta;
});
const limitedArticles = articles.slice(0, settings.articles.total);
const successSites = sites.filter(s => s.status === 'success').length;
const failedSites = sites.filter(s => s.status === 'failed').length;
const skippedSites = sites.filter(s => s.status === 'skipped').length;
const cache = {
version: '1.0',
pageId,
generatedAt: new Date().toISOString(),
title: pageConfig && pageConfig.title ? String(pageConfig.title) : '',
sites,
articles: limitedArticles,
stats: {
totalSites: sourceSites.length,
successSites,
failedSites,
skippedSites,
totalArticles: limitedArticles.length,
durationMs: Date.now() - startedAt
}
};
const cacheDir = path.resolve(process.cwd(), settings.cacheDir);
fs.mkdirSync(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, `${pageId}.feed-cache.json`);
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
return { cachePath, cache };
}
function pickArticlesPages(config, onlyPageId) {
const pages = [];
const nav = Array.isArray(config.navigation) ? config.navigation : [];
for (const item of nav) {
const pageId = item && item.id ? String(item.id) : '';
if (!pageId) continue;
if (onlyPageId && pageId !== onlyPageId) continue;
const pageConfig = config[pageId];
if (!pageConfig || typeof pageConfig !== 'object') continue;
const templateName = pageConfig.template ? String(pageConfig.template) : pageId;
if (templateName !== 'articles') continue;
pages.push({ pageId, pageConfig });
}
return pages;
}
async function main() {
const args = process.argv.slice(2);
const pageArgIndex = args.findIndex(a => a === '--page');
const onlyPageId = pageArgIndex >= 0 ? args[pageArgIndex + 1] : null;
const config = loadConfig();
const settings = getRssSettings(config);
if (!settings.enabled) {
console.log('[INFO] RSS 已禁用RSS_ENABLED=false跳过。');
return;
}
const pages = pickArticlesPages(config, onlyPageId);
if (pages.length === 0) {
console.log('[INFO] 未找到需要同步的 articles 页面。');
return;
}
console.log(`[INFO] 准备同步 ${pages.length} 个 articles 页面缓存…`);
for (const { pageId, pageConfig } of pages) {
try {
// eslint-disable-next-line no-await-in-loop
const { cachePath, cache } = await syncArticlesForPage(pageId, pageConfig, config, settings);
console.log(`[INFO] 已生成缓存:${cachePath}articles=${cache.stats.totalArticles}, sites=${cache.stats.totalSites}`);
} catch (e) {
console.warn(`[WARN] 页面 ${pageId} 同步失败:${e.message || e}`);
// best-effort不阻断其他页面/后续 build
}
}
}
if (require.main === module) {
main().catch(err => {
console.error('[ERROR] sync-articles 执行失败:', err);
// best-effort除非是非常规异常否则不阻断 CI此处仍保留非 0 退出码便于本地排查
process.exitCode = 1;
});
}
module.exports = {
getRssSettings,
isPrivateIp,
extractFeedLinksFromHtml,
stripHtmlToText,
normalizeArticle,
buildFlatSitesFromCategories
};

269
scripts/sync-projects.js Normal file
View File

@@ -0,0 +1,269 @@
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const { loadConfig } = require('../src/generator.js');
const DEFAULT_SETTINGS = {
enabled: true,
cacheDir: 'dev',
fetch: {
timeoutMs: 10_000,
concurrency: 4,
userAgent: 'MeNavProjectsSync/1.0'
},
colors: {
url: 'https://raw.githubusercontent.com/ozh/github-colors/master/colors.json',
maxAgeMs: 7 * 24 * 60 * 60 * 1000
}
};
function parseBooleanEnv(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
const v = String(value).trim().toLowerCase();
if (v === '1' || v === 'true' || v === 'yes' || v === 'y') return true;
if (v === '0' || v === 'false' || v === 'no' || v === 'n') return false;
return fallback;
}
function parseIntegerEnv(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
const n = Number.parseInt(String(value), 10);
return Number.isFinite(n) ? n : fallback;
}
function getSettings(config) {
const fromConfig =
config && config.site && config.site.github && typeof config.site.github === 'object' ? config.site.github : {};
const merged = {
...DEFAULT_SETTINGS,
...fromConfig,
fetch: {
...DEFAULT_SETTINGS.fetch,
...(fromConfig.fetch || {})
},
colors: {
...DEFAULT_SETTINGS.colors,
...(fromConfig.colors || {})
}
};
merged.enabled = parseBooleanEnv(process.env.PROJECTS_ENABLED, merged.enabled);
merged.cacheDir = process.env.PROJECTS_CACHE_DIR ? String(process.env.PROJECTS_CACHE_DIR) : merged.cacheDir;
merged.fetch.timeoutMs = parseIntegerEnv(process.env.PROJECTS_FETCH_TIMEOUT, merged.fetch.timeoutMs);
merged.fetch.concurrency = parseIntegerEnv(process.env.PROJECTS_FETCH_CONCURRENCY, merged.fetch.concurrency);
merged.fetch.timeoutMs = Math.max(1_000, merged.fetch.timeoutMs);
merged.fetch.concurrency = Math.max(1, Math.min(10, merged.fetch.concurrency));
return merged;
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function isGithubRepoUrl(url) {
if (!url) return null;
try {
const u = new URL(String(url));
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
if (u.hostname.toLowerCase() !== 'github.com') return null;
const parts = u.pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
const owner = parts[0];
const repo = parts[1].replace(/\.git$/i, '');
if (!owner || !repo) return null;
return { owner, repo, canonicalUrl: `https://github.com/${owner}/${repo}` };
} catch {
return null;
}
}
function collectSitesRecursively(node, output) {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node.subcategories)) node.subcategories.forEach(child => collectSitesRecursively(child, output));
if (Array.isArray(node.groups)) node.groups.forEach(child => collectSitesRecursively(child, output));
if (Array.isArray(node.subgroups)) node.subgroups.forEach(child => collectSitesRecursively(child, output));
if (Array.isArray(node.sites)) node.sites.forEach(site => output.push(site));
}
function findProjectsPages(config) {
const pages = [];
const nav = Array.isArray(config.navigation) ? config.navigation : [];
nav.forEach(item => {
const pageId = item && item.id ? String(item.id) : '';
if (!pageId || !config[pageId]) return;
const page = config[pageId];
const templateName = page && page.template ? String(page.template) : pageId;
if (templateName !== 'projects') return;
pages.push({ pageId, page });
});
return pages;
}
async function fetchJsonWithTimeout(url, { timeoutMs, headers }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} finally {
clearTimeout(timer);
}
}
async function loadLanguageColors(settings, cacheBaseDir) {
const cachePath = path.join(cacheBaseDir, 'github-colors.json');
try {
const stat = fs.existsSync(cachePath) ? fs.statSync(cachePath) : null;
if (stat && stat.mtimeMs && Date.now() - stat.mtimeMs < settings.colors.maxAgeMs) {
const raw = fs.readFileSync(cachePath, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
}
} catch {
// 继续联网抓取
}
try {
const headers = { 'user-agent': settings.fetch.userAgent, accept: 'application/json' };
const colors = await fetchJsonWithTimeout(settings.colors.url, { timeoutMs: settings.fetch.timeoutMs, headers });
if (colors && typeof colors === 'object') {
fs.writeFileSync(cachePath, JSON.stringify(colors, null, 2), 'utf8');
return colors;
}
} catch (error) {
console.warn(`[WARN] 获取语言颜色表失败(将不输出 languageColor${String(error && error.message ? error.message : error)}`);
}
return {};
}
async function fetchRepoMeta(repo, settings, colors) {
const headers = {
'user-agent': settings.fetch.userAgent,
accept: 'application/vnd.github+json'
};
const apiUrl = `https://api.github.com/repos/${repo.owner}/${repo.repo}`;
const data = await fetchJsonWithTimeout(apiUrl, { timeoutMs: settings.fetch.timeoutMs, headers });
const language = data && data.language ? String(data.language) : '';
const stars = data && Number.isFinite(data.stargazers_count) ? data.stargazers_count : null;
const forks = data && Number.isFinite(data.forks_count) ? data.forks_count : null;
let languageColor = '';
if (language && colors && colors[language] && colors[language].color) {
languageColor = String(colors[language].color);
}
return {
url: repo.canonicalUrl,
fullName: data && data.full_name ? String(data.full_name) : `${repo.owner}/${repo.repo}`,
language,
languageColor,
stars,
forks
};
}
async function runPool(items, concurrency, worker) {
const results = [];
let index = 0;
async function runOne() {
while (index < items.length) {
const current = items[index];
index += 1;
// eslint-disable-next-line no-await-in-loop
const result = await worker(current);
if (result) results.push(result);
}
}
const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => runOne());
await Promise.all(runners);
return results;
}
async function main() {
const config = loadConfig();
const settings = getSettings(config);
if (!settings.enabled) {
console.log('[INFO] projects 仓库同步已禁用PROJECTS_ENABLED=false');
return;
}
const cacheBaseDir = path.isAbsolute(settings.cacheDir) ? settings.cacheDir : path.join(process.cwd(), settings.cacheDir);
ensureDir(cacheBaseDir);
const colors = await loadLanguageColors(settings, cacheBaseDir);
const pages = findProjectsPages(config);
if (!pages.length) {
console.log('[INFO] 未找到 template=projects 的页面,跳过同步');
return;
}
for (const { pageId, page } of pages) {
const categories = Array.isArray(page.categories) ? page.categories : [];
const sites = [];
categories.forEach(category => collectSitesRecursively(category, sites));
const repos = sites
.map(site => (site && site.url ? isGithubRepoUrl(site.url) : null))
.filter(Boolean);
const unique = new Map();
repos.forEach(r => unique.set(r.canonicalUrl, r));
const repoList = Array.from(unique.values());
if (!repoList.length) {
console.log(`[INFO] 页面 ${pageId}:未发现 GitHub 仓库链接,跳过`);
continue;
}
let success = 0;
let failed = 0;
const results = await runPool(repoList, settings.fetch.concurrency, async repo => {
try {
const meta = await fetchRepoMeta(repo, settings, colors);
success += 1;
return meta;
} catch (error) {
failed += 1;
console.warn(`[WARN] 拉取失败:${repo.canonicalUrl}${String(error && error.message ? error.message : error)}`);
return null;
}
});
const payload = {
version: '1.0',
pageId,
generatedAt: new Date().toISOString(),
repos: results,
stats: {
totalRepos: repoList.length,
success,
failed
}
};
const cachePath = path.join(cacheBaseDir, `${pageId}.repo-cache.json`);
fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2), 'utf8');
console.log(`[INFO] 页面 ${pageId}:同步完成(成功 ${success} / 失败 ${failed}),写入缓存 ${cachePath}`);
}
}
main().catch(error => {
console.error('[ERROR] projects 同步异常:', error);
process.exitCode = 0; // best-effort不阻断后续 build
});

View File

@@ -17,8 +17,6 @@ const MODULAR_DEFAULT_BOOKMARKS_FILE = 'config/_default/pages/bookmarks.yml';
const USER_SITE_YML = path.join(CONFIG_USER_DIR, 'site.yml'); const USER_SITE_YML = path.join(CONFIG_USER_DIR, 'site.yml');
const DEFAULT_SITE_YML = path.join(CONFIG_DEFAULT_DIR, 'site.yml'); const DEFAULT_SITE_YML = path.join(CONFIG_DEFAULT_DIR, 'site.yml');
const LEGACY_USER_NAV_YML = path.join(CONFIG_USER_DIR, 'navigation.yml');
const LEGACY_DEFAULT_NAV_YML = path.join(CONFIG_DEFAULT_DIR, 'navigation.yml');
function ensureUserConfigInitialized() { function ensureUserConfigInitialized() {
if (fs.existsSync(CONFIG_USER_DIR)) { if (fs.existsSync(CONFIG_USER_DIR)) {
@@ -748,71 +746,9 @@ function updateNavigationWithBookmarks() {
if (result.reason === 'error') { if (result.reason === 'error') {
return { updated: false, target: 'site.yml', reason: 'error', error: result.error }; return { updated: false, target: 'site.yml', reason: 'error', error: result.error };
} }
// 如果 site.yml 无法更新(如 navigation 格式异常),继续尝试旧版 navigation.yml return { updated: false, target: 'site.yml', reason: result.reason };
}
// 2) 兼容旧版:独立 navigation.yml
if (fs.existsSync(LEGACY_USER_NAV_YML)) {
const updated = updateNavigationFile(LEGACY_USER_NAV_YML);
return { updated, target: 'navigation.yml', reason: updated ? 'updated' : 'already_present' };
}
if (fs.existsSync(LEGACY_DEFAULT_NAV_YML)) {
try {
const defaultNavContent = fs.readFileSync(LEGACY_DEFAULT_NAV_YML, 'utf8');
if (!fs.existsSync(CONFIG_USER_DIR)) {
fs.mkdirSync(CONFIG_USER_DIR, { recursive: true });
}
fs.writeFileSync(LEGACY_USER_NAV_YML, defaultNavContent, 'utf8');
const updated = updateNavigationFile(LEGACY_USER_NAV_YML);
return { updated, target: 'navigation.yml', reason: updated ? 'updated' : 'already_present' };
} catch (error) {
return { updated: false, target: 'navigation.yml', reason: 'error', error };
}
}
return { updated: false, target: null, reason: 'no_navigation_config' };
}
// 更新单个导航配置文件
function updateNavigationFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const navConfig = yaml.load(content);
// 检查是否已有书签页面
const hasBookmarksNav = Array.isArray(navConfig) &&
navConfig.some(nav => nav.id === 'bookmarks');
if (!hasBookmarksNav) {
// 添加书签导航项
if (!Array.isArray(navConfig)) {
console.log(`Warning: Navigation config in ${filePath} is not an array, cannot update`);
return false;
}
navConfig.push({
name: '书签',
icon: 'fas fa-bookmark',
id: 'bookmarks'
});
// 更新文件
const updatedYaml = yaml.dump(navConfig, {
indent: 2,
lineWidth: -1,
quotingType: '"'
});
fs.writeFileSync(filePath, updatedYaml, 'utf8');
return true;
}
return false; // 无需更新
} catch (error) {
console.error(`Error updating navigation file ${filePath}:`, error);
return false;
} }
return { updated: false, target: null, reason: 'no_site_yml' };
} }
// 主函数 // 主函数

View File

@@ -1,6 +1,7 @@
const fs = require('fs'); const fs = require('fs');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const path = require('path'); const path = require('path');
const { execFileSync } = require('child_process');
const Handlebars = require('handlebars'); const Handlebars = require('handlebars');
// 导入Handlebars助手函数 // 导入Handlebars助手函数
@@ -96,11 +97,11 @@ function renderTemplate(templateName, data, useLayout = true) {
const genericTemplateContent = fs.readFileSync(genericTemplatePath, 'utf8'); const genericTemplateContent = fs.readFileSync(genericTemplatePath, 'utf8');
const genericTemplate = handlebars.compile(genericTemplateContent); const genericTemplate = handlebars.compile(genericTemplateContent);
// 添加 pageId 到数据中,以便通用模板使用 // 添加 pageId 到数据中,以便通用模板使用(优先保留原 pageId避免回退时语义错位
const enhancedData = { const enhancedData = {
...data, ...data,
pageId: templateName // 确保pageId在模板中可用 pageId: data && data.pageId ? data.pageId : templateName
}; };
// 渲染页面内容 // 渲染页面内容
const pageContent = genericTemplate(enhancedData); const pageContent = genericTemplate(enhancedData);
@@ -255,17 +256,6 @@ function loadModularConfig(dirPath) {
} }
} }
// 如果site.yml中没有navigation配置则回退到独立的navigation.yml
if (!config.navigation || config.navigation.length === 0) {
const navConfigPath = path.join(dirPath, 'navigation.yml');
const navConfig = safeLoadYamlConfig(navConfigPath);
if (navConfig) {
config.navigation = navConfig;
console.log('site.yml 中未找到导航配置,使用独立的 navigation.yml 文件');
console.log('提示:建议将导航配置迁移到 site.yml 中,以便统一管理');
}
}
// 加载页面配置 // 加载页面配置
const pagesPath = path.join(dirPath, 'pages'); const pagesPath = path.join(dirPath, 'pages');
if (fs.existsSync(pagesPath)) { if (fs.existsSync(pagesPath)) {
@@ -280,11 +270,6 @@ function loadModularConfig(dirPath) {
// 提取文件名(不含扩展名)作为配置键 // 提取文件名(不含扩展名)作为配置键
const configKey = path.basename(file, path.extname(file)); const configKey = path.basename(file, path.extname(file));
// 特殊处理home.yml中的categories字段
if (configKey === 'home' && fileConfig.categories) {
config.categories = fileConfig.categories;
}
// 将页面配置添加到主配置对象 // 将页面配置添加到主配置对象
config[configKey] = fileConfig; config[configKey] = fileConfig;
} }
@@ -326,7 +311,6 @@ function ensureConfigDefaults(config) {
result.profile = result.profile || {}; result.profile = result.profile || {};
result.social = result.social || []; result.social = result.social || [];
result.categories = result.categories || [];
// 图标配置默认值 // 图标配置默认值
result.icons = result.icons || {}; result.icons = result.icons || {};
// icons.mode: manual | favicon, 默认 favicon // icons.mode: manual | favicon, 默认 favicon
@@ -367,17 +351,21 @@ function ensureConfigDefaults(config) {
category.sites.forEach(processSiteDefaults); category.sites.forEach(processSiteDefaults);
} }
// 为首页的每个类别和站点设置默认值
result.categories = result.categories || [];
result.categories.forEach(processCategoryDefaults);
// 为所有页面配置中的类别和站点设置默认值 // 为所有页面配置中的类别和站点设置默认值
Object.keys(result).forEach(key => { Object.keys(result).forEach(key => {
const pageConfig = result[key]; const pageConfig = result[key];
// 检查是否是页面配置对象且包含categories数组 // 检查是否是页面配置对象
if (pageConfig && typeof pageConfig === 'object' && Array.isArray(pageConfig.categories)) { if (!pageConfig || typeof pageConfig !== 'object') return;
// 传统结构categories -> sites
if (Array.isArray(pageConfig.categories)) {
pageConfig.categories.forEach(processCategoryDefaults); pageConfig.categories.forEach(processCategoryDefaults);
} }
// 扁平结构sites用于 friends/articles 等“无层级并列卡片”页面)
if (Array.isArray(pageConfig.sites)) {
pageConfig.sites.forEach(processSiteDefaults);
}
}); });
return result; return result;
@@ -412,30 +400,8 @@ function getSubmenuForNavItem(navItem, config) {
return null; return null;
} }
// 首页页面添加子菜单(分类 // 通用处理:任意页面的子菜单生成(基于 pages/<id>.yml 的 categories
if (navItem.id === 'home' && Array.isArray(config.categories)) { if (config[navItem.id] && Array.isArray(config[navItem.id].categories)) return config[navItem.id].categories;
return config.categories;
}
// 书签页面添加子菜单(分类)
else if (navItem.id === 'bookmarks' && config.bookmarks && Array.isArray(config.bookmarks.categories)) {
return config.bookmarks.categories;
}
// 项目页面添加子菜单
else if (navItem.id === 'projects' && config.projects && Array.isArray(config.projects.categories)) {
return config.projects.categories;
}
// 文章页面添加子菜单
else if (navItem.id === 'articles' && config.articles && Array.isArray(config.articles.categories)) {
return config.articles.categories;
}
// 友链页面添加子菜单
else if (navItem.id === 'friends' && config.friends && Array.isArray(config.friends.categories)) {
return config.friends.categories;
}
// 通用处理:任意自定义页面的子菜单生成
else if (config[navItem.id] && config[navItem.id].categories && Array.isArray(config[navItem.id].categories)) {
return config[navItem.id].categories;
}
return null; return null;
} }
@@ -454,6 +420,359 @@ function makeJsonSafeForHtmlScript(jsonString) {
return jsonString.replace(/<\/script/gi, '<\\/script'); return jsonString.replace(/<\/script/gi, '<\\/script');
} }
/**
* 解析页面配置文件路径(优先 user回退 _default
* 注意:仅用于构建期读取文件元信息,不会把路径注入到页面/扩展配置中。
* @param {string} pageId 页面ID与 pages/<id>.yml 文件名对应)
* @returns {string|null} 文件路径或 null
*/
function resolvePageConfigFilePath(pageId) {
if (!pageId) return null;
const candidates = [
path.join(process.cwd(), 'config', 'user', 'pages', `${pageId}.yml`),
path.join(process.cwd(), 'config', 'user', 'pages', `${pageId}.yaml`),
path.join(process.cwd(), 'config', '_default', 'pages', `${pageId}.yml`),
path.join(process.cwd(), 'config', '_default', 'pages', `${pageId}.yaml`),
];
for (const filePath of candidates) {
try {
if (fs.existsSync(filePath)) return filePath;
} catch (e) {
// 忽略 IO 异常,继续尝试下一个候选
}
}
return null;
}
/**
* 尝试获取文件最后一次 git 提交时间ISO 字符串)
* @param {string} filePath 文件路径
* @returns {string|null} ISO 字符串UTC失败返回 null
*/
function tryGetGitLastCommitIso(filePath) {
if (!filePath) return null;
try {
const relativePath = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
const output = execFileSync(
'git',
['log', '-1', '--format=%cI', '--', relativePath],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
);
const raw = String(output || '').trim();
if (!raw) return null;
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
} catch (e) {
return null;
}
}
/**
* 获取文件 mtimeISO 字符串)
* @param {string} filePath 文件路径
* @returns {string|null} ISO 字符串UTC失败返回 null
*/
function tryGetFileMtimeIso(filePath) {
if (!filePath) return null;
try {
const stats = fs.statSync(filePath);
const mtime = stats && stats.mtime ? stats.mtime : null;
if (!(mtime instanceof Date) || Number.isNaN(mtime.getTime())) return null;
return mtime.toISOString();
} catch (e) {
return null;
}
}
/**
* 计算页面配置文件“内容更新时间”(优先 git回退 mtime
* @param {string} pageId 页面ID
* @returns {{updatedAt: string, updatedAtSource: 'git'|'mtime'}|null}
*/
function getPageConfigUpdatedAtMeta(pageId) {
const filePath = resolvePageConfigFilePath(pageId);
if (!filePath) return null;
const gitIso = tryGetGitLastCommitIso(filePath);
if (gitIso) {
return { updatedAt: gitIso, updatedAtSource: 'git' };
}
const mtimeIso = tryGetFileMtimeIso(filePath);
if (mtimeIso) {
return { updatedAt: mtimeIso, updatedAtSource: 'mtime' };
}
return null;
}
/**
* 读取 articles 页面 RSS 缓存Phase 2
* - 缓存默认放在 dev/(仓库默认 gitignore
* - 构建端只读缓存:缓存缺失/损坏时回退到 Phase 1渲染来源站点分类
* @param {string} pageId 页面ID用于支持多个 articles 页面的独立缓存)
* @param {Object} config 全站配置(用于读取 site.rss.cacheDir
* @returns {{items: Array<Object>, meta: Object}|null}
*/
function tryLoadArticlesFeedCache(pageId, config) {
if (!pageId) return null;
const cacheDirFromEnv = process.env.RSS_CACHE_DIR ? String(process.env.RSS_CACHE_DIR) : '';
const cacheDirFromConfig =
config && config.site && config.site.rss && config.site.rss.cacheDir ? String(config.site.rss.cacheDir) : '';
const cacheDir = cacheDirFromEnv || cacheDirFromConfig || 'dev';
const cacheBaseDir = path.isAbsolute(cacheDir) ? cacheDir : path.join(process.cwd(), cacheDir);
const cachePath = path.join(cacheBaseDir, `${pageId}.feed-cache.json`);
if (!fs.existsSync(cachePath)) return null;
try {
const raw = fs.readFileSync(cachePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
const articles = Array.isArray(parsed.articles) ? parsed.articles : [];
const items = articles
.map(a => {
const title = a && a.title ? String(a.title) : '';
const url = a && a.url ? String(a.url) : '';
if (!title || !url) return null;
return {
// 兼容 site-card partial 字段
name: title,
url,
icon: a && a.icon ? String(a.icon) : 'fas fa-pen',
description: a && a.summary ? String(a.summary) : '',
// Phase 2 文章元信息(只读展示)
publishedAt: a && a.publishedAt ? String(a.publishedAt) : '',
source: a && a.source ? String(a.source) : '',
// 文章来源站点首页 URL用于按分类聚合展示旧缓存可能缺失
sourceUrl: a && a.sourceUrl ? String(a.sourceUrl) : '',
// 文章链接通常应在新标签页打开
external: true
};
})
.filter(Boolean);
return {
items,
meta: {
pageId: parsed.pageId || pageId,
generatedAt: parsed.generatedAt || '',
total: parsed.stats && Number.isFinite(parsed.stats.totalArticles) ? parsed.stats.totalArticles : items.length
}
};
} catch (e) {
console.warn(`[WARN] articles 缓存读取失败:${cachePath}(将回退 Phase 1`);
return null;
}
}
function normalizeUrlKey(input) {
if (!input) return '';
try {
const u = new URL(String(input));
const origin = u.origin;
let pathname = u.pathname || '/';
// 统一去掉末尾斜杠(根路径除外),避免 https://a.com 与 https://a.com/ 不匹配
if (pathname !== '/' && pathname.endsWith('/')) pathname = pathname.slice(0, -1);
return `${origin}${pathname}`;
} catch {
return String(input).trim();
}
}
function collectSitesRecursively(node, output) {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node.subcategories)) node.subcategories.forEach(child => collectSitesRecursively(child, output));
if (Array.isArray(node.groups)) node.groups.forEach(child => collectSitesRecursively(child, output));
if (Array.isArray(node.subgroups)) node.subgroups.forEach(child => collectSitesRecursively(child, output));
if (Array.isArray(node.sites)) {
node.sites.forEach(site => {
if (site && typeof site === 'object') output.push(site);
});
}
}
/**
* articles Phase 2按页面配置的“分类”聚合文章展示
* - 规则:某篇文章的 sourceUrl/source 归属到其来源站点pages/articles.yml 中配置的站点)所在的分类
* - 兼容:旧缓存缺少 sourceUrl 时回退使用 source站点名称匹配
* @param {Array<Object>} categories 页面配置 categories可包含更深层级
* @param {Array<Object>} articlesItems Phase 2 文章条目(来自缓存)
* @returns {Array<{name: string, icon: string, items: Array<Object>}>}
*/
function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
const safeItems = Array.isArray(articlesItems) ? articlesItems : [];
const safeCategories = Array.isArray(categories) ? categories : [];
// 若页面未配置分类,则回退为单一分类容器
if (safeCategories.length === 0) {
return [
{
name: '最新文章',
icon: 'fas fa-rss',
items: safeItems
}
];
}
const categoryIndex = safeCategories.map(category => {
const sites = [];
collectSitesRecursively(category, sites);
const siteUrlKeys = new Set();
const siteNameKeys = new Set();
sites.forEach(site => {
const urlKey = normalizeUrlKey(site && site.url ? String(site.url) : '');
if (urlKey) siteUrlKeys.add(urlKey);
const nameKey = site && site.name ? String(site.name).trim().toLowerCase() : '';
if (nameKey) siteNameKeys.add(nameKey);
});
return { category, siteUrlKeys, siteNameKeys };
});
const buckets = categoryIndex.map(() => []);
const uncategorized = [];
safeItems.forEach(item => {
const sourceUrlKey = normalizeUrlKey(item && item.sourceUrl ? String(item.sourceUrl) : '');
const sourceNameKey = item && item.source ? String(item.source).trim().toLowerCase() : '';
let matchedIndex = -1;
if (sourceUrlKey) {
matchedIndex = categoryIndex.findIndex(idx => idx.siteUrlKeys.has(sourceUrlKey));
}
if (matchedIndex < 0 && sourceNameKey) {
matchedIndex = categoryIndex.findIndex(idx => idx.siteNameKeys.has(sourceNameKey));
}
if (matchedIndex < 0) {
uncategorized.push(item);
return;
}
buckets[matchedIndex].push(item);
});
const displayCategories = categoryIndex.map((idx, i) => ({
name: idx.category && idx.category.name ? String(idx.category.name) : '未命名分类',
icon: idx.category && idx.category.icon ? String(idx.category.icon) : 'fas fa-rss',
items: buckets[i]
}));
if (uncategorized.length > 0) {
displayCategories.push({
name: '其他',
icon: 'fas fa-ellipsis-h',
items: uncategorized
});
}
return displayCategories;
}
function tryLoadProjectsRepoCache(pageId, config) {
if (!pageId) return null;
const cacheDirFromEnv = process.env.PROJECTS_CACHE_DIR ? String(process.env.PROJECTS_CACHE_DIR) : '';
const cacheDirFromConfig =
config && config.site && config.site.github && config.site.github.cacheDir ? String(config.site.github.cacheDir) : '';
const cacheDir = cacheDirFromEnv || cacheDirFromConfig || 'dev';
const cacheBaseDir = path.isAbsolute(cacheDir) ? cacheDir : path.join(process.cwd(), cacheDir);
const cachePath = path.join(cacheBaseDir, `${pageId}.repo-cache.json`);
if (!fs.existsSync(cachePath)) return null;
try {
const raw = fs.readFileSync(cachePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
const repos = Array.isArray(parsed.repos) ? parsed.repos : [];
const map = new Map();
repos.forEach(r => {
const url = r && r.url ? String(r.url) : '';
if (!url) return;
map.set(url, {
language: r && r.language ? String(r.language) : '',
languageColor: r && r.languageColor ? String(r.languageColor) : '',
stars: Number.isFinite(r && r.stars) ? r.stars : null,
forks: Number.isFinite(r && r.forks) ? r.forks : null
});
});
return {
map,
meta: {
pageId: parsed.pageId || pageId,
generatedAt: parsed.generatedAt || ''
}
};
} catch (e) {
console.warn(`[WARN] projects 缓存读取失败:${cachePath}(将仅展示标题与描述)`);
return null;
}
}
function normalizeGithubRepoUrl(url) {
if (!url) return '';
try {
const u = new URL(String(url));
if (u.hostname.toLowerCase() !== 'github.com') return '';
const parts = u.pathname.split('/').filter(Boolean);
if (parts.length < 2) return '';
const owner = parts[0];
const repo = parts[1].replace(/\.git$/i, '');
if (!owner || !repo) return '';
return `https://github.com/${owner}/${repo}`;
} catch {
return '';
}
}
function applyRepoMetaToCategories(categories, repoMetaMap) {
if (!Array.isArray(categories) || !(repoMetaMap instanceof Map)) return;
const walk = (node) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node.subcategories)) node.subcategories.forEach(walk);
if (Array.isArray(node.groups)) node.groups.forEach(walk);
if (Array.isArray(node.subgroups)) node.subgroups.forEach(walk);
if (Array.isArray(node.sites)) {
node.sites.forEach(site => {
if (!site || typeof site !== 'object' || !site.url) return;
const canonical = normalizeGithubRepoUrl(site.url);
if (!canonical) return;
const meta = repoMetaMap.get(canonical);
if (!meta) return;
site.language = meta.language || '';
site.languageColor = meta.languageColor || '';
site.stars = meta.stars;
site.forks = meta.forks;
});
}
};
categories.forEach(walk);
}
/** /**
* 准备渲染数据,添加模板所需的特殊属性 * 准备渲染数据,添加模板所需的特殊属性
* @param {Object} config 配置对象 * @param {Object} config 配置对象
@@ -527,8 +846,7 @@ function loadConfig() {
navigation: [], navigation: [],
fonts: {}, fonts: {},
profile: {}, profile: {},
social: [], social: []
categories: []
}; };
// 检查模块化配置来源是否存在 // 检查模块化配置来源是否存在
@@ -556,20 +874,10 @@ function loadConfig() {
// 2. 次高优先级: config/_default/ 目录 // 2. 次高优先级: config/_default/ 目录
config = loadModularConfig('config/_default'); config = loadModularConfig('config/_default');
} else { } else {
// 3. 最低优先级: 旧版单文件配置 (config.yml or config.yaml) console.error('[ERROR] 未找到可用配置:缺少 config/user/ 或 config/_default/。');
const legacyConfigPath = fs.existsSync('config.yml') ? 'config.yml' : 'config.yaml'; console.error('[ERROR] 本版本已不再支持旧版单文件配置(config.yml / config.yaml)。');
console.error('[ERROR] 解决方法:使用模块化配置目录(建议从 config/_default/ 复制到 config/user/ 再修改)。');
if (fs.existsSync(legacyConfigPath)) { process.exit(1);
try {
const fileContent = fs.readFileSync(legacyConfigPath, 'utf8');
config = yaml.load(fileContent);
} catch (e) {
console.error(`Error loading configuration from ${legacyConfigPath}:`, e);
}
} else {
console.error('No configuration found. Please create a configuration file.');
process.exit(1);
}
} }
// 确保配置有默认值并通过验证 // 确保配置有默认值并通过验证
@@ -689,8 +997,10 @@ function generatePageContent(pageId, data) {
console.error(`Missing data for page: ${pageId}`); console.error(`Missing data for page: ${pageId}`);
return ` return `
<div class="welcome-section"> <div class="welcome-section">
<h2>页面未配置</h2> <div class="welcome-section-main">
<p class="subtitle">请配置 ${pageId} 页面</p> <h2>页面未配置</h2>
<p class="subtitle">请配置 ${pageId} 页面</p>
</div>
</div>`; </div>`;
} }
@@ -700,8 +1010,10 @@ function generatePageContent(pageId, data) {
return ` return `
<div class="welcome-section"> <div class="welcome-section">
<h2>${escapeHtml(profile.title || '欢迎使用')}</h2> <div class="welcome-section-main">
<h3>${escapeHtml(profile.subtitle || '个人导航站')}</h3> <h2>${escapeHtml(profile.title || '欢迎使用')}</h2>
<h3>${escapeHtml(profile.subtitle || '个人导航站')}</h3>
</div>
</div> </div>
${generateCategories(data.categories)}`; ${generateCategories(data.categories)}`;
} else { } else {
@@ -712,8 +1024,10 @@ ${generateCategories(data.categories)}`;
return ` return `
<div class="welcome-section"> <div class="welcome-section">
<h2>${escapeHtml(title)}</h2> <div class="welcome-section-main">
<p class="subtitle">${escapeHtml(subtitle)}</p> <h2>${escapeHtml(title)}</h2>
<p class="subtitle">${escapeHtml(subtitle)}</p>
</div>
</div> </div>
${generateCategories(categories)}`; ${generateCategories(categories)}`;
} }
@@ -742,8 +1056,10 @@ function generateSearchResultsPage(config) {
<!-- 搜索结果页 --> <!-- 搜索结果页 -->
<div class="page" id="search-results"> <div class="page" id="search-results">
<div class="welcome-section"> <div class="welcome-section">
<h2>搜索结果</h2> <div class="welcome-section-main">
<p class="subtitle">在所有页面中找到的匹配项</p> <h2>搜索结果</h2>
<p class="subtitle">在所有页面中找到的匹配项</p>
</div>
</div> </div>
${searchSections} ${searchSections}
</div>`; </div>`;
@@ -784,6 +1100,40 @@ function generateFontVariables(config) {
return css; return css;
} }
function normalizeGithubHeatmapColor(input) {
const raw = String(input || '').trim().replace(/^#/, '');
const color = raw.toLowerCase();
if (/^[0-9a-f]{6}$/.test(color)) return color;
if (/^[0-9a-f]{3}$/.test(color)) return color;
return '339af0';
}
function getGithubUsernameFromConfig(config) {
const username = config && config.site && config.site.github && config.site.github.username
? String(config.site.github.username).trim()
: '';
return username;
}
function buildProjectsMeta(config) {
const username = getGithubUsernameFromConfig(config);
if (!username) return null;
const color = normalizeGithubHeatmapColor(
config && config.site && config.site.github && config.site.github.heatmapColor
? config.site.github.heatmapColor
: '339af0'
);
return {
heatmap: {
username,
profileUrl: `https://github.com/${username}`,
imageUrl: `https://ghchart.rshah.org/${color}/${username}`
}
};
}
/** /**
* 渲染单个页面 * 渲染单个页面
* @param {string} pageId 页面ID * @param {string} pageId 页面ID
@@ -793,7 +1143,7 @@ function generateFontVariables(config) {
function renderPage(pageId, config) { function renderPage(pageId, config) {
// 准备页面数据 // 准备页面数据
const data = { const data = {
...config, ...(config || {}),
currentPage: pageId, currentPage: pageId,
pageId // 同时保留pageId字段用于通用模板 pageId // 同时保留pageId字段用于通用模板
}; };
@@ -833,6 +1183,74 @@ function renderPage(pageId, config) {
Object.assign(data, config[pageId]); Object.assign(data, config[pageId]);
} }
// 页面配置缺失时也尽量给出可用的默认值,避免渲染空标题/undefined
if (data.title === undefined) {
const navItem = Array.isArray(config.navigation) ? config.navigation.find(nav => nav.id === pageId) : null;
if (navItem && navItem.name !== undefined) data.title = navItem.name;
}
if (data.subtitle === undefined) data.subtitle = '';
if (!Array.isArray(data.categories)) data.categories = [];
// 检查页面配置中是否指定了模板(用于派生字段与渲染)
const explicitTemplate = typeof data.template === 'string' ? data.template.trim() : '';
let templateName = explicitTemplate || pageId;
// 未显式指定模板时:若 pages/<pageId>.hbs 不存在,则默认使用通用 page 模板(避免依赖回退日志)
if (!explicitTemplate) {
const inferredTemplatePath = path.join(process.cwd(), 'templates', 'pages', `${templateName}.hbs`);
if (!fs.existsSync(inferredTemplatePath)) {
templateName = 'page';
}
}
// 页面级卡片风格开关(用于差异化)
if (templateName === 'projects') {
data.siteCardStyle = 'repo';
data.projectsMeta = buildProjectsMeta(config);
if (Array.isArray(data.categories)) {
const repoCache = tryLoadProjectsRepoCache(pageId, config);
if (repoCache && repoCache.map) {
applyRepoMetaToCategories(data.categories, repoCache.map);
}
}
}
// friends/articles允许顶层 sites历史/兼容),自动转换为一个分类容器以保持页面结构一致
// 注意:模板名可能被统一为 page例如 friends/home 取消专属模板后),因此这里同时按 pageId 判断。
const isFriendsPage = pageId === 'friends' || templateName === 'friends';
const isArticlesPage = pageId === 'articles' || templateName === 'articles';
if ((isFriendsPage || isArticlesPage)
&& (!Array.isArray(data.categories) || data.categories.length === 0)
&& Array.isArray(data.sites)
&& data.sites.length > 0) {
const implicitName = isFriendsPage ? '全部友链' : '全部来源';
data.categories = [
{
name: implicitName,
icon: 'fas fa-link',
sites: data.sites
}
];
}
// articles 模板页面Phase 2 若存在 RSS 缓存,则注入 articlesItems缓存缺失/损坏则回退 Phase 1
if (templateName === 'articles') {
const cache = tryLoadArticlesFeedCache(pageId, config);
data.articlesItems = cache && Array.isArray(cache.items) ? cache.items : [];
data.articlesMeta = cache ? cache.meta : null;
// Phase 2按页面配置分类聚合展示用于模板渲染只读文章列表
data.articlesCategories = data.articlesItems.length
? buildArticlesCategoriesByPageCategories(data.categories, data.articlesItems)
: [];
}
// bookmarks 模板页面:注入配置文件“内容更新时间”(优先 git回退 mtime
if (templateName === 'bookmarks') {
const updatedAtMeta = getPageConfigUpdatedAtMeta(pageId);
if (updatedAtMeta) {
data.pageMeta = { ...updatedAtMeta };
}
}
// 首页标题规则:使用 site.yml 的 profile 覆盖首页(导航第一项)的 title/subtitle 显示 // 首页标题规则:使用 site.yml 的 profile 覆盖首页(导航第一项)的 title/subtitle 显示
const homePageId = config.homePageId const homePageId = config.homePageId
|| (Array.isArray(config.navigation) && config.navigation[0] ? config.navigation[0].id : null) || (Array.isArray(config.navigation) && config.navigation[0] ? config.navigation[0].id : null)
@@ -844,10 +1262,7 @@ function renderPage(pageId, config) {
if (config.profile.subtitle !== undefined) data.subtitle = config.profile.subtitle; if (config.profile.subtitle !== undefined) data.subtitle = config.profile.subtitle;
} }
// 检查页面配置中是否指定了模板
let templateName = pageId;
if (config[pageId] && config[pageId].template) { if (config[pageId] && config[pageId].template) {
templateName = config[pageId].template;
console.log(`页面 ${pageId} 使用指定模板: ${templateName}`); console.log(`页面 ${pageId} 使用指定模板: ${templateName}`);
} }

View File

@@ -235,9 +235,31 @@ window.MeNav = {
const sitesGrid = parent.querySelector('[data-container="sites"]'); const sitesGrid = parent.querySelector('[data-container="sites"]');
if (!sitesGrid) return null; if (!sitesGrid) return null;
// 站点卡片样式根据“页面模板”决定friends/articles/projects 等)
let siteCardStyle = '';
try {
const pageEl = parent.closest('.page');
const pageId = pageEl && pageEl.id ? String(pageEl.id) : '';
let templateName = pageId;
const cfg =
window.MeNav && typeof window.MeNav.getConfig === 'function'
? window.MeNav.getConfig()
: null;
const pageConfig = cfg && cfg.data && pageId ? cfg.data[pageId] : null;
if (pageConfig && pageConfig.template) {
templateName = String(pageConfig.template);
}
// projects 模板使用代码仓库风格卡片(与生成端 templates/components/site-card.hbs 保持一致)
if (templateName === 'projects') siteCardStyle = 'repo';
} catch (e) {
siteCardStyle = '';
}
// 创建新的站点卡片 // 创建新的站点卡片
const newSite = document.createElement('a'); const newSite = document.createElement('a');
newSite.className = 'site-card'; newSite.className = siteCardStyle ? `site-card site-card-${siteCardStyle}` : 'site-card';
const siteName = data.name || '未命名站点'; const siteName = data.name || '未命名站点';
const siteUrl = data.url || '#'; const siteUrl = data.url || '#';
@@ -246,6 +268,10 @@ window.MeNav = {
newSite.href = siteUrl; newSite.href = siteUrl;
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : ''); newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
if (/^https?:\/\//i.test(siteUrl)) {
newSite.target = '_blank';
newSite.rel = 'noopener';
}
// 设置数据属性 // 设置数据属性
newSite.setAttribute('data-type', 'site'); newSite.setAttribute('data-type', 'site');
@@ -254,76 +280,163 @@ window.MeNav = {
newSite.setAttribute('data-icon', siteIcon); newSite.setAttribute('data-icon', siteIcon);
newSite.setAttribute('data-description', siteDescription); newSite.setAttribute('data-description', siteDescription);
// 添加内容(根据图标模式渲染,避免 innerHTML 注入 // projects repo 风格:与模板中的 repo 结构保持一致(不走 favicon 逻辑
const iconWrapper = document.createElement('div'); if (siteCardStyle === 'repo') {
iconWrapper.className = 'site-card-icon'; const repoHeader = document.createElement('div');
iconWrapper.setAttribute('aria-hidden', 'true'); repoHeader.className = 'repo-header';
const contentWrapper = document.createElement('div'); const repoIcon = document.createElement('i');
contentWrapper.className = 'site-card-content'; repoIcon.className = `${siteIcon || 'fas fa-code'} repo-icon`;
repoIcon.setAttribute('aria-hidden', 'true');
const titleEl = document.createElement('h3'); const repoTitle = document.createElement('div');
titleEl.textContent = siteName; repoTitle.className = 'repo-title';
repoTitle.textContent = siteName;
const descEl = document.createElement('p'); repoHeader.appendChild(repoIcon);
descEl.textContent = siteDescription; repoHeader.appendChild(repoTitle);
contentWrapper.appendChild(titleEl); const repoDesc = document.createElement('div');
contentWrapper.appendChild(descEl); repoDesc.className = 'repo-desc';
repoDesc.textContent = siteDescription;
let iconsMode = 'favicon'; newSite.appendChild(repoHeader);
try { newSite.appendChild(repoDesc);
const cfg =
window.MeNav && typeof window.MeNav.getConfig === 'function'
? window.MeNav.getConfig()
: null;
iconsMode = (cfg && (cfg.data?.icons?.mode || cfg.icons?.mode)) || 'favicon';
} catch (e) {
iconsMode = 'favicon';
}
if (iconsMode === 'favicon' && data.url && /^https?:\/\//i.test(data.url)) { const hasStats =
const faviconUrl = `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(data.url)}&size=32`; data &&
(data.language ||
data.stars ||
data.forks ||
data.issues);
const iconContainer = document.createElement('div'); if (hasStats) {
iconContainer.className = 'icon-container'; const repoStats = document.createElement('div');
repoStats.className = 'repo-stats';
const placeholder = document.createElement('i'); if (data.language) {
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder'; const languageItem = document.createElement('div');
placeholder.setAttribute('aria-hidden', 'true'); languageItem.className = 'stat-item';
const fallback = document.createElement('i'); const langDot = document.createElement('span');
fallback.className = `${siteIcon} icon-fallback`; langDot.className = 'lang-dot';
fallback.setAttribute('aria-hidden', 'true'); langDot.style.backgroundColor = data.languageColor || '#909296';
const favicon = document.createElement('img'); languageItem.appendChild(langDot);
favicon.className = 'favicon-icon'; languageItem.appendChild(document.createTextNode(String(data.language)));
favicon.src = faviconUrl; repoStats.appendChild(languageItem);
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); if (data.stars) {
iconContainer.appendChild(favicon); const starsItem = document.createElement('div');
iconContainer.appendChild(fallback); starsItem.className = 'stat-item';
iconWrapper.appendChild(iconContainer);
const starIcon = document.createElement('i');
starIcon.className = 'far fa-star';
starIcon.setAttribute('aria-hidden', 'true');
starsItem.appendChild(starIcon);
starsItem.appendChild(document.createTextNode(` ${data.stars}`));
repoStats.appendChild(starsItem);
}
if (data.forks) {
const forksItem = document.createElement('div');
forksItem.className = 'stat-item';
const forkIcon = document.createElement('i');
forkIcon.className = 'fas fa-code-branch';
forkIcon.setAttribute('aria-hidden', 'true');
forksItem.appendChild(forkIcon);
forksItem.appendChild(document.createTextNode(` ${data.forks}`));
repoStats.appendChild(forksItem);
}
if (data.issues) {
const issuesItem = document.createElement('div');
issuesItem.className = 'stat-item';
const issueIcon = document.createElement('i');
issueIcon.className = 'fas fa-exclamation-circle';
issueIcon.setAttribute('aria-hidden', 'true');
issuesItem.appendChild(issueIcon);
issuesItem.appendChild(document.createTextNode(` ${data.issues}`));
repoStats.appendChild(issuesItem);
}
newSite.appendChild(repoStats);
}
} else { } else {
const iconEl = document.createElement('i'); // 添加内容(根据图标模式渲染,避免 innerHTML 注入)
iconEl.className = `${siteIcon} site-icon`; const iconWrapper = document.createElement('div');
iconEl.setAttribute('aria-hidden', 'true'); iconWrapper.className = 'site-card-icon';
iconWrapper.appendChild(iconEl); iconWrapper.setAttribute('aria-hidden', 'true');
}
newSite.appendChild(iconWrapper); const contentWrapper = document.createElement('div');
newSite.appendChild(contentWrapper); contentWrapper.className = 'site-card-content';
const titleEl = document.createElement('h3');
titleEl.textContent = siteName;
const descEl = document.createElement('p');
descEl.textContent = siteDescription;
contentWrapper.appendChild(titleEl);
contentWrapper.appendChild(descEl);
let iconsMode = 'favicon';
try {
const cfg =
window.MeNav && typeof window.MeNav.getConfig === 'function'
? window.MeNav.getConfig()
: null;
iconsMode = (cfg && (cfg.data?.icons?.mode || cfg.icons?.mode)) || 'favicon';
} catch (e) {
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 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 = faviconUrl;
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 {
const iconEl = document.createElement('i');
iconEl.className = `${siteIcon} site-icon`;
iconEl.setAttribute('aria-hidden', 'true');
iconWrapper.appendChild(iconEl);
}
newSite.appendChild(iconWrapper);
newSite.appendChild(contentWrapper);
}
// 添加到DOM // 添加到DOM
sitesGrid.appendChild(newSite); sitesGrid.appendChild(newSite);
@@ -703,7 +816,7 @@ document.addEventListener('DOMContentLoaded', () => {
let isLightTheme = false; // 主题状态 let isLightTheme = false; // 主题状态
let isSidebarCollapsed = false; // 侧边栏折叠状态 let isSidebarCollapsed = false; // 侧边栏折叠状态
let pages; // 页面元素的全局引用 let pages; // 页面元素的全局引用
let currentSearchEngine = 'local'; // 当前选择的搜索引擎 let currentSearchEngine = 'local'; // 当前选择的搜索引擎
// 搜索索引,用于提高搜索效率 // 搜索索引,用于提高搜索效率
let searchIndex = { let searchIndex = {
@@ -711,8 +824,8 @@ document.addEventListener('DOMContentLoaded', () => {
items: [] items: []
}; };
// 搜索引擎配置 // 搜索引擎配置
const searchEngines = { const searchEngines = {
local: { local: {
name: '本地搜索', name: '本地搜索',
icon: 'fas fa-search', icon: 'fas fa-search',
@@ -733,7 +846,8 @@ document.addEventListener('DOMContentLoaded', () => {
icon: 'fas fa-paw', icon: 'fas fa-paw',
url: 'https://www.baidu.com/s?wd=' url: 'https://www.baidu.com/s?wd='
} }
}; };
// 获取DOM元素 - 基本元素 // 获取DOM元素 - 基本元素
const searchInput = document.getElementById('search'); const searchInput = document.getElementById('search');
@@ -879,8 +993,24 @@ document.addEventListener('DOMContentLoaded', () => {
page.querySelectorAll('.site-card').forEach(card => { page.querySelectorAll('.site-card').forEach(card => {
try { try {
const title = card.querySelector('h3')?.textContent?.toLowerCase() || ''; // 排除“扩展写回影子结构”等不应参与搜索的卡片
const description = card.querySelector('p')?.textContent?.toLowerCase() || ''; if (card.closest('[data-search-exclude="true"]')) return;
// 兼容不同页面/卡片样式:优先取可见文本,其次回退到 data-*(确保 projects repo 卡片也能被搜索)
const dataTitle = card.dataset?.name || card.getAttribute('data-name') || '';
const dataDescription = card.dataset?.description || card.getAttribute('data-description') || '';
const titleText =
card.querySelector('h3')?.textContent ||
card.querySelector('.repo-title')?.textContent ||
dataTitle;
const descriptionText =
card.querySelector('p')?.textContent ||
card.querySelector('.repo-desc')?.textContent ||
dataDescription;
const title = String(titleText || '').toLowerCase();
const description = String(descriptionText || '').toLowerCase();
const url = card.href || card.getAttribute('href') || '#'; const url = card.href || card.getAttribute('href') || '#';
const icon = card.querySelector('i.icon-fallback')?.className || card.querySelector('i')?.className || ''; const icon = card.querySelector('i.icon-fallback')?.className || card.querySelector('i')?.className || '';
@@ -1154,114 +1284,84 @@ document.addEventListener('DOMContentLoaded', () => {
if (!card || !searchTerm) return; if (!card || !searchTerm) return;
try { try {
const title = card.querySelector('h3'); // 兼容 projects repo 卡片title/desc 不一定是 h3/p
const description = card.querySelector('p'); const titleElement = card.querySelector('h3') || card.querySelector('.repo-title');
const descriptionElement = card.querySelector('p') || card.querySelector('.repo-desc');
if (!title || !description) return; const hasPinyinMatch = typeof PinyinMatch !== 'undefined' && PinyinMatch && typeof PinyinMatch.match === 'function';
// 安全地高亮标题中的匹配文本 const applyRangeHighlight = (element, start, end) => {
if (title.textContent.toLowerCase().includes(searchTerm)) { const text = element.textContent || '';
const titleText = title.textContent; const safeStart = Math.max(0, Math.min(text.length, start));
const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi'); const safeEnd = Math.max(safeStart, Math.min(text.length - 1, end));
// 创建安全的DOM结构而不是直接使用innerHTML const fragment = document.createDocumentFragment();
const titleFragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode(text.slice(0, safeStart)));
let lastIndex = 0;
let match;
// 使用正则表达式查找所有匹配项 const span = document.createElement('span');
const titleRegex = new RegExp(regex); span.className = 'highlight';
while ((match = titleRegex.exec(titleText)) !== null) { span.textContent = text.slice(safeStart, safeEnd + 1);
// 添加匹配前的文本 fragment.appendChild(span);
if (match.index > lastIndex) {
titleFragment.appendChild(document.createTextNode( fragment.appendChild(document.createTextNode(text.slice(safeEnd + 1)));
titleText.substring(lastIndex, match.index)
)); while (element.firstChild) {
element.removeChild(element.firstChild);
}
element.appendChild(fragment);
};
const highlightInElement = element => {
if (!element) return;
const rawText = element.textContent || '';
const lowerText = rawText.toLowerCase();
if (!rawText) return;
if (lowerText.includes(searchTerm)) {
const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
const fragment = document.createDocumentFragment();
let lastIndex = 0;
let match;
while ((match = regex.exec(rawText)) !== null) {
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(rawText.substring(lastIndex, match.index)));
}
const span = document.createElement('span');
span.className = 'highlight';
span.textContent = match[0];
fragment.appendChild(span);
lastIndex = match.index + match[0].length;
// 防止无限循环
if (regex.lastIndex === 0) break;
} }
// 添加高亮的匹配文本 if (lastIndex < rawText.length) {
const span = document.createElement('span'); fragment.appendChild(document.createTextNode(rawText.substring(lastIndex)));
span.className = 'highlight';
span.textContent = match[0];
titleFragment.appendChild(span);
lastIndex = match.index + match[0].length;
// 防止无限循环
if (titleRegex.lastIndex === 0) break;
}
// 添加剩余文本
if (lastIndex < titleText.length) {
titleFragment.appendChild(document.createTextNode(
titleText.substring(lastIndex)
));
}
// 清空原标题并添加新内容
while (title.firstChild) {
title.removeChild(title.firstChild);
}
title.appendChild(titleFragment);
} else if (PinyinMatch.match(title.textContent, searchTerm)) {
const arr = PinyinMatch.match(title.textContent, searchTerm);
const [start, end] = arr;
title.innerHTML = title.textContent.slice(0, start) +
`<span class="highlight">${title.textContent.slice(start, end + 1)}</span>` +
title.textContent.slice(end + 1);
}
// 安全地高亮描述中的匹配文本
if (description.textContent.toLowerCase().includes(searchTerm)) {
const descText = description.textContent;
const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
// 创建安全的DOM结构而不是直接使用innerHTML
const descFragment = document.createDocumentFragment();
let lastIndex = 0;
let match;
// 使用正则表达式查找所有匹配项
const descRegex = new RegExp(regex);
while ((match = descRegex.exec(descText)) !== null) {
// 添加匹配前的文本
if (match.index > lastIndex) {
descFragment.appendChild(document.createTextNode(
descText.substring(lastIndex, match.index)
));
} }
// 添加高亮的匹配文本 while (element.firstChild) {
const span = document.createElement('span'); element.removeChild(element.firstChild);
span.className = 'highlight'; }
span.textContent = match[0]; element.appendChild(fragment);
descFragment.appendChild(span); return;
lastIndex = match.index + match[0].length;
// 防止无限循环
if (descRegex.lastIndex === 0) break;
} }
// 添加剩余文本 if (hasPinyinMatch) {
if (lastIndex < descText.length) { const arr = PinyinMatch.match(rawText, searchTerm);
descFragment.appendChild(document.createTextNode( if (Array.isArray(arr) && arr.length >= 2) {
descText.substring(lastIndex) const [start, end] = arr;
)); applyRangeHighlight(element, start, end);
}
} }
};
// 清空原描述并添加新内容 highlightInElement(titleElement);
while (description.firstChild) { highlightInElement(descriptionElement);
description.removeChild(description.firstChild);
}
description.appendChild(descFragment);
} else if (PinyinMatch.match(description.textContent, searchTerm)) {
const arr = PinyinMatch.match(description.textContent, searchTerm);
const [start, end] = arr;
description.innerHTML = description.textContent.slice(0, start) +
`<span class="highlight">${description.textContent.slice(start, end + 1)}</span>` +
description.textContent.slice(end + 1);
}
} catch (error) { } catch (error) {
console.error('Error highlighting search term'); console.error('Error highlighting search term');
} }
@@ -1688,11 +1788,11 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
// 初始化嵌套分类功能 // 初始化嵌套分类功能
initializeNestedCategories(); initializeNestedCategories();
// 初始化分类切换按钮 // 初始化分类切换按钮
const categoryToggleBtn = document.getElementById('category-toggle'); const categoryToggleBtn = document.getElementById('category-toggle');
if (categoryToggleBtn) { if (categoryToggleBtn) {
categoryToggleBtn.addEventListener('click', function() { categoryToggleBtn.addEventListener('click', function() {
window.MeNav.toggleCategories(); window.MeNav.toggleCategories();

View File

@@ -30,10 +30,13 @@ templates/
├── layouts/ # 布局模板 - 定义页面整体结构 ├── layouts/ # 布局模板 - 定义页面整体结构
│ └── default.hbs # 默认布局 │ └── default.hbs # 默认布局
├── pages/ # 页面模板 - 对应不同页面内容 ├── pages/ # 页面模板 - 对应不同页面内容
│ ├── home.hbs # 首页 │ ├── page.hbs # 通用页面模板(默认/回退模板;普通页面常用)
│ ├── projects.hbs # 项目页repo 风格卡片)
│ ├── articles.hbs # 文章页RSS 聚合/只读文章条目)
│ ├── bookmarks.hbs # 书签页 │ ├── bookmarks.hbs # 书签页
│ └── ... │ └── search-results.hbs # 搜索结果页(内置)
├── components/ # 组件模板 - 可复用的界面元素 ├── components/ # 组件模板 - 可复用的界面元素
│ ├── page-header.hbs # 统一标题区(首页/非首页/书签更新时间/项目热力图)
│ ├── navigation.hbs # 导航组件 │ ├── navigation.hbs # 导航组件
│ ├── site-card.hbs # 站点卡片组件 │ ├── site-card.hbs # 站点卡片组件
│ ├── category.hbs # 分类组件 │ ├── category.hbs # 分类组件
@@ -88,20 +91,24 @@ templates/
**位置**: `templates/pages/` **位置**: `templates/pages/`
**主要页面**: **主要页面**:
- `home.hbs` - 首页 - `page.hbs` - 通用页面模板(默认/回退模板;普通页面常用)
- `bookmarks.hbs` - 书签页 - `bookmarks.hbs` - 书签页
- `projects.hbs` - 项目页
- `articles.hbs` - 文章页
- `search-results.hbs` - 搜索结果 - `search-results.hbs` - 搜索结果
- 其他自定义页面 - 其他自定义页面
**示例** (`home.hbs`): > 说明MeNav 不再依赖 `home.hbs` 作为首页模板。
> “首页/默认打开页”由 `site.yml -> navigation` 的**第一项**决定;首页可使用任意页面模板,具体取决于该页面配置(`pages/<homePageId>.yml` 的 `template` 字段与回退规则)。
**示例** (`page.hbs`):
```handlebars ```handlebars
<div class="welcome-section"> <div class="page-template page-template-{{pageId}}">
<h2>{{profile.title}}</h2> {{> page-header}}
<h3>{{profile.subtitle}}</h3> {{#each categories}}
{{> category}}
{{/each}}
</div> </div>
{{#each categories}}
{{> category}}
{{/each}}
``` ```
### 组件模板 ### 组件模板
@@ -110,25 +117,38 @@ templates/
**位置**: `templates/components/` **位置**: `templates/components/`
> 说明:生成器启动时会自动扫描 `templates/components/` 下的所有 `.hbs` 并注册为 Handlebars partialpartial 名称=文件名去掉 `.hbs`)。因此新增组件后无需手动“注册步骤”,可直接通过 `{{> component-name}}` 引用。
**主要组件**: **主要组件**:
- `page-header.hbs` - 统一页面标题区(首页/非首页/书签更新时间/项目热力图)
- `navigation.hbs` - 导航菜单 - `navigation.hbs` - 导航菜单
- `site-card.hbs` - 站点卡片 - `site-card.hbs` - 站点卡片
- `category.hbs` - 分类容器(支持多层级嵌套) - `category.hbs` - 分类容器(支持多层级嵌套)
- `group.hbs` - 分组容器(支持多层级嵌套) - `group.hbs` - 分组容器(支持多层级嵌套)
- `social-links.hbs` - 社交链接 - `social-links.hbs` - 社交链接
- `search-results.hbs` - 搜索结果展示
**示例** (`site-card.hbs`): **示例** (`site-card.hbs`,精简展示关键结构):
```handlebars ```handlebars
{{#if url}} {{#if url}}
<a href="{{url}}" class="site-card{{#if style}} site-card-{{style}}{{/if}}" title="{{name}} - {{description}}" {{#if external}}target="_blank" rel="noopener"{{/if}}> <a href="{{url}}"
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}}"></i> class="site-card{{#if style}} site-card-{{style}}{{/if}}"
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3> {{#if external}}target="_blank" rel="noopener"{{/if}}
<p>{{description}}</p> data-type="{{#if type}}{{type}}{{else}}site{{/if}}"
data-name="{{name}}"
data-url="{{url}}">
<div class="site-card-icon">...</div>
<div class="site-card-content">
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>
<p>{{description}}</p>
</div>
</a> </a>
{{/if}} {{/if}}
``` ```
说明:
- `type=article`:用于 articles Phase 2 的只读文章条目卡片(仍保留 `data-*` 结构;扩展解析应以 `data-type="article"` 区分类型)
- `style=repo`:用于 projects 的代码仓库风卡片(展示 language/stars/forks 等只读元信息)
### 多层级嵌套模板组件 ### 多层级嵌套模板组件
#### category.hbs - 分类容器组件 #### category.hbs - 分类容器组件
@@ -338,6 +358,15 @@ MeNav 模板系统的数据流如下:
- `profile` - 个人资料数据 - `profile` - 个人资料数据
- `social` - 社交链接数据 - `social` - 社交链接数据
常见派生字段(由生成器注入,供模板差异化使用):
- `homePageId`:首页页面 ID始终等于 `navigation` 第一项的 `id`
- `pageId`:当前页面 ID用于 `.page-template-{{pageId}}` 等)
- `pageMeta.updatedAt/updatedAtSource`:仅 bookmarks 模板页用于“update: YYYY-MM-DD | from: ...”展示
- `projectsMeta.heatmap`:仅 projects 模板页用于右侧 GitHub 热力图展示(需要配置 `site.github.username`
- `articlesItems/articlesCategories`:仅 articles 模板页Phase 2用于渲染只读文章条目RSS 缓存存在时)
> 提示:页面模板是“页面内容片段”,不要包含 `<!DOCTYPE html>` 等整页骨架;整页骨架由 `layouts/default.hbs` 负责。
## 模板使用示例 ## 模板使用示例
### 布局模板使用 ### 布局模板使用
@@ -427,7 +456,7 @@ categories:
### 添加新页面 ### 添加新页面
1.`templates/pages/` 创建新的 `.hbs` 文件 1.`templates/pages/` 创建新的 `.hbs` 文件
2.`config/_default/site.yml``navigation` 部分添加页面配置 2.`config/user/site.yml``navigation` 部分添加页面配置(配置采用“完全替换”策略,推荐使用 user 配置)
3. 页面内容可引用现有组件或创建新组件 3. 页面内容可引用现有组件或创建新组件
示例: 示例:

View File

@@ -53,7 +53,7 @@
<div class="sites-grid" data-container="sites"> <div class="sites-grid" data-container="sites">
{{#if sites.length}} {{#if sites.length}}
{{#each sites}} {{#each sites}}
{{> site-card}} {{> site-card style=@root.siteCardStyle}}
{{/each}} {{/each}}
{{else}} {{else}}
<p class="empty-sites">暂无网站</p> <p class="empty-sites">暂无网站</p>

View File

@@ -24,7 +24,7 @@
<div class="sites-grid" data-container="sites"> <div class="sites-grid" data-container="sites">
{{#if sites.length}} {{#if sites.length}}
{{#each sites}} {{#each sites}}
{{> site-card}} {{> site-card style=@root.siteCardStyle}}
{{/each}} {{/each}}
{{else}} {{else}}
<p class="empty-sites">暂无网站</p> <p class="empty-sites">暂无网站</p>

View File

@@ -0,0 +1,36 @@
{{!-- page-header.hbs - 统一页面标题区(可选显示书签页内容更新时间) --}}
<div class="welcome-section{{#ifCond projectsMeta '&&' projectsMeta.heatmap}} welcome-section-with-side{{/ifCond}}">
<div class="welcome-section-main">
{{#ifEquals pageId @root.homePageId}}
<h2 data-editable="profile-title">{{title}}</h2>
<h3 data-editable="profile-subtitle">{{subtitle}}</h3>
{{else}}
<div class="welcome-title-row">
<h2 data-editable="page-title">{{title}}</h2>
{{#if pageMeta}}
{{#if pageMeta.updatedAt}}
<span class="page-updated-inline" title="{{pageMeta.updatedAt}}">
update: {{formatDate pageMeta.updatedAt "YYYY-MM-DD"}} | from: {{pageMeta.updatedAtSource}}
</span>
{{/if}}
{{/if}}
</div>
<p class="subtitle" data-editable="page-subtitle">{{subtitle}}</p>
{{/ifEquals}}
</div>
{{#if projectsMeta}}
{{#if projectsMeta.heatmap}}
<div class="welcome-section-side">
<div class="heatmap-container" title="我的 GitHub 贡献热力图">
<a href="{{projectsMeta.heatmap.profileUrl}}" target="_blank" rel="noopener">
<img class="heatmap-img"
src="{{projectsMeta.heatmap.imageUrl}}"
alt="Github Chart"
loading="lazy" />
</a>
</div>
</div>
{{/if}}
{{/if}}
</div>

View File

@@ -1,11 +0,0 @@
<!-- 搜索结果组件 -->
<div class="welcome-section">
<h2 data-editable="page-title">搜索结果</h2>
<p class="subtitle" data-editable="page-subtitle">在所有页面中找到的匹配项</p>
</div>
{{#each navigation}}
<section class="category search-section" data-section="{{id}}" data-type="category" data-name="{{name}}" data-icon="{{icon}}" style="display: none;">
<h2 data-editable="category-name"><i class="{{icon}}"></i> {{name}}匹配项</h2>
<div class="sites-grid" data-container="sites"></div>
</section>
{{/each}}

View File

@@ -1,37 +1,123 @@
{{#if url}} {{#if url}}
<a href="{{url}}" class="site-card{{#if style}} site-card-{{style}}{{/if}}" <a href="{{url}}" class="site-card{{#if style}} site-card-{{style}}{{/if}}"
{{#if external}}target="_blank" rel="noopener"{{/if}} {{#if external}}target="_blank" rel="noopener"{{/if}}
data-type="site" data-type="{{#if type}}{{type}}{{else}}site{{/if}}"
data-name="{{name}}" data-name="{{name}}"
data-url="{{url}}" data-url="{{url}}"
data-icon="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}}" data-icon="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}}"
data-description="{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}"> data-description="{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}"
<div class="site-card-icon" aria-hidden="true"> {{#if publishedAt}}data-published-at="{{publishedAt}}"{{/if}}
{{#ifEquals @root.icons.mode "favicon"}} {{#if source}}data-source="{{source}}"{{/if}}>
{{#ifHttpUrl url}} {{!-- articles首行图标+标题;下方“时间/来源 + 简介”全宽对齐,不被图标列缩进 --}}
<div class="icon-container"> {{#ifEquals type "article"}}
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i> <div class="article-card-header">
<img <div class="site-card-icon" aria-hidden="true">
class="favicon-icon" {{#ifEquals @root.icons.mode "favicon"}}
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32" {{#ifHttpUrl url}}
alt="{{name}} favicon" <div class="icon-container">
loading="lazy" <i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');" <img
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');" class="favicon-icon"
/> src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i> 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-body">
{{#ifCond publishedAt '||' source}}
<div class="site-card-meta">
{{#if publishedAt}}
<span class="site-card-meta-date">{{formatDate publishedAt "YYYY-MM-DD"}}</span>
{{/if}}
{{#ifCond publishedAt '&&' source}}
<span class="site-card-meta-sep">·</span>
{{/ifCond}}
{{#if source}}
<span class="site-card-meta-source">{{source}}</span>
{{/if}}
</div> </div>
{{/ifCond}}
<p>{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}</p>
</div>
{{else}}
{{!-- projects代码仓库风格卡片保留 data-* 结构,便于扩展识别与写回) --}}
{{#ifEquals style "repo"}}
<div class="repo-header">
<i class="{{#if icon}}{{icon}}{{else}}fas fa-code{{/if}} repo-icon" aria-hidden="true"></i>
<div class="repo-title">{{#if name}}{{name}}{{else}}未命名项目{{/if}}</div>
</div>
<div class="repo-desc">{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}</div>
{{#ifCond language '||' stars}}
<div class="repo-stats">
{{#if language}}
<div class="stat-item">
<span class="lang-dot" style="background-color: {{#if languageColor}}{{languageColor}}{{else}}#909296{{/if}};"></span>
{{language}}
</div>
{{/if}}
{{#if stars}}
<div class="stat-item">
<i class="far fa-star" aria-hidden="true"></i> {{stars}}
</div>
{{/if}}
{{#if forks}}
<div class="stat-item">
<i class="fas fa-code-branch" aria-hidden="true"></i> {{forks}}
</div>
{{/if}}
{{#if issues}}
<div class="stat-item">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i> {{issues}}
</div>
{{/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}} {{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i> <i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifHttpUrl}} {{/ifEquals}}
{{else}} </div>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifEquals}}
</div>
<div class="site-card-content"> <div class="site-card-content">
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3> <h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>
<p>{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}</p> <p>{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}</p>
</div> </div>
{{/ifEquals}}
{{/ifEquals}}
</a> </a>
{{/if}} {{/if}}

View File

@@ -1,14 +1,66 @@
{{#ifEquals pageId @root.homePageId}} {{!-- articles.hbs - 文章页面恢复分类展示Phase 2 优先展示文章条目(只读) --}}
<div class="welcome-section"> <div class="page-template page-template-articles">
<h2 data-editable="profile-title">{{title}}</h2> {{> page-header}}
<h3 data-editable="profile-subtitle">{{subtitle}}</h3>
{{#if articlesItems.length}}
{{!-- Phase 2按配置分类聚合展示文章条目只读保持与页面分类结构一致 --}}
{{#if articlesCategories.length}}
{{#each articlesCategories}}
<section class="category category-level-1 category-readonly">
<div class="category-header" data-toggle="category">
<h2>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-rss{{/if}}"></i>
{{name}}
<span class="toggle-icon">
<i class="fas fa-chevron-down"></i>
</span>
</h2>
</div>
<div class="category-content">
{{#if items.length}}
<div class="sites-grid">
{{#each items}}
{{> site-card type="article" style=@root.siteCardStyle}}
{{/each}}
</div>
{{else}}
<p class="empty-content">暂无文章</p>
{{/if}}
</div>
</section>
{{/each}}
{{else}}
{{!-- 兜底:无分类映射时展示为单一“最新文章”列表 --}}
<section class="category category-level-1 category-readonly">
<div class="category-header" data-toggle="category">
<h2>
<i class="fas fa-rss"></i>
最新文章
<span class="toggle-icon">
<i class="fas fa-chevron-down"></i>
</span>
</h2>
</div>
<div class="category-content">
<div class="sites-grid">
{{#each articlesItems}}
{{> site-card type="article" style=@root.siteCardStyle}}
{{/each}}
</div>
</div>
</section>
{{/if}}
{{!-- 保留扩展可写回结构(隐藏),避免文章条目影响 DOM → IR → YAML --}}
<div class="menav-extension-shadow" data-extension-shadow="true" data-search-exclude="true" aria-hidden="true">
{{#each categories}}
{{> category}}
{{/each}}
</div>
{{else}}
{{!-- Phase 1来源站点入口可写回 --}}
{{#each categories}}
{{> category}}
{{/each}}
{{/if}}
</div> </div>
{{else}}
<div class="welcome-section">
<h2 data-editable="page-title">{{title}}</h2>
<p class="subtitle" data-editable="page-subtitle">{{subtitle}}</p>
</div>
{{/ifEquals}}
{{#each categories}}
{{> category}}
{{/each}}

View File

@@ -1,14 +1,4 @@
{{#ifEquals pageId @root.homePageId}} {{> page-header}}
<div class="welcome-section">
<h2 data-editable="profile-title">{{title}}</h2>
<h3 data-editable="profile-subtitle">{{subtitle}}</h3>
</div>
{{else}}
<div class="welcome-section">
<h2 data-editable="page-title">{{title}}</h2>
<p class="subtitle" data-editable="page-subtitle">{{subtitle}}</p>
</div>
{{/ifEquals}}
{{#each categories}} {{#each categories}}
{{> category}} {{> category}}
{{/each}} {{/each}}

View File

@@ -1,14 +0,0 @@
{{#ifEquals pageId @root.homePageId}}
<div class="welcome-section">
<h2 data-editable="profile-title">{{title}}</h2>
<h3 data-editable="profile-subtitle">{{subtitle}}</h3>
</div>
{{else}}
<div class="welcome-section">
<h2 data-editable="page-title">{{title}}</h2>
<p class="subtitle" data-editable="page-subtitle">{{subtitle}}</p>
</div>
{{/ifEquals}}
{{#each categories}}
{{> category}}
{{/each}}

View File

@@ -1,14 +0,0 @@
{{#ifEquals pageId @root.homePageId}}
<div class="welcome-section">
<h2 data-editable="profile-title">{{title}}</h2>
<h3 data-editable="profile-subtitle">{{subtitle}}</h3>
</div>
{{else}}
<div class="welcome-section">
<h2 data-editable="page-title">{{title}}</h2>
<p class="subtitle" data-editable="page-subtitle">{{subtitle}}</p>
</div>
{{/ifEquals}}
{{#each categories}}
{{> category}}
{{/each}}

View File

@@ -1,142 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{site.title}}</title>
<link rel="icon" href="./{{site.favicon}}" type="image/x-icon">
<link rel="shortcut icon" href="./{{site.favicon}}" type="image/x-icon">
<link href="{{{googleFontsLink}}}" rel="stylesheet">
<style>
{{{fontVariables}}}
</style>
<!-- 预设主题和侧边栏状态,避免闪烁 -->
<script>
(function() {
// 读取并应用主题设置
var savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light') {
document.documentElement.classList.add('theme-preload');
}
// 读取并应用侧边栏状态
var sidebarCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
var isMobile = window.innerWidth <= 768;
if (sidebarCollapsed && !isMobile) {
document.documentElement.classList.add('sidebar-collapsed-preload');
}
// 添加这个类用于控制初始渲染
document.documentElement.classList.add('preload');
})();
</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>
<body class="loading">
<!-- 滚动进度指示条 -->
<div class="scroll-progress"></div>
<div class="layout">
<!-- 移动端按钮 -->
<div class="mobile-buttons">
<button class="menu-toggle" aria-label="切换菜单">
<i class="fas fa-bars"></i>
</button>
<button class="search-toggle" aria-label="切换搜索">
<i class="fas fa-search"></i>
</button>
</div>
<!-- 遮罩层 -->
<div class="overlay"></div>
<!-- 左侧导航 -->
<nav class="sidebar">
<div class="logo">
<h1>{{site.logo_text}}</h1>
<button class="sidebar-toggle" aria-label="收起/展开侧边栏">
<i class="fas fa-chevron-left toggle-icon"></i>
</button>
</div>
<div class="sidebar-content">
<div class="nav-section">
{{#each navigationData}}
{{> navigation}}
{{/each}}
</div>
<div class="social-links">
{{> social-links}}
</div>
</div>
</nav>
<!-- 右侧内容区 -->
<main class="content">
<!-- 顶部操作栏 -->
<div class="main-header">
<div class="left-actions">
<button class="theme-toggle" aria-label="切换主题">
<i class="fas fa-moon"></i>
</button>
</div>
<div class="search-container">
<div class="search-input-container">
<i class="fas fa-search"></i>
<input type="text" class="search-input" placeholder="搜索..." aria-label="搜索">
<button class="search-clear" aria-label="清除搜索">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="right-actions">
<button class="fullscreen-toggle" aria-label="切换全屏模式">
<i class="fas fa-expand"></i>
</button>
</div>
</div>
<!-- 主要内容 -->
<div class="main-content">
<!-- home页 -->
<div class="page active" id="home">
{{{pages.home}}}
</div>
<!-- 项目页 -->
<div class="page" id="projects">
{{{pages.projects}}}
</div>
<!-- 文章页 -->
<div class="page" id="articles">
{{{pages.articles}}}
</div>
<!-- 朋友页 -->
<div class="page" id="friends">
{{{pages.friends}}}
</div>
<!-- 书签页 -->
<div class="page" id="bookmarks">
{{{pages.bookmarks}}}
</div>
<!-- 搜索结果页 -->
<div class="page" id="search-results">
{{{pages.search-results}}}
</div>
</div>
<!-- 页脚 -->
<footer class="main-footer">
<p>© {{currentYear}} {{site.title}} | {{site.footer}}</p>
</footer>
</main>
</div>
<script src="script.js"></script>
</body>
</html>

View File

@@ -1,16 +1,7 @@
<div class="page" id="{{pageId}}"> {{!-- page.hbs - 通用页面模板:结构与其他页面一致(标题区 + 分类内容) --}}
{{#ifEquals pageId @root.homePageId}} <div class="page-template page-template-{{pageId}}">
<div class="welcome-section"> {{> page-header}}
<h2 data-editable="profile-title">{{title}}</h2>
<h3 data-editable="profile-subtitle">{{subtitle}}</h3>
</div>
{{else}}
<div class="welcome-section">
<h2 data-editable="page-title">{{title}}</h2>
<p class="subtitle" data-editable="page-subtitle">{{subtitle}}</p>
</div>
{{/ifEquals}}
{{#each categories}} {{#each categories}}
{{> category}} {{> category}}
{{/each}} {{/each}}
</div> </div>

View File

@@ -1,14 +1,8 @@
{{#ifEquals pageId @root.homePageId}} {{!-- projects.hbs - 项目页:风格 A代码仓库风标题栏右侧展示 GitHub 热力图(可选) --}}
<div class="welcome-section"> <div class="page-template page-template-projects">
<h2 data-editable="profile-title">{{title}}</h2> {{> page-header}}
<h3 data-editable="profile-subtitle">{{subtitle}}</h3>
{{#each categories}}
{{> category}}
{{/each}}
</div> </div>
{{else}}
<div class="welcome-section">
<h2 data-editable="page-title">{{title}}</h2>
<p class="subtitle" data-editable="page-subtitle">{{subtitle}}</p>
</div>
{{/ifEquals}}
{{#each categories}}
{{> category}}
{{/each}}

View File

@@ -1,11 +1,13 @@
<!-- 搜索结果页 --> <!-- 搜索结果页 -->
<div class="welcome-section"> <div class="welcome-section">
<h2 data-editable="page-title">搜索结果</h2> <div class="welcome-section-main">
<p class="subtitle" data-editable="page-subtitle">在所有页面中找到的匹配项</p> <h2 data-editable="page-title">搜索结果</h2>
<p class="subtitle" data-editable="page-subtitle">在所有页面中找到的匹配项</p>
</div>
</div> </div>
{{#each navigation}} {{#each navigation}}
<section class="category search-section" data-section="{{id}}" data-type="category" data-name="{{name}}" data-icon="{{icon}}" style="display: none;"> <section class="category search-section" data-section="{{id}}" data-type="category" data-name="{{name}}" data-icon="{{icon}}" style="display: none;">
<h2 data-editable="category-name"><i class="{{icon}}"></i> {{name}}匹配项</h2> <h2 data-editable="category-name"><i class="{{icon}}"></i> {{name}}匹配项</h2>
<div class="sites-grid" data-container="sites"></div> <div class="sites-grid" data-container="sites"></div>
</section> </section>
{{/each}} {{/each}}

View File

@@ -64,11 +64,13 @@ test('templatessubgroups第4层应可渲染到页面', () => {
const category = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'category.hbs'), 'utf8'); const category = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'category.hbs'), 'utf8');
const group = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'group.hbs'), 'utf8'); const group = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'group.hbs'), 'utf8');
const pageHeader = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'page-header.hbs'), 'utf8');
const siteCard = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'site-card.hbs'), 'utf8'); const siteCard = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'site-card.hbs'), 'utf8');
const page = fs.readFileSync(path.join(__dirname, '..', 'templates', 'pages', 'bookmarks.hbs'), 'utf8'); const page = fs.readFileSync(path.join(__dirname, '..', 'templates', 'pages', 'bookmarks.hbs'), 'utf8');
hbs.registerPartial('category', category); hbs.registerPartial('category', category);
hbs.registerPartial('group', group); hbs.registerPartial('group', group);
hbs.registerPartial('page-header', pageHeader);
hbs.registerPartial('site-card', siteCard); hbs.registerPartial('site-card', siteCard);
const tpl = hbs.compile(page); const tpl = hbs.compile(page);
@@ -165,12 +167,12 @@ test('ensureUserConfigInitialized/ensureUserSiteYmlExists可在空目录初
try { try {
fs.mkdirSync('config/_default/pages', { recursive: true }); fs.mkdirSync('config/_default/pages', { recursive: true });
fs.writeFileSync('config/_default/site.yml', 'title: Default\n', 'utf8'); fs.writeFileSync('config/_default/site.yml', 'title: Default\n', 'utf8');
fs.writeFileSync('config/_default/pages/home.yml', 'categories: []\n', 'utf8'); fs.writeFileSync('config/_default/pages/common.yml', 'categories: []\n', 'utf8');
const init = ensureUserConfigInitialized(); const init = ensureUserConfigInitialized();
assert.equal(init.initialized, true); assert.equal(init.initialized, true);
assert.ok(fs.existsSync('config/user/site.yml')); assert.ok(fs.existsSync('config/user/site.yml'));
assert.ok(fs.existsSync('config/user/pages/home.yml')); assert.ok(fs.existsSync('config/user/pages/common.yml'));
// 若 site.yml 已存在,应直接返回 true // 若 site.yml 已存在,应直接返回 true
assert.equal(ensureUserSiteYmlExists(), true); assert.equal(ensureUserSiteYmlExists(), true);

View File

@@ -21,7 +21,7 @@ test('首页navigation 第一项)应使用 profile 覆盖 title/subtitle
{ id: 'projects', name: '项目', icon: 'fas fa-project-diagram' }, { id: 'projects', name: '项目', icon: 'fas fa-project-diagram' },
], ],
bookmarks: { title: '书签页标题', subtitle: '书签页副标题', template: 'bookmarks', categories: [] }, bookmarks: { title: '书签页标题', subtitle: '书签页副标题', template: 'bookmarks', categories: [] },
home: { title: 'HOME_PAGE_TITLE', subtitle: 'HOME_PAGE_SUBTITLE', template: 'home', categories: [] }, home: { title: 'HOME_PAGE_TITLE', subtitle: 'HOME_PAGE_SUBTITLE', template: 'page', categories: [] },
projects: { title: '项目页标题', subtitle: '项目页副标题', template: 'projects', categories: [] }, projects: { title: '项目页标题', subtitle: '项目页副标题', template: 'projects', categories: [] },
}; };

View File

@@ -0,0 +1,295 @@
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 { loadHandlebarsTemplates, generateAllPagesHTML } = require('../src/generator.js');
function withRepoRoot(fn) {
const originalCwd = process.cwd();
process.chdir(path.join(__dirname, '..'));
try {
return fn();
} finally {
process.chdir(originalCwd);
}
}
test('friends/articles应恢复分类展示扩展仍以 data-* 结构为准)', () => {
withRepoRoot(() => {
loadHandlebarsTemplates();
const config = {
site: { title: 'Test Site', description: '', author: '', favicon: '', logo_text: 'Test' },
profile: { title: 'PROFILE_TITLE', subtitle: 'PROFILE_SUBTITLE' },
social: [],
navigation: [
{ id: 'home', name: '首页', icon: 'fas fa-home' },
{ id: 'friends', name: '朋友', icon: 'fas fa-users' },
{ id: 'articles', name: '文章', icon: 'fas fa-book' },
],
home: { title: 'HOME', subtitle: 'HOME_SUB', template: 'page', categories: [] },
friends: {
title: '友情链接',
subtitle: '朋友们',
template: 'page',
categories: [
{
name: '技术博主',
icon: 'fas fa-user-friends',
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
},
],
},
articles: {
title: '文章',
subtitle: '文章入口',
template: 'articles',
categories: [
{
name: '最新文章',
icon: 'fas fa-pen',
sites: [{ name: 'Article A', url: 'https://example.com/a', icon: 'fas fa-link', description: 'summary' }],
},
],
},
};
const pages = generateAllPagesHTML(config);
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
assert.ok(pages.friends.includes('page-template-friends'));
assert.ok(pages.friends.includes('sites-grid'));
assert.ok(pages.friends.includes('class="site-card'), 'friends 应使用普通 site-card 样式(图标在左,标题在右)');
assert.ok(!pages.friends.includes('site-card-friend'), 'friends 不应使用 site-card-friend 变体样式');
assert.ok(pages.friends.includes('category-header'), 'friends 应输出分类标题结构');
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
assert.ok(pages.articles.includes('page-template-articles'));
assert.ok(pages.articles.includes('sites-grid'));
assert.ok(pages.articles.includes('class="site-card'), 'articles 应使用普通 site-card 样式(图标在左,标题在右)');
assert.ok(!pages.articles.includes('site-card-article'), 'articles 不应使用 site-card-article 变体样式');
assert.ok(pages.articles.includes('category-header'), 'articles 应输出分类标题结构');
});
});
test('friends/articles页面配置使用顶层 sites 时应自动映射为分类容器', () => {
withRepoRoot(() => {
loadHandlebarsTemplates();
const config = {
site: { title: 'Test Site', description: '', author: '', favicon: '', logo_text: 'Test' },
profile: { title: 'PROFILE_TITLE', subtitle: 'PROFILE_SUBTITLE' },
social: [],
navigation: [
{ id: 'home', name: '首页', icon: 'fas fa-home' },
{ id: 'friends', name: '朋友', icon: 'fas fa-users' },
{ id: 'articles', name: '文章', icon: 'fas fa-book' },
],
home: { title: 'HOME', subtitle: 'HOME_SUB', template: 'page', categories: [] },
friends: {
title: '友情链接',
subtitle: '朋友们',
template: 'page',
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
},
articles: {
title: '文章',
subtitle: '文章入口',
template: 'articles',
sites: [{ name: 'Article A', url: 'https://example.com/a', icon: 'fas fa-link', description: 'summary' }],
},
};
const pages = generateAllPagesHTML(config);
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
assert.ok(pages.friends.includes('page-template-friends'));
assert.ok(pages.friends.includes('sites-grid'));
assert.ok(pages.friends.includes('class="site-card'), 'friends 应使用普通 site-card 样式(图标在左,标题在右)');
assert.ok(!pages.friends.includes('site-card-friend'), 'friends 不应使用 site-card-friend 变体样式');
assert.ok(pages.friends.includes('category-header'), 'friends 应输出分类标题结构');
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
assert.ok(pages.articles.includes('page-template-articles'));
assert.ok(pages.articles.includes('sites-grid'));
assert.ok(pages.articles.includes('class="site-card'), 'articles 应使用普通 site-card 样式(图标在左,标题在右)');
assert.ok(!pages.articles.includes('site-card-article'), 'articles 不应使用 site-card-article 变体样式');
assert.ok(pages.articles.includes('category-header'), 'articles 应输出分类标题结构');
});
});
test('缺少 friends 页面配置时:仍应渲染页面(标题回退为导航名称)', () => {
withRepoRoot(() => {
loadHandlebarsTemplates();
const config = {
site: { title: 'Test Site', description: '', author: '', favicon: '', logo_text: 'Test' },
profile: { title: 'PROFILE_TITLE', subtitle: 'PROFILE_SUBTITLE' },
social: [],
navigation: [
{ id: 'home', name: '首页', icon: 'fas fa-home' },
{ id: 'friends', name: '朋友', icon: 'fas fa-users' },
],
home: { title: 'HOME', subtitle: 'HOME_SUB', template: 'page', categories: [] },
// 刻意不提供 friends 配置
};
const pages = generateAllPagesHTML(config);
const html = pages.friends;
assert.ok(typeof html === 'string' && html.length > 0);
assert.ok(html.includes('page-template-friends'));
assert.ok(html.includes('data-editable="page-title"'));
assert.ok(html.includes('朋友'));
});
});
test('bookmarks标题区应显示内容更新时间日期 + 来源)', () => {
withRepoRoot(() => {
loadHandlebarsTemplates();
const config = {
site: { title: 'Test Site', description: '', author: '', favicon: '', logo_text: 'Test' },
profile: { title: 'PROFILE_TITLE', subtitle: 'PROFILE_SUBTITLE' },
social: [],
navigation: [
{ id: 'home', name: '首页', icon: 'fas fa-home' },
{ id: 'bookmarks', name: '书签', icon: 'fas fa-bookmark' },
],
home: { title: 'HOME', subtitle: 'HOME_SUB', template: 'page', categories: [] },
bookmarks: { title: '书签', subtitle: '书签页', template: 'bookmarks', categories: [] },
};
const pages = generateAllPagesHTML(config);
const html = pages.bookmarks;
assert.ok(typeof html === 'string' && html.length > 0);
assert.ok(html.includes('page-updated-inline'));
assert.ok(html.includes('update:'), '应显示 update: 前缀');
assert.ok(html.includes('from:'), '应显示 from: 前缀');
assert.ok(/update:\s*\d{4}-\d{2}-\d{2}/.test(html), '应显示 YYYY-MM-DD 日期');
assert.ok(/from:\s*(git|mtime)/.test(html), '应显示来源git|mtime');
});
});
test('projects应输出代码仓库风格卡片site-card-repo', () => {
withRepoRoot(() => {
loadHandlebarsTemplates();
const config = {
site: { title: 'Test Site', description: '', author: '', favicon: '', logo_text: 'Test' },
profile: { title: 'PROFILE_TITLE', subtitle: 'PROFILE_SUBTITLE' },
social: [],
navigation: [{ id: 'projects', name: '项目', icon: 'fas fa-project-diagram' }],
projects: {
title: '项目',
subtitle: '项目页',
template: 'projects',
categories: [
{
name: '项目',
icon: 'fas fa-code',
sites: [{ name: 'Proj', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
},
],
},
};
const pages = generateAllPagesHTML(config);
const html = pages.projects;
assert.ok(typeof html === 'string' && html.length > 0);
assert.ok(html.includes('page-template-projects'), 'projects 应包含模板容器 class');
assert.ok(html.includes('sites-grid'), 'projects 应包含网格容器sites-grid');
assert.ok(html.includes('site-card-repo'), 'projects 应包含代码仓库风格卡片类');
});
});
test('articles Phase 2存在 RSS 缓存时渲染文章条目,并隐藏扩展写回结构', () => {
withRepoRoot(() => {
loadHandlebarsTemplates();
const previousCacheDir = process.env.RSS_CACHE_DIR;
const tmpCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-rss-cache-'));
process.env.RSS_CACHE_DIR = tmpCacheDir;
const cachePath = path.join(tmpCacheDir, 'articles.feed-cache.json');
fs.writeFileSync(
cachePath,
JSON.stringify(
{
version: '1.0',
pageId: 'articles',
generatedAt: '2025-12-26T00:00:00.000Z',
articles: [
{
title: 'Article A',
url: 'https://example.com/a',
summary: 'summary',
publishedAt: '2025-12-25T12:00:00.000Z',
source: 'Example Blog',
sourceUrl: 'https://example.com',
icon: 'fas fa-pen'
}
],
stats: { totalArticles: 1 }
},
null,
2
)
);
try {
const config = {
site: { title: 'Test Site', description: '', author: '', favicon: '', logo_text: 'Test' },
profile: { title: 'PROFILE_TITLE', subtitle: 'PROFILE_SUBTITLE' },
social: [],
navigation: [
{ id: 'home', name: '首页', icon: 'fas fa-home' },
{ id: 'articles', name: '文章', icon: 'fas fa-book' },
],
home: { title: 'HOME', subtitle: 'HOME_SUB', template: 'page', categories: [] },
articles: {
title: '文章',
subtitle: '文章入口',
template: 'articles',
categories: [
{
name: '来源',
icon: 'fas fa-pen',
sites: [{ name: 'Source A', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
},
],
},
};
const pages = generateAllPagesHTML(config);
const html = pages.articles;
assert.ok(typeof html === 'string' && html.length > 0);
assert.ok(html.includes('data-type="article"'), '文章条目卡片应为 data-type="article"(只读)');
assert.ok(html.includes('site-card-meta'), '文章条目应展示日期/来源元信息');
assert.ok(html.includes('Example Blog'));
assert.ok(html.includes('2025-12-25'));
assert.match(
html,
/<section class="category category-level-1 category-readonly">[\s\S]*?来源[\s\S]*?Article A[\s\S]*?<\/section>/,
'文章条目应按页面配置分类聚合展示'
);
assert.ok(html.includes('data-extension-shadow="true"'), '应保留隐藏的扩展写回结构');
assert.ok(html.includes('data-search-exclude="true"'), '扩展影子结构应排除搜索索引');
} finally {
try {
fs.rmSync(tmpCacheDir, { recursive: true, force: true });
} finally {
if (previousCacheDir === undefined) {
delete process.env.RSS_CACHE_DIR;
} else {
process.env.RSS_CACHE_DIR = previousCacheDir;
}
}
}
});
});