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:
27
.github/workflows/deploy.yml
vendored
27
.github/workflows/deploy.yml
vendored
@@ -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-effort):projects 仓库信息、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: |
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
346
assets/style.css
346
assets/style.css
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* projects:GitHub 热力图(标题区右侧,可选) */
|
||||||
|
.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 2:articles 页面隐藏“扩展写回结构”,避免与文章条目渲染混淆 */
|
||||||
|
.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%;
|
||||||
|
|||||||
@@ -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. **RSS(articles 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. **GitHub(projects 热力图,可选)**
|
||||||
|
- `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"
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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-DD(git|mtime)”,无需额外配置
|
||||||
template: bookmarks
|
template: bookmarks
|
||||||
|
|
||||||
categories:
|
categories:
|
||||||
|
|||||||
@@ -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 URL(favicon 模式将尝试加载站点图标)
|
url: https://linux.do/ # http/https URL(favicon 模式将尝试加载站点图标)
|
||||||
@@ -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: 谷歌云平台
|
||||||
@@ -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
|
|
||||||
@@ -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: "#"
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
# RSS(Phase 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
|
||||||
|
|||||||
59
config/update-instructions.md
Normal file
59
config/update-instructions.md
Normal 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
48
package-lock.json
generated
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
687
scripts/sync-articles.js
Normal 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(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/�?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
269
scripts/sync-projects.js
Normal 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
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主函数
|
// 主函数
|
||||||
|
|||||||
575
src/generator.js
575
src/generator.js
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件 mtime(ISO 字符串)
|
||||||
|
* @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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
430
src/script.js
430
src/script.js
@@ -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();
|
||||||
|
|||||||
@@ -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 partial(partial 名称=文件名去掉 `.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. 页面内容可引用现有组件或创建新组件
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
36
templates/components/page-header.hbs
Normal file
36
templates/components/page-header.hbs
Normal 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>
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -64,11 +64,13 @@ test('templates:subgroups(第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);
|
||||||
|
|||||||
@@ -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: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
295
test/template-diff-phase1.node-test.js
Normal file
295
test/template-diff-phase1.node-test.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user