chore: 使用 Prettier 统一代码风格
This commit is contained in:
7
.github/workflows/deploy.yml
vendored
7
.github/workflows/deploy.yml
vendored
@@ -2,7 +2,7 @@ name: Build and Deploy Site
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
# 定时刷新 RSS / projects 仓库元信息(GitHub Actions 的 cron 使用 UTC 时区)
|
# 定时刷新 RSS / projects 仓库元信息(GitHub Actions 的 cron 使用 UTC 时区)
|
||||||
@@ -16,7 +16,7 @@ permissions:
|
|||||||
|
|
||||||
# 允许一个并发部署
|
# 允许一个并发部署
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "pages"
|
group: 'pages'
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -153,7 +153,6 @@ jobs:
|
|||||||
echo "No HTML files found to clean up."
|
echo "No HTML files found to clean up."
|
||||||
fi
|
fi
|
||||||
# --- 书签处理步骤结束 ---
|
# --- 书签处理步骤结束 ---
|
||||||
|
|
||||||
- name: Push configuration changes (if any)
|
- name: Push configuration changes (if any)
|
||||||
# 只有在书签处理步骤修改了文件时才推送
|
# 只有在书签处理步骤修改了文件时才推送
|
||||||
# 使用 GITHUB_TOKEN 推送
|
# 使用 GITHUB_TOKEN 推送
|
||||||
@@ -173,7 +172,7 @@ jobs:
|
|||||||
echo "Pushing changes to repository..."
|
echo "Pushing changes to repository..."
|
||||||
git push "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" HEAD:${{ github.ref_name }}
|
git push "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" HEAD:${{ github.ref_name }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# --- 网站构建和部署步骤 ---
|
# --- 网站构建和部署步骤 ---
|
||||||
# 同步时效性数据(best-effort):projects 仓库信息、articles RSS 聚合
|
# 同步时效性数据(best-effort):projects 仓库信息、articles RSS 聚合
|
||||||
|
|||||||
@@ -117,13 +117,13 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优
|
|||||||
- 示例:
|
- 示例:
|
||||||
```yml
|
```yml
|
||||||
sites:
|
sites:
|
||||||
- name: "Ant Design"
|
- name: 'Ant Design'
|
||||||
url: "https://ant.design/"
|
url: 'https://ant.design/'
|
||||||
icon: "fas fa-th"
|
icon: 'fas fa-th'
|
||||||
forceIconMode: manual # 强制使用手动图标,绕过 favicon 默认"地球"图标
|
forceIconMode: manual # 强制使用手动图标,绕过 favicon 默认"地球"图标
|
||||||
- name: "Example"
|
- name: 'Example'
|
||||||
url: "https://example.com/"
|
url: 'https://example.com/'
|
||||||
faviconUrl: "https://example.com/favicon.png" # 单站点自定义 favicon
|
faviconUrl: 'https://example.com/favicon.png' # 单站点自定义 favicon
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **安全策略(链接白名单)**
|
3. **安全策略(链接白名单)**
|
||||||
@@ -254,46 +254,46 @@ MeNav 配置系统采用“完全替换”策略:只会选择一套目录加
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# 网站基本信息
|
# 网站基本信息
|
||||||
title: "我的个人导航"
|
title: '我的个人导航'
|
||||||
description: "个人收藏的网站导航页"
|
description: '个人收藏的网站导航页'
|
||||||
keywords: "导航,网址,书签,个人主页"
|
keywords: '导航,网址,书签,个人主页'
|
||||||
|
|
||||||
# 个人资料配置
|
# 个人资料配置
|
||||||
profile:
|
profile:
|
||||||
title: "个人导航站"
|
title: '个人导航站'
|
||||||
subtitle: "我收藏的精选网站"
|
subtitle: '我收藏的精选网站'
|
||||||
|
|
||||||
# 字体:全站基础字体
|
# 字体:全站基础字体
|
||||||
fonts:
|
fonts:
|
||||||
source: css
|
source: css
|
||||||
cssUrl: "https://fontsapi.zeoseven.com/292/main/result.css"
|
cssUrl: 'https://fontsapi.zeoseven.com/292/main/result.css'
|
||||||
preload: true
|
preload: true
|
||||||
family: "LXGW WenKai"
|
family: 'LXGW WenKai'
|
||||||
weight: normal
|
weight: normal
|
||||||
|
|
||||||
# 社交媒体链接
|
# 社交媒体链接
|
||||||
social:
|
social:
|
||||||
- name: "GitHub"
|
- name: 'GitHub'
|
||||||
url: "https://github.com/username"
|
url: 'https://github.com/username'
|
||||||
icon: "fab fa-github"
|
icon: 'fab fa-github'
|
||||||
- name: "Twitter"
|
- name: 'Twitter'
|
||||||
url: "https://twitter.com/username"
|
url: 'https://twitter.com/username'
|
||||||
icon: "fab fa-twitter"
|
icon: 'fab fa-twitter'
|
||||||
|
|
||||||
# 导航配置
|
# 导航配置
|
||||||
navigation:
|
navigation:
|
||||||
- name: "常用"
|
- name: '常用'
|
||||||
icon: "fas fa-star"
|
icon: 'fas fa-star'
|
||||||
id: "common"
|
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: "书签"
|
- name: '书签'
|
||||||
icon: "fas fa-bookmark"
|
icon: 'fas fa-bookmark'
|
||||||
id: "bookmarks"
|
id: 'bookmarks'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 通用页面配置示例(例如 common.yml)
|
### 通用页面配置示例(例如 common.yml)
|
||||||
@@ -301,25 +301,25 @@ navigation:
|
|||||||
```yaml
|
```yaml
|
||||||
# 页面分类配置
|
# 页面分类配置
|
||||||
categories:
|
categories:
|
||||||
- name: "常用工具"
|
- name: '常用工具'
|
||||||
icon: "fas fa-tools"
|
icon: 'fas fa-tools'
|
||||||
sites:
|
sites:
|
||||||
- name: "Google"
|
- name: 'Google'
|
||||||
url: "https://www.google.com"
|
url: 'https://www.google.com'
|
||||||
description: "全球最大的搜索引擎"
|
description: '全球最大的搜索引擎'
|
||||||
icon: "fab fa-google"
|
icon: 'fab fa-google'
|
||||||
- name: "GitHub"
|
- name: 'GitHub'
|
||||||
url: "https://github.com"
|
url: 'https://github.com'
|
||||||
description: "代码托管平台"
|
description: '代码托管平台'
|
||||||
icon: "fab fa-github"
|
icon: 'fab fa-github'
|
||||||
|
|
||||||
- name: "学习资源"
|
- name: '学习资源'
|
||||||
icon: "fas fa-graduation-cap"
|
icon: 'fas fa-graduation-cap'
|
||||||
sites:
|
sites:
|
||||||
- name: "MDN Web Docs"
|
- name: 'MDN Web Docs'
|
||||||
url: "https://developer.mozilla.org"
|
url: 'https://developer.mozilla.org'
|
||||||
description: "Web开发技术文档"
|
description: 'Web开发技术文档'
|
||||||
icon: "fab fa-firefox-browser"
|
icon: 'fab fa-firefox-browser'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 最佳实践
|
## 最佳实践
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 默认页面配置(请勿直接修改)。
|
# 默认页面配置(请勿直接修改)。
|
||||||
# 建议复制到 config/user/pages/articles.yml 并按需调整。
|
# 建议复制到 config/user/pages/articles.yml 并按需调整。
|
||||||
title: 技术文章 # 页面标题
|
title: 技术文章 # 页面标题
|
||||||
subtitle: RSS 聚合文章列表 # 页面副标题
|
subtitle: RSS 聚合文章列表 # 页面副标题
|
||||||
|
|
||||||
# 指定使用的模板文件名,现有页面模板可见 templates/pages(不含 .hbs)
|
# 指定使用的模板文件名,现有页面模板可见 templates/pages(不含 .hbs)
|
||||||
template: articles
|
template: articles
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 默认页面配置(请勿直接修改)。
|
# 默认页面配置(请勿直接修改)。
|
||||||
# 建议复制到 config/user/pages/common.yml 并按需调整。
|
# 建议复制到 config/user/pages/common.yml 并按需调整。
|
||||||
title: 常用网站 # 页面标题
|
title: 常用网站 # 页面标题
|
||||||
subtitle: Common website # 页面副标题
|
subtitle: Common website # 页面副标题
|
||||||
|
|
||||||
# 指定使用的模板文件名,现有页面模板可见 templates/pages(不含 .hbs)
|
# 指定使用的模板文件名,现有页面模板可见 templates/pages(不含 .hbs)
|
||||||
# 说明:推荐使用通用模板 page;首页由“导航第一项”决定
|
# 说明:推荐使用通用模板 page;首页由“导航第一项”决定
|
||||||
@@ -12,9 +12,9 @@ categories:
|
|||||||
- name: 置顶
|
- name: 置顶
|
||||||
icon: fas fa-star # 分类图标
|
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 模式将尝试加载站点图标)
|
||||||
icon: fab fa-linux # 手动图标:manual 模式使用;favicon 模式下作为回退
|
icon: fab fa-linux # 手动图标:manual 模式使用;favicon 模式下作为回退
|
||||||
description: 新的理想型社区 # 站点描述
|
description: 新的理想型社区 # 站点描述
|
||||||
- name: Menav
|
- name: Menav
|
||||||
url: https://rbetree.github.io/menav
|
url: https://rbetree.github.io/menav
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 默认页面配置(请勿直接修改)。
|
# 默认页面配置(请勿直接修改)。
|
||||||
# 建议复制到 config/user/pages/projects.yml 并按需调整。
|
# 建议复制到 config/user/pages/projects.yml 并按需调整。
|
||||||
title: 项目 # 页面标题
|
title: 项目 # 页面标题
|
||||||
subtitle: 项目展示 # 页面副标题
|
subtitle: 项目展示 # 页面副标题
|
||||||
|
|
||||||
# 指定使用的模板文件名,现有页面模板可见 templates/pages(不含 .hbs)
|
# 指定使用的模板文件名,现有页面模板可见 templates/pages(不含 .hbs)
|
||||||
template: projects
|
template: projects
|
||||||
@@ -14,20 +14,20 @@ template: projects
|
|||||||
# - `npm run build` 默认不联网;缓存缺失时卡片仅展示标题与描述
|
# - `npm run build` 默认不联网;缓存缺失时卡片仅展示标题与描述
|
||||||
categories:
|
categories:
|
||||||
- name: 个人项目
|
- name: 个人项目
|
||||||
icon: fas fa-code # 分类图标(Font Awesome)
|
icon: fas fa-code # 分类图标(Font Awesome)
|
||||||
sites:
|
sites:
|
||||||
- name: MeNav
|
- name: MeNav
|
||||||
icon: fab fa-github # 手动图标(manual 模式显示;favicon 模式下作为回退)
|
icon: fab fa-github # 手动图标(manual 模式显示;favicon 模式下作为回退)
|
||||||
description: 一键部署的个人导航站生成器,支持书签导入与自动构建,轻松整理展示您的网络收藏 # 站点描述
|
description: 一键部署的个人导航站生成器,支持书签导入与自动构建,轻松整理展示您的网络收藏 # 站点描述
|
||||||
url: https://github.com/rbetree/menav
|
url: https://github.com/rbetree/menav
|
||||||
- name: MarksVault
|
- name: MarksVault
|
||||||
icon: fab fa-github
|
icon: fab fa-github
|
||||||
description: 一个强大的浏览器扩展,用于智能管理、整理和安全备份您的书签数据
|
description: 一个强大的浏览器扩展,用于智能管理、整理和安全备份您的书签数据
|
||||||
url: "https://github.com/rbetree/MarksVault"
|
url: 'https://github.com/rbetree/MarksVault'
|
||||||
- name: star
|
- name: star
|
||||||
icon: fas fa-star
|
icon: fas fa-star
|
||||||
sites:
|
sites:
|
||||||
- name: CLIProxyAPI
|
- name: CLIProxyAPI
|
||||||
icon: fab fa-github
|
icon: fab fa-github
|
||||||
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
|
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: "https://github.com/router-for-me/CLIProxyAPI"
|
url: 'https://github.com/router-for-me/CLIProxyAPI'
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ security:
|
|||||||
# - system: 只使用本地/系统字体,不额外发起请求
|
# - system: 只使用本地/系统字体,不额外发起请求
|
||||||
fonts:
|
fonts:
|
||||||
source: css
|
source: css
|
||||||
cssUrl: "https://fontsapi.zeoseven.com/292/main/result.css"
|
cssUrl: 'https://fontsapi.zeoseven.com/292/main/result.css'
|
||||||
preload: true # 可选:使用 preload+onload 的方式非阻塞加载字体 CSS(更利于首屏性能)
|
preload: true # 可选:使用 preload+onload 的方式非阻塞加载字体 CSS(更利于首屏性能)
|
||||||
family: LXGW WenKai
|
family: LXGW WenKai
|
||||||
weight: normal
|
weight: normal
|
||||||
@@ -67,23 +67,23 @@ rss:
|
|||||||
enabled: true
|
enabled: true
|
||||||
cacheDir: dev
|
cacheDir: dev
|
||||||
fetch:
|
fetch:
|
||||||
timeoutMs: 10000 # 单请求超时(毫秒)
|
timeoutMs: 10000 # 单请求超时(毫秒)
|
||||||
totalTimeoutMs: 60000 # 全流程总超时(毫秒)
|
totalTimeoutMs: 60000 # 全流程总超时(毫秒)
|
||||||
concurrency: 5 # 并发抓取站点数
|
concurrency: 5 # 并发抓取站点数
|
||||||
maxRetries: 1 # 单站点重试次数(best-effort)
|
maxRetries: 1 # 单站点重试次数(best-effort)
|
||||||
maxRedirects: 3 # 最大重定向次数
|
maxRedirects: 3 # 最大重定向次数
|
||||||
articles:
|
articles:
|
||||||
perSite: 8 # 单站点最多抓取条数
|
perSite: 8 # 单站点最多抓取条数
|
||||||
total: 50 # 全站聚合上限
|
total: 50 # 全站聚合上限
|
||||||
summaryMaxLength: 200 # 摘要最大长度(字符)
|
summaryMaxLength: 200 # 摘要最大长度(字符)
|
||||||
|
|
||||||
# GitHub:用于 projects 页面右侧“贡献热力图”(可选)
|
# GitHub:用于 projects 页面右侧“贡献热力图”(可选)
|
||||||
# - username:你的 GitHub 用户名(例如 torvalds)
|
# - username:你的 GitHub 用户名(例如 torvalds)
|
||||||
# - heatmapColor:热力图主题色(不带 #,例如 339af0)
|
# - heatmapColor:热力图主题色(不带 #,例如 339af0)
|
||||||
github:
|
github:
|
||||||
username: "rbetree" # 你的 GitHub 用户名(例如 torvalds;为空则 projects 页不展示热力图)
|
username: 'rbetree' # 你的 GitHub 用户名(例如 torvalds;为空则 projects 页不展示热力图)
|
||||||
heatmapColor: 339af0
|
heatmapColor: 339af0
|
||||||
cacheDir: dev # projects 仓库元信息缓存目录(默认 dev,仓库默认 gitignore)
|
cacheDir: dev # projects 仓库元信息缓存目录(默认 dev,仓库默认 gitignore)
|
||||||
|
|
||||||
# 社交媒体链接:显示在侧边栏底部;可按需增删
|
# 社交媒体链接:显示在侧边栏底部;可按需增删
|
||||||
social:
|
social:
|
||||||
@@ -102,9 +102,9 @@ social:
|
|||||||
|
|
||||||
# 导航配置(顺序第一项即首页/默认打开页)
|
# 导航配置(顺序第一项即首页/默认打开页)
|
||||||
navigation:
|
navigation:
|
||||||
- name: 常用 # 菜单名称
|
- name: 常用 # 菜单名称
|
||||||
icon: fas fa-star # Font Awesome 图标类
|
icon: fas fa-star # Font Awesome 图标类
|
||||||
id: common # 页面标识符(唯一,需与 pages/<id>.yml 对应)
|
id: common # 页面标识符(唯一,需与 pages/<id>.yml 对应)
|
||||||
- name: 项目
|
- name: 项目
|
||||||
icon: fas fa-project-diagram
|
icon: fas fa-project-diagram
|
||||||
id: projects
|
id: projects
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
用途:为 `articles` 页面提供 RSS/Atom 文章聚合数据,供 `npm run sync-articles` 联网抓取并写入缓存;`npm run build` 默认不联网,只读取缓存渲染。
|
用途:为 `articles` 页面提供 RSS/Atom 文章聚合数据,供 `npm run sync-articles` 联网抓取并写入缓存;`npm run build` 默认不联网,只读取缓存渲染。
|
||||||
|
|
||||||
关键字段(默认示例见 `config/_default/site.yml`):
|
关键字段(默认示例见 `config/_default/site.yml`):
|
||||||
|
|
||||||
- `site.rss.enabled`:是否启用 RSS 抓取能力
|
- `site.rss.enabled`:是否启用 RSS 抓取能力
|
||||||
- `site.rss.cacheDir`:缓存目录(建议 `dev/`,仓库默认 gitignore)
|
- `site.rss.cacheDir`:缓存目录(建议 `dev/`,仓库默认 gitignore)
|
||||||
- `site.rss.fetch.*`:抓取参数(超时、并发、重试、重定向等)
|
- `site.rss.fetch.*`:抓取参数(超时、并发、重试、重定向等)
|
||||||
- `site.rss.articles.*`:抓取条数与摘要长度(例如每站点最多 8 篇)
|
- `site.rss.articles.*`:抓取条数与摘要长度(例如每站点最多 8 篇)
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- RSS 抓取只影响 `articles` Phase 2(文章条目只读展示),不会影响扩展对“来源站点(sites)”的写回能力(构建会保留影子写回结构)。
|
- RSS 抓取只影响 `articles` Phase 2(文章条目只读展示),不会影响扩展对“来源站点(sites)”的写回能力(构建会保留影子写回结构)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -26,15 +28,18 @@
|
|||||||
### 1.2 `site.github.*`(projects 仓库元信息 + 热力图)
|
### 1.2 `site.github.*`(projects 仓库元信息 + 热力图)
|
||||||
|
|
||||||
用途:
|
用途:
|
||||||
|
|
||||||
- projects 卡片可展示仓库元信息(language/stars/forks 等,只读),由 `npm run sync-projects` 联网抓取并写入缓存。
|
- projects 卡片可展示仓库元信息(language/stars/forks 等,只读),由 `npm run sync-projects` 联网抓取并写入缓存。
|
||||||
- projects 标题区右侧可选展示 GitHub 贡献热力图。
|
- projects 标题区右侧可选展示 GitHub 贡献热力图。
|
||||||
|
|
||||||
关键字段(默认示例见 `config/_default/site.yml`):
|
关键字段(默认示例见 `config/_default/site.yml`):
|
||||||
|
|
||||||
- `site.github.username`:GitHub 用户名;为空则不展示热力图
|
- `site.github.username`:GitHub 用户名;为空则不展示热力图
|
||||||
- `site.github.heatmapColor`:热力图主题色(不带 `#`,如 `339af0`)
|
- `site.github.heatmapColor`:热力图主题色(不带 `#`,如 `339af0`)
|
||||||
- `site.github.cacheDir`:仓库元信息缓存目录(建议 `dev/`)
|
- `site.github.cacheDir`:仓库元信息缓存目录(建议 `dev/`)
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 仓库元信息来自 GitHub API,属于“时效性数据”,不会写回到 `pages/projects.yml`。
|
- 仓库元信息来自 GitHub API,属于“时效性数据”,不会写回到 `pages/projects.yml`。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -44,6 +49,7 @@
|
|||||||
用途:指定页面使用的模板(对应 `templates/pages/<template>.hbs`,不含扩展名)。
|
用途:指定页面使用的模板(对应 `templates/pages/<template>.hbs`,不含扩展名)。
|
||||||
|
|
||||||
行为规则:
|
行为规则:
|
||||||
|
|
||||||
- 若 `template` 缺省:优先尝试同名模板(`templates/pages/<pageId>.hbs`),不存在则回退到通用 `page` 模板。
|
- 若 `template` 缺省:优先尝试同名模板(`templates/pages/<pageId>.hbs`),不存在则回退到通用 `page` 模板。
|
||||||
- `bookmarks/projects/articles` 等特殊页建议显式配置 `template`,以减少误解。
|
- `bookmarks/projects/articles` 等特殊页建议显式配置 `template`,以减少误解。
|
||||||
|
|
||||||
@@ -56,6 +62,7 @@
|
|||||||
当前版本不再回退读取根目录 `config.yml`/`config.yaml`。
|
当前版本不再回退读取根目录 `config.yml`/`config.yaml`。
|
||||||
|
|
||||||
迁移要点:
|
迁移要点:
|
||||||
|
|
||||||
- 使用模块化配置目录:`config/user/`(优先级最高,完全替换)或 `config/_default/`(默认示例)。
|
- 使用模块化配置目录:`config/user/`(优先级最高,完全替换)或 `config/_default/`(默认示例)。
|
||||||
- 推荐迁移方式:复制 `config/_default/` → `config/user/`,再按需修改 `site.yml` 与 `pages/*.yml`。
|
- 推荐迁移方式:复制 `config/_default/` → `config/user/`,再按需修改 `site.yml` 与 `pages/*.yml`。
|
||||||
|
|
||||||
@@ -66,6 +73,7 @@
|
|||||||
当前版本仅从 `site.yml -> navigation` 读取导航配置,不再读取 `navigation.yml`。
|
当前版本仅从 `site.yml -> navigation` 读取导航配置,不再读取 `navigation.yml`。
|
||||||
|
|
||||||
迁移要点:
|
迁移要点:
|
||||||
|
|
||||||
- 将原 `navigation.yml` 的数组内容移动到 `config/user/site.yml` 的 `navigation:` 字段下。
|
- 将原 `navigation.yml` 的数组内容移动到 `config/user/site.yml` 的 `navigation:` 字段下。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -75,6 +83,7 @@
|
|||||||
当前版本不再维护“首页固定叫 `home`”的遗留逻辑(例如把 `pages/home.yml` 的分类提升到顶层 `config.categories`)。
|
当前版本不再维护“首页固定叫 `home`”的遗留逻辑(例如把 `pages/home.yml` 的分类提升到顶层 `config.categories`)。
|
||||||
|
|
||||||
迁移要点:
|
迁移要点:
|
||||||
|
|
||||||
- 不要依赖固定页面 id `home`。
|
- 不要依赖固定页面 id `home`。
|
||||||
- 首页始终由 `site.yml -> navigation` 的**第一项**决定;其分类内容应写在对应的 `pages/<homePageId>.yml` 中。
|
- 首页始终由 `site.yml -> navigation` 的**第一项**决定;其分类内容应写在对应的 `pages/<homePageId>.yml` 中。
|
||||||
|
|
||||||
@@ -85,10 +94,12 @@
|
|||||||
历史版本可能通过 `navigation[].active` 指定“默认打开页/首页”。
|
历史版本可能通过 `navigation[].active` 指定“默认打开页/首页”。
|
||||||
|
|
||||||
当前版本:
|
当前版本:
|
||||||
|
|
||||||
- 首页/默认打开页始终由 `site.yml -> navigation` 的**第一项**决定
|
- 首页/默认打开页始终由 `site.yml -> navigation` 的**第一项**决定
|
||||||
- `active` 字段将被忽略(即使写了也不会生效)
|
- `active` 字段将被忽略(即使写了也不会生效)
|
||||||
|
|
||||||
迁移要点:
|
迁移要点:
|
||||||
|
|
||||||
- 通过调整 `navigation` 数组顺序来设置首页(把希望作为首页的页面放到第一项)。
|
- 通过调整 `navigation` 数组顺序来设置首页(把希望作为首页的页面放到第一项)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const fs = require("fs");
|
const fs = require('fs');
|
||||||
const path = require("path");
|
const path = require('path');
|
||||||
|
|
||||||
const distPath = path.resolve(__dirname, "..", "dist");
|
const distPath = path.resolve(__dirname, '..', 'dist');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.rmSync(distPath, { recursive: true, force: true });
|
fs.rmSync(distPath, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ const DEFAULT_RSS_SETTINGS = {
|
|||||||
maxRedirects: 3,
|
maxRedirects: 3,
|
||||||
userAgent: 'MeNavRSSSync/1.0',
|
userAgent: 'MeNavRSSSync/1.0',
|
||||||
htmlMaxBytes: 512 * 1024,
|
htmlMaxBytes: 512 * 1024,
|
||||||
feedMaxBytes: 1024 * 1024
|
feedMaxBytes: 1024 * 1024,
|
||||||
},
|
},
|
||||||
articles: {
|
articles: {
|
||||||
perSite: 8,
|
perSite: 8,
|
||||||
total: 50,
|
total: 50,
|
||||||
summaryMaxLength: 200
|
summaryMaxLength: 200,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseBooleanEnv(value, fallback) {
|
function parseBooleanEnv(value, fallback) {
|
||||||
@@ -42,21 +42,22 @@ function parseIntegerEnv(value, fallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRssSettings(config) {
|
function getRssSettings(config) {
|
||||||
const fromConfig = (config && config.site && config.site.rss && typeof config.site.rss === 'object')
|
const fromConfig =
|
||||||
? config.site.rss
|
config && config.site && config.site.rss && typeof config.site.rss === 'object'
|
||||||
: {};
|
? config.site.rss
|
||||||
|
: {};
|
||||||
|
|
||||||
const merged = {
|
const merged = {
|
||||||
...DEFAULT_RSS_SETTINGS,
|
...DEFAULT_RSS_SETTINGS,
|
||||||
...fromConfig,
|
...fromConfig,
|
||||||
fetch: {
|
fetch: {
|
||||||
...DEFAULT_RSS_SETTINGS.fetch,
|
...DEFAULT_RSS_SETTINGS.fetch,
|
||||||
...(fromConfig.fetch || {})
|
...(fromConfig.fetch || {}),
|
||||||
},
|
},
|
||||||
articles: {
|
articles: {
|
||||||
...DEFAULT_RSS_SETTINGS.articles,
|
...DEFAULT_RSS_SETTINGS.articles,
|
||||||
...(fromConfig.articles || {})
|
...(fromConfig.articles || {}),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 环境变量覆盖(主要给 CI 调试/降级用)
|
// 环境变量覆盖(主要给 CI 调试/降级用)
|
||||||
@@ -64,12 +65,27 @@ function getRssSettings(config) {
|
|||||||
merged.cacheDir = process.env.RSS_CACHE_DIR ? String(process.env.RSS_CACHE_DIR) : merged.cacheDir;
|
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.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.maxRetries = parseIntegerEnv(
|
||||||
merged.fetch.concurrency = parseIntegerEnv(process.env.RSS_FETCH_CONCURRENCY, merged.fetch.concurrency);
|
process.env.RSS_FETCH_MAX_RETRIES,
|
||||||
merged.fetch.totalTimeoutMs = parseIntegerEnv(process.env.RSS_TOTAL_TIMEOUT, merged.fetch.totalTimeoutMs);
|
merged.fetch.maxRetries
|
||||||
merged.fetch.maxRedirects = parseIntegerEnv(process.env.RSS_FETCH_MAX_REDIRECTS, merged.fetch.maxRedirects);
|
);
|
||||||
|
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.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.total = parseIntegerEnv(process.env.RSS_ARTICLES_TOTAL, merged.articles.total);
|
||||||
merged.articles.summaryMaxLength = parseIntegerEnv(
|
merged.articles.summaryMaxLength = parseIntegerEnv(
|
||||||
process.env.RSS_SUMMARY_MAX_LENGTH,
|
process.env.RSS_SUMMARY_MAX_LENGTH,
|
||||||
@@ -104,8 +120,9 @@ function isPrivateIp(ip) {
|
|||||||
if (!ip) return true;
|
if (!ip) return true;
|
||||||
|
|
||||||
if (net.isIP(ip) === 4) {
|
if (net.isIP(ip) === 4) {
|
||||||
const parts = ip.split('.').map(n => Number.parseInt(n, 10));
|
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;
|
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255))
|
||||||
|
return true;
|
||||||
|
|
||||||
const [a, b] = parts;
|
const [a, b] = parts;
|
||||||
if (a === 10) return true;
|
if (a === 10) return true;
|
||||||
@@ -152,7 +169,12 @@ async function assertSafeToFetch(url, timeoutMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hostname = u.hostname.toLowerCase();
|
const hostname = u.hostname.toLowerCase();
|
||||||
if (hostname === 'localhost' || hostname === '0.0.0.0' || hostname === '127.0.0.1' || hostname === '::1') {
|
if (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname === '0.0.0.0' ||
|
||||||
|
hostname === '127.0.0.1' ||
|
||||||
|
hostname === '::1'
|
||||||
|
) {
|
||||||
throw new Error('禁止访问本机地址');
|
throw new Error('禁止访问本机地址');
|
||||||
}
|
}
|
||||||
if (hostname.endsWith('.local')) {
|
if (hostname.endsWith('.local')) {
|
||||||
@@ -175,14 +197,14 @@ async function assertSafeToFetch(url, timeoutMs) {
|
|||||||
throw new Error('DNS 解析失败或无结果');
|
throw new Error('DNS 解析失败或无结果');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPrivate = records.some(r => isPrivateIp(r.address));
|
const hasPrivate = records.some((r) => isPrivateIp(r.address));
|
||||||
if (hasPrivate) throw new Error('DNS 解析到内网/保留地址,已阻断');
|
if (hasPrivate) throw new Error('DNS 解析到内网/保留地址,已阻断');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHeaders(userAgent) {
|
function buildHeaders(userAgent) {
|
||||||
return {
|
return {
|
||||||
'user-agent': userAgent,
|
'user-agent': userAgent,
|
||||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +222,7 @@ async function fetchWithRedirects(url, { timeoutMs, maxRedirects, headers, maxBy
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
headers,
|
headers,
|
||||||
signal: controller.signal
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -283,7 +305,7 @@ function extractFeedLinksFromHtml(html, baseUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 简单排序:优先 RSS,其次 Atom
|
// 简单排序:优先 RSS,其次 Atom
|
||||||
const rank = url => (url.includes('atom') ? 2 : 1);
|
const rank = (url) => (url.includes('atom') ? 2 : 1);
|
||||||
return [...new Set(candidates)].sort((a, b) => rank(a) - rank(b));
|
return [...new Set(candidates)].sort((a, b) => rank(a) - rank(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,11 +331,15 @@ async function discoverFeedUrl(siteUrl, settings, deadlineTs) {
|
|||||||
timeoutMs: Math.min(settings.fetch.timeoutMs, timeRemaining),
|
timeoutMs: Math.min(settings.fetch.timeoutMs, timeRemaining),
|
||||||
maxRedirects: settings.fetch.maxRedirects,
|
maxRedirects: settings.fetch.maxRedirects,
|
||||||
headers: buildHeaders(settings.fetch.userAgent),
|
headers: buildHeaders(settings.fetch.userAgent),
|
||||||
maxBytes: settings.fetch.htmlMaxBytes
|
maxBytes: settings.fetch.htmlMaxBytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contentType = homepage.response.headers.get('content-type') || '';
|
const contentType = homepage.response.headers.get('content-type') || '';
|
||||||
if (/text\/html/i.test(contentType) || /application\/xhtml\+xml/i.test(contentType) || !contentType) {
|
if (
|
||||||
|
/text\/html/i.test(contentType) ||
|
||||||
|
/application\/xhtml\+xml/i.test(contentType) ||
|
||||||
|
!contentType
|
||||||
|
) {
|
||||||
const candidates = extractFeedLinksFromHtml(homepage.text, homepage.url);
|
const candidates = extractFeedLinksFromHtml(homepage.text, homepage.url);
|
||||||
if (candidates.length > 0) {
|
if (candidates.length > 0) {
|
||||||
return candidates[0];
|
return candidates[0];
|
||||||
@@ -325,7 +351,8 @@ async function discoverFeedUrl(siteUrl, settings, deadlineTs) {
|
|||||||
|
|
||||||
function stripHtmlToText(input) {
|
function stripHtmlToText(input) {
|
||||||
const raw = String(input || '');
|
const raw = String(input || '');
|
||||||
const withoutTags = raw.replace(/<script[\s\S]*?<\/script>/gi, '')
|
const withoutTags = raw
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
.replace(/<[^>]+>/g, ' ');
|
.replace(/<[^>]+>/g, ' ');
|
||||||
|
|
||||||
@@ -363,17 +390,14 @@ function normalizePublishedAt(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeArticle(item, sourceSite, settings) {
|
function normalizeArticle(item, sourceSite, settings) {
|
||||||
const title = (item && item.title !== undefined) ? String(item.title).trim() : '';
|
const title = item && item.title !== undefined ? String(item.title).trim() : '';
|
||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
|
|
||||||
const link = item && item.link ? String(item.link).trim() : '';
|
const link = item && item.link ? String(item.link).trim() : '';
|
||||||
if (!isHttpUrl(link)) return null;
|
if (!isHttpUrl(link)) return null;
|
||||||
|
|
||||||
const summaryRaw =
|
const summaryRaw =
|
||||||
(item && item.contentSnippet) ||
|
(item && item.contentSnippet) || (item && item.summary) || (item && item.content) || '';
|
||||||
(item && item.summary) ||
|
|
||||||
(item && item.content) ||
|
|
||||||
'';
|
|
||||||
const summaryText = stripHtmlToText(summaryRaw);
|
const summaryText = stripHtmlToText(summaryRaw);
|
||||||
const summary = settings.articles.summaryMaxLength
|
const summary = settings.articles.summaryMaxLength
|
||||||
? truncateText(summaryText, settings.articles.summaryMaxLength)
|
? truncateText(summaryText, settings.articles.summaryMaxLength)
|
||||||
@@ -393,7 +417,7 @@ function normalizeArticle(item, sourceSite, settings) {
|
|||||||
source,
|
source,
|
||||||
// 站点首页 URL(用于生成端按分类聚合展示;文章 url 为具体文章链接)
|
// 站点首页 URL(用于生成端按分类聚合展示;文章 url 为具体文章链接)
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
icon
|
icon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,13 +430,17 @@ async function fetchAndParseFeed(feedUrl, settings, parser, deadlineTs) {
|
|||||||
maxRedirects: settings.fetch.maxRedirects,
|
maxRedirects: settings.fetch.maxRedirects,
|
||||||
headers: {
|
headers: {
|
||||||
...buildHeaders(settings.fetch.userAgent),
|
...buildHeaders(settings.fetch.userAgent),
|
||||||
accept: 'application/rss+xml,application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8'
|
accept: 'application/rss+xml,application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8',
|
||||||
},
|
},
|
||||||
maxBytes: settings.fetch.feedMaxBytes
|
maxBytes: settings.fetch.feedMaxBytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = await parser.parseString(feed.text);
|
const parsed = await parser.parseString(feed.text);
|
||||||
return { feedUrl: feed.url, feedTitle: parsed.title || '', items: Array.isArray(parsed.items) ? parsed.items : [] };
|
return {
|
||||||
|
feedUrl: feed.url,
|
||||||
|
feedTitle: parsed.title || '',
|
||||||
|
items: Array.isArray(parsed.items) ? parsed.items : [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
||||||
@@ -425,18 +453,18 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
|||||||
feedUrl: '',
|
feedUrl: '',
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
error: '无效 URL(需为 http/https)',
|
error: '无效 URL(需为 http/https)',
|
||||||
fetchedAt: new Date().toISOString()
|
fetchedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
articles: []
|
articles: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
|
|
||||||
const tryOnce = async feedUrl => {
|
const tryOnce = async (feedUrl) => {
|
||||||
const parsed = await fetchAndParseFeed(feedUrl, settings, parser, deadlineTs);
|
const parsed = await fetchAndParseFeed(feedUrl, settings, parser, deadlineTs);
|
||||||
const normalized = parsed.items
|
const normalized = parsed.items
|
||||||
.map(item => normalizeArticle(item, sourceSite, settings))
|
.map((item) => normalizeArticle(item, sourceSite, settings))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, settings.articles.perSite);
|
.slice(0, settings.articles.perSite);
|
||||||
return { feedUrl: parsed.feedUrl, articles: normalized };
|
return { feedUrl: parsed.feedUrl, articles: normalized };
|
||||||
@@ -444,7 +472,9 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
|||||||
|
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
const discovered = await discoverFeedUrl(url, settings, deadlineTs);
|
const discovered = await discoverFeedUrl(url, settings, deadlineTs);
|
||||||
const candidates = discovered ? [discovered, ...buildCommonFeedUrls(url)] : buildCommonFeedUrls(url);
|
const candidates = discovered
|
||||||
|
? [discovered, ...buildCommonFeedUrls(url)]
|
||||||
|
: buildCommonFeedUrls(url);
|
||||||
|
|
||||||
for (const candidate of [...new Set(candidates)]) {
|
for (const candidate of [...new Set(candidates)]) {
|
||||||
try {
|
try {
|
||||||
@@ -471,9 +501,9 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
error: '',
|
error: '',
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
durationMs: Date.now() - startedAt
|
durationMs: Date.now() - startedAt,
|
||||||
},
|
},
|
||||||
articles: res.articles
|
articles: res.articles,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lastError = e;
|
lastError = e;
|
||||||
@@ -488,9 +518,9 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
|||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: lastError ? String(lastError.message || lastError) : '未知错误',
|
error: lastError ? String(lastError.message || lastError) : '未知错误',
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
durationMs: Date.now() - startedAt
|
durationMs: Date.now() - startedAt,
|
||||||
},
|
},
|
||||||
articles: []
|
articles: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,12 +554,15 @@ async function mapWithConcurrency(items, concurrency, worker) {
|
|||||||
function collectSitesRecursively(node, output) {
|
function collectSitesRecursively(node, output) {
|
||||||
if (!node || typeof node !== 'object') return;
|
if (!node || typeof node !== 'object') return;
|
||||||
|
|
||||||
if (Array.isArray(node.subcategories)) node.subcategories.forEach(child => collectSitesRecursively(child, output));
|
if (Array.isArray(node.subcategories))
|
||||||
if (Array.isArray(node.groups)) node.groups.forEach(child => collectSitesRecursively(child, output));
|
node.subcategories.forEach((child) => collectSitesRecursively(child, output));
|
||||||
if (Array.isArray(node.subgroups)) node.subgroups.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)) {
|
if (Array.isArray(node.sites)) {
|
||||||
node.sites.forEach(site => {
|
node.sites.forEach((site) => {
|
||||||
if (site && typeof site === 'object') output.push(site);
|
if (site && typeof site === 'object') output.push(site);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -538,26 +571,26 @@ function collectSitesRecursively(node, output) {
|
|||||||
function buildFlatSitesFromCategories(categories) {
|
function buildFlatSitesFromCategories(categories) {
|
||||||
const out = [];
|
const out = [];
|
||||||
if (!Array.isArray(categories)) return out;
|
if (!Array.isArray(categories)) return out;
|
||||||
categories.forEach(category => collectSitesRecursively(category, out));
|
categories.forEach((category) => collectSitesRecursively(category, out));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
||||||
const sourceSites = Array.isArray(pageConfig && pageConfig.sites)
|
const sourceSites = Array.isArray(pageConfig && pageConfig.sites)
|
||||||
? pageConfig.sites
|
? pageConfig.sites
|
||||||
: buildFlatSitesFromCategories(pageConfig && Array.isArray(pageConfig.categories) ? pageConfig.categories : []);
|
: buildFlatSitesFromCategories(
|
||||||
|
pageConfig && Array.isArray(pageConfig.categories) ? pageConfig.categories : []
|
||||||
|
);
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const deadlineTs = startedAt + settings.fetch.totalTimeoutMs;
|
const deadlineTs = startedAt + settings.fetch.totalTimeoutMs;
|
||||||
|
|
||||||
const parser = new Parser({
|
const parser = new Parser({
|
||||||
timeout: settings.fetch.timeoutMs
|
timeout: settings.fetch.timeoutMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await mapWithConcurrency(
|
const results = await mapWithConcurrency(sourceSites, settings.fetch.concurrency, async (site) =>
|
||||||
sourceSites,
|
processSourceSite(site, settings, parser, deadlineTs)
|
||||||
settings.fetch.concurrency,
|
|
||||||
async site => processSourceSite(site, settings, parser, deadlineTs)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sites = [];
|
const sites = [];
|
||||||
@@ -585,9 +618,9 @@ async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
|||||||
|
|
||||||
const limitedArticles = articles.slice(0, settings.articles.total);
|
const limitedArticles = articles.slice(0, settings.articles.total);
|
||||||
|
|
||||||
const successSites = sites.filter(s => s.status === 'success').length;
|
const successSites = sites.filter((s) => s.status === 'success').length;
|
||||||
const failedSites = sites.filter(s => s.status === 'failed').length;
|
const failedSites = sites.filter((s) => s.status === 'failed').length;
|
||||||
const skippedSites = sites.filter(s => s.status === 'skipped').length;
|
const skippedSites = sites.filter((s) => s.status === 'skipped').length;
|
||||||
|
|
||||||
const cache = {
|
const cache = {
|
||||||
version: '1.0',
|
version: '1.0',
|
||||||
@@ -602,8 +635,8 @@ async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
|||||||
failedSites,
|
failedSites,
|
||||||
skippedSites,
|
skippedSites,
|
||||||
totalArticles: limitedArticles.length,
|
totalArticles: limitedArticles.length,
|
||||||
durationMs: Date.now() - startedAt
|
durationMs: Date.now() - startedAt,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheDir = path.resolve(process.cwd(), settings.cacheDir);
|
const cacheDir = path.resolve(process.cwd(), settings.cacheDir);
|
||||||
@@ -638,7 +671,7 @@ function pickArticlesPages(config, onlyPageId) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const pageArgIndex = args.findIndex(a => a === '--page');
|
const pageArgIndex = args.findIndex((a) => a === '--page');
|
||||||
const onlyPageId = pageArgIndex >= 0 ? args[pageArgIndex + 1] : null;
|
const onlyPageId = pageArgIndex >= 0 ? args[pageArgIndex + 1] : null;
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -661,7 +694,9 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const { cachePath, cache } = await syncArticlesForPage(pageId, pageConfig, config, settings);
|
const { cachePath, cache } = await syncArticlesForPage(pageId, pageConfig, config, settings);
|
||||||
console.log(`[INFO] 已生成缓存:${cachePath}(articles=${cache.stats.totalArticles}, sites=${cache.stats.totalSites})`);
|
console.log(
|
||||||
|
`[INFO] 已生成缓存:${cachePath}(articles=${cache.stats.totalArticles}, sites=${cache.stats.totalSites})`
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[WARN] 页面 ${pageId} 同步失败:${e.message || e}`);
|
console.warn(`[WARN] 页面 ${pageId} 同步失败:${e.message || e}`);
|
||||||
// best-effort:不阻断其他页面/后续 build
|
// best-effort:不阻断其他页面/后续 build
|
||||||
@@ -670,7 +705,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
main().catch(err => {
|
main().catch((err) => {
|
||||||
console.error('[ERROR] sync-articles 执行失败:', err);
|
console.error('[ERROR] sync-articles 执行失败:', err);
|
||||||
// best-effort:不阻断后续 build/deploy(错误已输出到日志,便于排查)
|
// best-effort:不阻断后续 build/deploy(错误已输出到日志,便于排查)
|
||||||
process.exitCode = 0;
|
process.exitCode = 0;
|
||||||
@@ -683,5 +718,5 @@ module.exports = {
|
|||||||
extractFeedLinksFromHtml,
|
extractFeedLinksFromHtml,
|
||||||
stripHtmlToText,
|
stripHtmlToText,
|
||||||
normalizeArticle,
|
normalizeArticle,
|
||||||
buildFlatSitesFromCategories
|
buildFlatSitesFromCategories,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ const DEFAULT_SETTINGS = {
|
|||||||
fetch: {
|
fetch: {
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
concurrency: 4,
|
concurrency: 4,
|
||||||
userAgent: 'MeNavProjectsSync/1.0'
|
userAgent: 'MeNavProjectsSync/1.0',
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
url: 'https://raw.githubusercontent.com/ozh/github-colors/master/colors.json',
|
url: 'https://raw.githubusercontent.com/ozh/github-colors/master/colors.json',
|
||||||
maxAgeMs: 7 * 24 * 60 * 60 * 1000
|
maxAgeMs: 7 * 24 * 60 * 60 * 1000,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseBooleanEnv(value, fallback) {
|
function parseBooleanEnv(value, fallback) {
|
||||||
@@ -34,25 +34,35 @@ function parseIntegerEnv(value, fallback) {
|
|||||||
|
|
||||||
function getSettings(config) {
|
function getSettings(config) {
|
||||||
const fromConfig =
|
const fromConfig =
|
||||||
config && config.site && config.site.github && typeof config.site.github === 'object' ? config.site.github : {};
|
config && config.site && config.site.github && typeof config.site.github === 'object'
|
||||||
|
? config.site.github
|
||||||
|
: {};
|
||||||
|
|
||||||
const merged = {
|
const merged = {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...fromConfig,
|
...fromConfig,
|
||||||
fetch: {
|
fetch: {
|
||||||
...DEFAULT_SETTINGS.fetch,
|
...DEFAULT_SETTINGS.fetch,
|
||||||
...(fromConfig.fetch || {})
|
...(fromConfig.fetch || {}),
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
...DEFAULT_SETTINGS.colors,
|
...DEFAULT_SETTINGS.colors,
|
||||||
...(fromConfig.colors || {})
|
...(fromConfig.colors || {}),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
merged.enabled = parseBooleanEnv(process.env.PROJECTS_ENABLED, merged.enabled);
|
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.cacheDir = process.env.PROJECTS_CACHE_DIR
|
||||||
merged.fetch.timeoutMs = parseIntegerEnv(process.env.PROJECTS_FETCH_TIMEOUT, merged.fetch.timeoutMs);
|
? String(process.env.PROJECTS_CACHE_DIR)
|
||||||
merged.fetch.concurrency = parseIntegerEnv(process.env.PROJECTS_FETCH_CONCURRENCY, merged.fetch.concurrency);
|
: 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.timeoutMs = Math.max(1_000, merged.fetch.timeoutMs);
|
||||||
merged.fetch.concurrency = Math.max(1, Math.min(10, merged.fetch.concurrency));
|
merged.fetch.concurrency = Math.max(1, Math.min(10, merged.fetch.concurrency));
|
||||||
@@ -83,16 +93,19 @@ function isGithubRepoUrl(url) {
|
|||||||
|
|
||||||
function collectSitesRecursively(node, output) {
|
function collectSitesRecursively(node, output) {
|
||||||
if (!node || typeof node !== 'object') return;
|
if (!node || typeof node !== 'object') return;
|
||||||
if (Array.isArray(node.subcategories)) node.subcategories.forEach(child => collectSitesRecursively(child, output));
|
if (Array.isArray(node.subcategories))
|
||||||
if (Array.isArray(node.groups)) node.groups.forEach(child => collectSitesRecursively(child, output));
|
node.subcategories.forEach((child) => collectSitesRecursively(child, output));
|
||||||
if (Array.isArray(node.subgroups)) node.subgroups.forEach(child => collectSitesRecursively(child, output));
|
if (Array.isArray(node.groups))
|
||||||
if (Array.isArray(node.sites)) node.sites.forEach(site => output.push(site));
|
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) {
|
function findProjectsPages(config) {
|
||||||
const pages = [];
|
const pages = [];
|
||||||
const nav = Array.isArray(config.navigation) ? config.navigation : [];
|
const nav = Array.isArray(config.navigation) ? config.navigation : [];
|
||||||
nav.forEach(item => {
|
nav.forEach((item) => {
|
||||||
const pageId = item && item.id ? String(item.id) : '';
|
const pageId = item && item.id ? String(item.id) : '';
|
||||||
if (!pageId || !config[pageId]) return;
|
if (!pageId || !config[pageId]) return;
|
||||||
const page = config[pageId];
|
const page = config[pageId];
|
||||||
@@ -131,13 +144,18 @@ async function loadLanguageColors(settings, cacheBaseDir) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const headers = { 'user-agent': settings.fetch.userAgent, accept: 'application/json' };
|
const headers = { 'user-agent': settings.fetch.userAgent, accept: 'application/json' };
|
||||||
const colors = await fetchJsonWithTimeout(settings.colors.url, { timeoutMs: settings.fetch.timeoutMs, headers });
|
const colors = await fetchJsonWithTimeout(settings.colors.url, {
|
||||||
|
timeoutMs: settings.fetch.timeoutMs,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
if (colors && typeof colors === 'object') {
|
if (colors && typeof colors === 'object') {
|
||||||
fs.writeFileSync(cachePath, JSON.stringify(colors, null, 2), 'utf8');
|
fs.writeFileSync(cachePath, JSON.stringify(colors, null, 2), 'utf8');
|
||||||
return colors;
|
return colors;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WARN] 获取语言颜色表失败(将不输出 languageColor):${String(error && error.message ? error.message : error)}`);
|
console.warn(
|
||||||
|
`[WARN] 获取语言颜色表失败(将不输出 languageColor):${String(error && error.message ? error.message : error)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@@ -146,7 +164,7 @@ async function loadLanguageColors(settings, cacheBaseDir) {
|
|||||||
async function fetchRepoMeta(repo, settings, colors) {
|
async function fetchRepoMeta(repo, settings, colors) {
|
||||||
const headers = {
|
const headers = {
|
||||||
'user-agent': settings.fetch.userAgent,
|
'user-agent': settings.fetch.userAgent,
|
||||||
accept: 'application/vnd.github+json'
|
accept: 'application/vnd.github+json',
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiUrl = `https://api.github.com/repos/${repo.owner}/${repo.repo}`;
|
const apiUrl = `https://api.github.com/repos/${repo.owner}/${repo.repo}`;
|
||||||
@@ -167,7 +185,7 @@ async function fetchRepoMeta(repo, settings, colors) {
|
|||||||
language,
|
language,
|
||||||
languageColor,
|
languageColor,
|
||||||
stars,
|
stars,
|
||||||
forks
|
forks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +217,9 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheBaseDir = path.isAbsolute(settings.cacheDir) ? settings.cacheDir : path.join(process.cwd(), settings.cacheDir);
|
const cacheBaseDir = path.isAbsolute(settings.cacheDir)
|
||||||
|
? settings.cacheDir
|
||||||
|
: path.join(process.cwd(), settings.cacheDir);
|
||||||
ensureDir(cacheBaseDir);
|
ensureDir(cacheBaseDir);
|
||||||
|
|
||||||
const colors = await loadLanguageColors(settings, cacheBaseDir);
|
const colors = await loadLanguageColors(settings, cacheBaseDir);
|
||||||
@@ -213,14 +233,14 @@ async function main() {
|
|||||||
for (const { pageId, page } of pages) {
|
for (const { pageId, page } of pages) {
|
||||||
const categories = Array.isArray(page.categories) ? page.categories : [];
|
const categories = Array.isArray(page.categories) ? page.categories : [];
|
||||||
const sites = [];
|
const sites = [];
|
||||||
categories.forEach(category => collectSitesRecursively(category, sites));
|
categories.forEach((category) => collectSitesRecursively(category, sites));
|
||||||
|
|
||||||
const repos = sites
|
const repos = sites
|
||||||
.map(site => (site && site.url ? isGithubRepoUrl(site.url) : null))
|
.map((site) => (site && site.url ? isGithubRepoUrl(site.url) : null))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const unique = new Map();
|
const unique = new Map();
|
||||||
repos.forEach(r => unique.set(r.canonicalUrl, r));
|
repos.forEach((r) => unique.set(r.canonicalUrl, r));
|
||||||
const repoList = Array.from(unique.values());
|
const repoList = Array.from(unique.values());
|
||||||
|
|
||||||
if (!repoList.length) {
|
if (!repoList.length) {
|
||||||
@@ -231,14 +251,16 @@ async function main() {
|
|||||||
let success = 0;
|
let success = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
const results = await runPool(repoList, settings.fetch.concurrency, async repo => {
|
const results = await runPool(repoList, settings.fetch.concurrency, async (repo) => {
|
||||||
try {
|
try {
|
||||||
const meta = await fetchRepoMeta(repo, settings, colors);
|
const meta = await fetchRepoMeta(repo, settings, colors);
|
||||||
success += 1;
|
success += 1;
|
||||||
return meta;
|
return meta;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
console.warn(`[WARN] 拉取失败:${repo.canonicalUrl}(${String(error && error.message ? error.message : error)})`);
|
console.warn(
|
||||||
|
`[WARN] 拉取失败:${repo.canonicalUrl}(${String(error && error.message ? error.message : error)})`
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -251,19 +273,20 @@ async function main() {
|
|||||||
stats: {
|
stats: {
|
||||||
totalRepos: repoList.length,
|
totalRepos: repoList.length,
|
||||||
success,
|
success,
|
||||||
failed
|
failed,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const cachePath = path.join(cacheBaseDir, `${pageId}.repo-cache.json`);
|
const cachePath = path.join(cacheBaseDir, `${pageId}.repo-cache.json`);
|
||||||
fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2), 'utf8');
|
fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
|
||||||
console.log(`[INFO] 页面 ${pageId}:同步完成(成功 ${success} / 失败 ${failed}),写入缓存 ${cachePath}`);
|
console.log(
|
||||||
|
`[INFO] 页面 ${pageId}:同步完成(成功 ${success} / 失败 ${failed}),写入缓存 ${cachePath}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(error => {
|
main().catch((error) => {
|
||||||
console.error('[ERROR] projects 同步异常:', error);
|
console.error('[ERROR] projects 同步异常:', error);
|
||||||
process.exitCode = 0; // best-effort:不阻断后续 build
|
process.exitCode = 0; // best-effort:不阻断后续 build
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ function ensureUserConfigInitialized() {
|
|||||||
|
|
||||||
if (fs.existsSync(CONFIG_DEFAULT_DIR)) {
|
if (fs.existsSync(CONFIG_DEFAULT_DIR)) {
|
||||||
fs.cpSync(CONFIG_DEFAULT_DIR, CONFIG_USER_DIR, { recursive: true });
|
fs.cpSync(CONFIG_DEFAULT_DIR, CONFIG_USER_DIR, { recursive: true });
|
||||||
console.log('[INFO] 检测到 config/user 不存在,已从 config/_default 初始化用户配置(完全替换策略需要完整配置)。');
|
console.log(
|
||||||
|
'[INFO] 检测到 config/user 不存在,已从 config/_default 初始化用户配置(完全替换策略需要完整配置)。'
|
||||||
|
);
|
||||||
return { initialized: true, source: '_default' };
|
return { initialized: true, source: '_default' };
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(CONFIG_USER_DIR, { recursive: true });
|
fs.mkdirSync(CONFIG_USER_DIR, { recursive: true });
|
||||||
console.log('[WARN] 未找到默认配置目录 config/_default,已创建空的 config/user 目录;建议补齐 site.yml 与 pages/*.yml。');
|
console.log(
|
||||||
|
'[WARN] 未找到默认配置目录 config/_default,已创建空的 config/user 目录;建议补齐 site.yml 与 pages/*.yml。'
|
||||||
|
);
|
||||||
return { initialized: true, source: 'empty' };
|
return { initialized: true, source: 'empty' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +52,9 @@ function ensureUserSiteYmlExists() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[WARN] 未找到可用的 site.yml,无法自动更新导航;请手动在 config/user/site.yml 添加 navigation(含 id: bookmarks)。');
|
console.log(
|
||||||
|
'[WARN] 未找到可用的 site.yml,无法自动更新导航;请手动在 config/user/site.yml 添加 navigation(含 id: bookmarks)。'
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +69,7 @@ function upsertBookmarksNavInSiteYml(siteYmlPath) {
|
|||||||
|
|
||||||
const navigation = loaded.navigation;
|
const navigation = loaded.navigation;
|
||||||
|
|
||||||
if (Array.isArray(navigation) && navigation.some(item => item && item.id === 'bookmarks')) {
|
if (Array.isArray(navigation) && navigation.some((item) => item && item.id === 'bookmarks')) {
|
||||||
return { updated: false, reason: 'already_present' };
|
return { updated: false, reason: 'already_present' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@ function upsertBookmarksNavInSiteYml(siteYmlPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lines = raw.split(/\r?\n/);
|
const lines = raw.split(/\r?\n/);
|
||||||
const navLineIndex = lines.findIndex(line => /^navigation\s*:/.test(line));
|
const navLineIndex = lines.findIndex((line) => /^navigation\s*:/.test(line));
|
||||||
|
|
||||||
const itemIndent = ' ';
|
const itemIndent = ' ';
|
||||||
const propIndent = `${itemIndent} `;
|
const propIndent = `${itemIndent} `;
|
||||||
@@ -149,15 +155,15 @@ const ICON_MAPPING = {
|
|||||||
'netflix.com': 'fas fa-film',
|
'netflix.com': 'fas fa-film',
|
||||||
'trello.com': 'fab fa-trello',
|
'trello.com': 'fab fa-trello',
|
||||||
'wordpress.com': 'fab fa-wordpress',
|
'wordpress.com': 'fab fa-wordpress',
|
||||||
'jira': 'fab fa-jira',
|
jira: 'fab fa-jira',
|
||||||
'atlassian.com': 'fab fa-atlassian',
|
'atlassian.com': 'fab fa-atlassian',
|
||||||
'dropbox.com': 'fab fa-dropbox',
|
'dropbox.com': 'fab fa-dropbox',
|
||||||
'npm': 'fab fa-npm',
|
npm: 'fab fa-npm',
|
||||||
'docker.com': 'fab fa-docker',
|
'docker.com': 'fab fa-docker',
|
||||||
'python.org': 'fab fa-python',
|
'python.org': 'fab fa-python',
|
||||||
'javascript': 'fab fa-js',
|
javascript: 'fab fa-js',
|
||||||
'php.net': 'fab fa-php',
|
'php.net': 'fab fa-php',
|
||||||
'java': 'fab fa-java',
|
java: 'fab fa-java',
|
||||||
'codepen.io': 'fab fa-codepen',
|
'codepen.io': 'fab fa-codepen',
|
||||||
'behance.net': 'fab fa-behance',
|
'behance.net': 'fab fa-behance',
|
||||||
'dribbble.com': 'fab fa-dribbble',
|
'dribbble.com': 'fab fa-dribbble',
|
||||||
@@ -166,10 +172,10 @@ const ICON_MAPPING = {
|
|||||||
'flickr.com': 'fab fa-flickr',
|
'flickr.com': 'fab fa-flickr',
|
||||||
'github.io': 'fab fa-github',
|
'github.io': 'fab fa-github',
|
||||||
'airbnb.com': 'fab fa-airbnb',
|
'airbnb.com': 'fab fa-airbnb',
|
||||||
'bitcoin': 'fab fa-bitcoin',
|
bitcoin: 'fab fa-bitcoin',
|
||||||
'paypal.com': 'fab fa-paypal',
|
'paypal.com': 'fab fa-paypal',
|
||||||
'ethereum': 'fab fa-ethereum',
|
ethereum: 'fab fa-ethereum',
|
||||||
'steam': 'fab fa-steam',
|
steam: 'fab fa-steam',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取最新的书签文件
|
// 获取最新的书签文件
|
||||||
@@ -183,8 +189,9 @@ function getLatestBookmarkFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取目录中的所有HTML文件
|
// 获取目录中的所有HTML文件
|
||||||
const files = fs.readdirSync(BOOKMARKS_DIR)
|
const files = fs
|
||||||
.filter(file => file.toLowerCase().endsWith('.html'));
|
.readdirSync(BOOKMARKS_DIR)
|
||||||
|
.filter((file) => file.toLowerCase().endsWith('.html'));
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
console.log('[WARN] 未找到任何HTML书签文件');
|
console.log('[WARN] 未找到任何HTML书签文件');
|
||||||
@@ -192,9 +199,9 @@ function getLatestBookmarkFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取文件状态,按最后修改时间排序
|
// 获取文件状态,按最后修改时间排序
|
||||||
const fileStats = files.map(file => ({
|
const fileStats = files.map((file) => ({
|
||||||
file,
|
file,
|
||||||
mtime: fs.statSync(path.join(BOOKMARKS_DIR, file)).mtime
|
mtime: fs.statSync(path.join(BOOKMARKS_DIR, file)).mtime,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 找出最新的文件
|
// 找出最新的文件
|
||||||
@@ -213,20 +220,21 @@ function getLatestBookmarkFile() {
|
|||||||
|
|
||||||
// 解析书签HTML内容,支持2-4层级嵌套结构
|
// 解析书签HTML内容,支持2-4层级嵌套结构
|
||||||
function parseBookmarks(htmlContent) {
|
function parseBookmarks(htmlContent) {
|
||||||
|
|
||||||
// 正则表达式匹配文件夹和书签
|
// 正则表达式匹配文件夹和书签
|
||||||
const folderRegex = /<DT><H3([^>]*)>(.*?)<\/H3>/g;
|
const folderRegex = /<DT><H3([^>]*)>(.*?)<\/H3>/g;
|
||||||
const bookmarkRegex = /<DT><A HREF="([^"]+)"[^>]*>(.*?)<\/A>/g;
|
const bookmarkRegex = /<DT><A HREF="([^"]+)"[^>]*>(.*?)<\/A>/g;
|
||||||
|
|
||||||
// 储存解析结果
|
// 储存解析结果
|
||||||
const bookmarks = {
|
const bookmarks = {
|
||||||
categories: []
|
categories: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 提取根路径书签(书签栏容器内但不在任何子文件夹内的书签)
|
// 提取根路径书签(书签栏容器内但不在任何子文件夹内的书签)
|
||||||
function extractRootBookmarks(htmlContent) {
|
function extractRootBookmarks(htmlContent) {
|
||||||
// 找到书签栏文件夹标签
|
// 找到书签栏文件夹标签
|
||||||
const bookmarkBarMatch = htmlContent.match(/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i);
|
const bookmarkBarMatch = htmlContent.match(
|
||||||
|
/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i
|
||||||
|
);
|
||||||
if (!bookmarkBarMatch) {
|
if (!bookmarkBarMatch) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -266,7 +274,10 @@ function parseBookmarks(htmlContent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmarkBarContent = htmlContent.substring(bookmarkBarContentStart, bookmarkBarContentEnd);
|
const bookmarkBarContent = htmlContent.substring(
|
||||||
|
bookmarkBarContentStart,
|
||||||
|
bookmarkBarContentEnd
|
||||||
|
);
|
||||||
|
|
||||||
// 现在提取书签栏内所有子文件夹的范围
|
// 现在提取书签栏内所有子文件夹的范围
|
||||||
const subfolderRanges = [];
|
const subfolderRanges = [];
|
||||||
@@ -312,7 +323,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
subfolderRanges.push({
|
subfolderRanges.push({
|
||||||
name: folderName,
|
name: folderName,
|
||||||
start: folderMatch.index,
|
start: folderMatch.index,
|
||||||
end: folderContentEnd
|
end: folderContentEnd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,7 +348,6 @@ function parseBookmarks(htmlContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!inFolder) {
|
if (!inFolder) {
|
||||||
|
|
||||||
// 基于URL选择适当的图标
|
// 基于URL选择适当的图标
|
||||||
let icon = 'fas fa-link';
|
let icon = 'fas fa-link';
|
||||||
for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) {
|
for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) {
|
||||||
@@ -351,7 +361,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
name: name,
|
name: name,
|
||||||
url: url,
|
url: url,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
description: ''
|
description: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,7 +411,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
name: folderName,
|
name: folderName,
|
||||||
start: folderStart,
|
start: folderStart,
|
||||||
headerEnd: folderHeaderEnd,
|
headerEnd: folderHeaderEnd,
|
||||||
end: folderEnd
|
end: folderEnd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
pos += '</DL><p>'.length;
|
pos += '</DL><p>'.length;
|
||||||
@@ -451,7 +461,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
const folder = {
|
const folder = {
|
||||||
name: folderName,
|
name: folderName,
|
||||||
icon: 'fas fa-folder',
|
icon: 'fas fa-folder',
|
||||||
path: [...parentPath, folderName]
|
path: [...parentPath, folderName],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查是否包含子文件夹 - 创建新的正则实例避免干扰主循环
|
// 检查是否包含子文件夹 - 创建新的正则实例避免干扰主循环
|
||||||
@@ -484,10 +494,11 @@ function parseBookmarks(htmlContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 只添加包含内容的文件夹
|
// 只添加包含内容的文件夹
|
||||||
const hasContent = folder.sites && folder.sites.length > 0 ||
|
const hasContent =
|
||||||
folder.subcategories && folder.subcategories.length > 0 ||
|
(folder.sites && folder.sites.length > 0) ||
|
||||||
folder.groups && folder.groups.length > 0 ||
|
(folder.subcategories && folder.subcategories.length > 0) ||
|
||||||
folder.subgroups && folder.subgroups.length > 0;
|
(folder.groups && folder.groups.length > 0) ||
|
||||||
|
(folder.subgroups && folder.subgroups.length > 0);
|
||||||
|
|
||||||
if (hasContent) {
|
if (hasContent) {
|
||||||
folders.push(folder);
|
folders.push(folder);
|
||||||
@@ -547,7 +558,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
subfolderRanges.push({
|
subfolderRanges.push({
|
||||||
name: folderName,
|
name: folderName,
|
||||||
start: folderStart,
|
start: folderStart,
|
||||||
end: folderContentEnd
|
end: folderContentEnd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -571,7 +582,6 @@ function parseBookmarks(htmlContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!inSubfolder) {
|
if (!inSubfolder) {
|
||||||
|
|
||||||
// 基于URL选择适当的图标
|
// 基于URL选择适当的图标
|
||||||
let icon = 'fas fa-link'; // 默认图标
|
let icon = 'fas fa-link'; // 默认图标
|
||||||
for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) {
|
for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) {
|
||||||
@@ -585,7 +595,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
name: name,
|
name: name,
|
||||||
url: url,
|
url: url,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
description: ''
|
description: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,7 +607,9 @@ function parseBookmarks(htmlContent) {
|
|||||||
const rootSites = extractRootBookmarks(htmlContent);
|
const rootSites = extractRootBookmarks(htmlContent);
|
||||||
|
|
||||||
// 找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER)
|
// 找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER)
|
||||||
const bookmarkBarMatch = htmlContent.match(/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i);
|
const bookmarkBarMatch = htmlContent.match(
|
||||||
|
/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i
|
||||||
|
);
|
||||||
if (!bookmarkBarMatch) {
|
if (!bookmarkBarMatch) {
|
||||||
console.log('[WARN] 未找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER),使用备用方案');
|
console.log('[WARN] 未找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER),使用备用方案');
|
||||||
// 备用方案:使用第一个 <DL><p> 标签
|
// 备用方案:使用第一个 <DL><p> 标签
|
||||||
@@ -668,7 +680,10 @@ function parseBookmarks(htmlContent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmarkBarContent = htmlContent.substring(bookmarkBarContentStart, bookmarkBarContentEnd);
|
const bookmarkBarContent = htmlContent.substring(
|
||||||
|
bookmarkBarContentStart,
|
||||||
|
bookmarkBarContentEnd
|
||||||
|
);
|
||||||
|
|
||||||
// 解析书签栏内的子文件夹作为顶层分类(跳过书签栏本身)
|
// 解析书签栏内的子文件夹作为顶层分类(跳过书签栏本身)
|
||||||
bookmarks.categories = parseNestedFolder(bookmarkBarContent);
|
bookmarks.categories = parseNestedFolder(bookmarkBarContent);
|
||||||
@@ -684,7 +699,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
name: '根目录书签',
|
name: '根目录书签',
|
||||||
icon: 'fas fa-star',
|
icon: 'fas fa-star',
|
||||||
path: ['根目录书签'],
|
path: ['根目录书签'],
|
||||||
sites: rootSites
|
sites: rootSites,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 插入到数组首位
|
// 插入到数组首位
|
||||||
@@ -702,14 +717,14 @@ function generateBookmarksYaml(bookmarks) {
|
|||||||
const bookmarksPage = {
|
const bookmarksPage = {
|
||||||
title: '我的书签',
|
title: '我的书签',
|
||||||
subtitle: '从浏览器导入的书签收藏',
|
subtitle: '从浏览器导入的书签收藏',
|
||||||
categories: bookmarks.categories
|
categories: bookmarks.categories,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 转换为YAML
|
// 转换为YAML
|
||||||
const yamlString = yaml.dump(bookmarksPage, {
|
const yamlString = yaml.dump(bookmarksPage, {
|
||||||
indent: 2,
|
indent: 2,
|
||||||
lineWidth: -1,
|
lineWidth: -1,
|
||||||
quotingType: '"'
|
quotingType: '"',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加注释(可选确定性输出,方便版本管理)
|
// 添加注释(可选确定性输出,方便版本管理)
|
||||||
@@ -718,8 +733,7 @@ function generateBookmarksYaml(bookmarks) {
|
|||||||
? ''
|
? ''
|
||||||
: `# 由bookmark-processor.js生成于 ${new Date().toISOString()}\n`;
|
: `# 由bookmark-processor.js生成于 ${new Date().toISOString()}\n`;
|
||||||
|
|
||||||
const yamlWithComment =
|
const yamlWithComment = `# 自动生成的书签配置文件
|
||||||
`# 自动生成的书签配置文件
|
|
||||||
${timestampLine}# 若要更新,请将新的书签HTML文件放入bookmarks/目录
|
${timestampLine}# 若要更新,请将新的书签HTML文件放入bookmarks/目录
|
||||||
# 此文件使用模块化配置格式,位于config/user/pages/目录下
|
# 此文件使用模块化配置格式,位于config/user/pages/目录下
|
||||||
|
|
||||||
@@ -822,14 +836,15 @@ async function main() {
|
|||||||
} else if (navUpdateResult.reason === 'already_present') {
|
} else if (navUpdateResult.reason === 'already_present') {
|
||||||
console.log('[INFO] 导航配置已包含书签入口,无需更新\n');
|
console.log('[INFO] 导航配置已包含书签入口,无需更新\n');
|
||||||
} else if (navUpdateResult.reason === 'no_navigation_config') {
|
} else if (navUpdateResult.reason === 'no_navigation_config') {
|
||||||
console.log('[WARN] 未找到可更新的导航配置文件;请确认 config/user/site.yml 存在且包含 navigation\n');
|
console.log(
|
||||||
|
'[WARN] 未找到可更新的导航配置文件;请确认 config/user/site.yml 存在且包含 navigation\n'
|
||||||
|
);
|
||||||
} else if (navUpdateResult.reason === 'error') {
|
} else if (navUpdateResult.reason === 'error') {
|
||||||
console.log('[WARN] 导航更新失败,请手动检查配置文件格式(详见错误信息)\n');
|
console.log('[WARN] 导航更新失败,请手动检查配置文件格式(详见错误信息)\n');
|
||||||
console.error(navUpdateResult.error);
|
console.error(navUpdateResult.error);
|
||||||
} else {
|
} else {
|
||||||
console.log('[INFO] 导航配置无需更新\n');
|
console.log('[INFO] 导航配置无需更新\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (writeError) {
|
} catch (writeError) {
|
||||||
console.error(`[ERROR] 写入文件时出错:`, writeError);
|
console.error(`[ERROR] 写入文件时出错:`, writeError);
|
||||||
console.error('[ERROR] 错误堆栈:', writeError.stack);
|
console.error('[ERROR] 错误堆栈:', writeError.stack);
|
||||||
@@ -839,7 +854,6 @@ async function main() {
|
|||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('[SUCCESS] 书签处理完成!');
|
console.log('[SUCCESS] 书签处理完成!');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[FATAL] 处理书签文件时发生错误:', error);
|
console.error('[FATAL] 处理书签文件时发生错误:', error);
|
||||||
console.error('[ERROR] 错误堆栈:', error.stack);
|
console.error('[ERROR] 错误堆栈:', error.stack);
|
||||||
@@ -849,7 +863,7 @@ async function main() {
|
|||||||
|
|
||||||
// 启动处理
|
// 启动处理
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
main().catch(err => {
|
main().catch((err) => {
|
||||||
console.error('Unhandled error in bookmark processing:', err);
|
console.error('Unhandled error in bookmark processing:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
891
src/generator.js
891
src/generator.js
File diff suppressed because it is too large
Load Diff
@@ -39,25 +39,25 @@ function ifNotEquals(v1, v2, options) {
|
|||||||
function ifCond(v1, operator, v2, options) {
|
function ifCond(v1, operator, v2, options) {
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
case '==':
|
case '==':
|
||||||
return (v1 == v2) ? options.fn(this) : options.inverse(this);
|
return v1 == v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '===':
|
case '===':
|
||||||
return (v1 === v2) ? options.fn(this) : options.inverse(this);
|
return v1 === v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '!=':
|
case '!=':
|
||||||
return (v1 != v2) ? options.fn(this) : options.inverse(this);
|
return v1 != v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '!==':
|
case '!==':
|
||||||
return (v1 !== v2) ? options.fn(this) : options.inverse(this);
|
return v1 !== v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '<':
|
case '<':
|
||||||
return (v1 < v2) ? options.fn(this) : options.inverse(this);
|
return v1 < v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '<=':
|
case '<=':
|
||||||
return (v1 <= v2) ? options.fn(this) : options.inverse(this);
|
return v1 <= v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '>':
|
case '>':
|
||||||
return (v1 > v2) ? options.fn(this) : options.inverse(this);
|
return v1 > v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '>=':
|
case '>=':
|
||||||
return (v1 >= v2) ? options.fn(this) : options.inverse(this);
|
return v1 >= v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '&&':
|
case '&&':
|
||||||
return (v1 && v2) ? options.fn(this) : options.inverse(this);
|
return v1 && v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '||':
|
case '||':
|
||||||
return (v1 || v2) ? options.fn(this) : options.inverse(this);
|
return v1 || v2 ? options.fn(this) : options.inverse(this);
|
||||||
default:
|
default:
|
||||||
return options.inverse(this);
|
return options.inverse(this);
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ function isEmpty(value, options) {
|
|||||||
function isNotEmpty(value, options) {
|
function isNotEmpty(value, options) {
|
||||||
return isEmpty(value, {
|
return isEmpty(value, {
|
||||||
fn: options.inverse,
|
fn: options.inverse,
|
||||||
inverse: options.fn
|
inverse: options.fn,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ function isNotEmpty(value, options) {
|
|||||||
* @example {{#and isPremium isActive}}高级活跃用户{{else}}其他用户{{/and}}
|
* @example {{#and isPremium isActive}}高级活跃用户{{else}}其他用户{{/and}}
|
||||||
*/
|
*/
|
||||||
function and(a, b, options) {
|
function and(a, b, options) {
|
||||||
return (a && b) ? options.fn(this) : options.inverse(this);
|
return a && b ? options.fn(this) : options.inverse(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,7 +125,7 @@ function and(a, b, options) {
|
|||||||
* @example {{#or isPremium isAdmin}}有权限{{else}}无权限{{/or}}
|
* @example {{#or isPremium isAdmin}}有权限{{else}}无权限{{/or}}
|
||||||
*/
|
*/
|
||||||
function or(a, b, options) {
|
function or(a, b, options) {
|
||||||
return (a || b) ? options.fn(this) : options.inverse(this);
|
return a || b ? options.fn(this) : options.inverse(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,5 +184,5 @@ module.exports = {
|
|||||||
or,
|
or,
|
||||||
orHelper,
|
orHelper,
|
||||||
not,
|
not,
|
||||||
ifHttpUrl
|
ifHttpUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,5 +123,5 @@ module.exports = {
|
|||||||
toLowerCase,
|
toLowerCase,
|
||||||
toUpperCase,
|
toUpperCase,
|
||||||
json,
|
json,
|
||||||
extractDomain
|
extractDomain,
|
||||||
};
|
};
|
||||||
@@ -29,20 +29,20 @@ function registerAllHelpers(handlebars) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 注册HTML转义函数(作为助手函数,方便在模板中调用)
|
// 注册HTML转义函数(作为助手函数,方便在模板中调用)
|
||||||
handlebars.registerHelper('escapeHtml', function(text) {
|
handlebars.registerHelper('escapeHtml', function (text) {
|
||||||
if (text === undefined || text === null) {
|
if (text === undefined || text === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return String(text)
|
return String(text)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, "<")
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, ''');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 注册非转义助手函数(安全输出HTML)
|
// 注册非转义助手函数(安全输出HTML)
|
||||||
handlebars.registerHelper('safeHtml', function(text) {
|
handlebars.registerHelper('safeHtml', function (text) {
|
||||||
if (text === undefined || text === null) {
|
if (text === undefined || text === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -55,5 +55,5 @@ module.exports = {
|
|||||||
formatters,
|
formatters,
|
||||||
conditions,
|
conditions,
|
||||||
utils,
|
utils,
|
||||||
registerAllHelpers
|
registerAllHelpers,
|
||||||
};
|
};
|
||||||
@@ -36,7 +36,7 @@ function concat() {
|
|||||||
const options = args.pop(); // 最后一个参数是Handlebars的options对象
|
const options = args.pop(); // 最后一个参数是Handlebars的options对象
|
||||||
|
|
||||||
// 过滤掉非数组参数
|
// 过滤掉非数组参数
|
||||||
const validArrays = args.filter(arg => Array.isArray(arg));
|
const validArrays = args.filter((arg) => Array.isArray(arg));
|
||||||
|
|
||||||
if (validArrays.length === 0) {
|
if (validArrays.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -143,7 +143,7 @@ function pick() {
|
|||||||
|
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
||||||
keys.forEach(key => {
|
keys.forEach((key) => {
|
||||||
if (obj.hasOwnProperty(key)) {
|
if (obj.hasOwnProperty(key)) {
|
||||||
result[key] = obj[key];
|
result[key] = obj[key];
|
||||||
}
|
}
|
||||||
@@ -274,15 +274,23 @@ function safeUrl(url, options) {
|
|||||||
options.data.root.site.security &&
|
options.data.root.site.security &&
|
||||||
options.data.root.site.security.allowedSchemes;
|
options.data.root.site.security.allowedSchemes;
|
||||||
|
|
||||||
const allowedSchemes = Array.isArray(allowedFromConfig) && allowedFromConfig.length > 0
|
const allowedSchemes =
|
||||||
? allowedFromConfig
|
Array.isArray(allowedFromConfig) && allowedFromConfig.length > 0
|
||||||
.map(s => String(s || '').trim().toLowerCase().replace(/:$/, ''))
|
? allowedFromConfig
|
||||||
.filter(Boolean)
|
.map((s) =>
|
||||||
: ['http', 'https', 'mailto', 'tel'];
|
String(s || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/:$/, '')
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
: ['http', 'https', 'mailto', 'tel'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(raw);
|
const parsed = new URL(raw);
|
||||||
const scheme = String(parsed.protocol || '').toLowerCase().replace(/:$/, '');
|
const scheme = String(parsed.protocol || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/:$/, '');
|
||||||
if (allowedSchemes.includes(scheme)) return raw;
|
if (allowedSchemes.includes(scheme)) return raw;
|
||||||
console.warn(`[WARN] 已拦截不安全 URL scheme:${raw}`);
|
console.warn(`[WARN] 已拦截不安全 URL scheme:${raw}`);
|
||||||
return '#';
|
return '#';
|
||||||
@@ -306,5 +314,5 @@ module.exports = {
|
|||||||
add,
|
add,
|
||||||
faviconV2Url,
|
faviconV2Url,
|
||||||
faviconFallbackUrl,
|
faviconFallbackUrl,
|
||||||
safeUrl
|
safeUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
3961
src/script.js
3961
src/script.js
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ const {
|
|||||||
function stripYamlComments(yamlText) {
|
function stripYamlComments(yamlText) {
|
||||||
return yamlText
|
return yamlText
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter(line => !/^\s*#/.test(line))
|
.filter((line) => !/^\s*#/.test(line))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ test('parseBookmarks:解析书签栏、根目录书签与图标映射', () =>
|
|||||||
assert.equal(bookmarks.categories[0].sites[0].name, 'GitHub');
|
assert.equal(bookmarks.categories[0].sites[0].name, 'GitHub');
|
||||||
assert.equal(bookmarks.categories[0].sites[0].icon, 'fab fa-github');
|
assert.equal(bookmarks.categories[0].sites[0].icon, 'fab fa-github');
|
||||||
|
|
||||||
const tools = bookmarks.categories.find(c => c.name === '工具');
|
const tools = bookmarks.categories.find((c) => c.name === '工具');
|
||||||
assert.ok(tools, '应解析出“工具”分类');
|
assert.ok(tools, '应解析出“工具”分类');
|
||||||
assert.ok(Array.isArray(tools.sites));
|
assert.ok(Array.isArray(tools.sites));
|
||||||
assert.equal(tools.sites[0].name, 'Google');
|
assert.equal(tools.sites[0].name, 'Google');
|
||||||
@@ -62,11 +62,26 @@ test('templates:subgroups(第4层)应可渲染到页面', () => {
|
|||||||
const hbs = Handlebars.create();
|
const hbs = Handlebars.create();
|
||||||
registerAllHelpers(hbs);
|
registerAllHelpers(hbs);
|
||||||
|
|
||||||
const category = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'category.hbs'), 'utf8');
|
const category = fs.readFileSync(
|
||||||
const group = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'group.hbs'), 'utf8');
|
path.join(__dirname, '..', 'templates', 'components', 'category.hbs'),
|
||||||
const pageHeader = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'page-header.hbs'), 'utf8');
|
'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 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 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);
|
||||||
@@ -96,7 +111,12 @@ test('templates:subgroups(第4层)应可渲染到页面', () => {
|
|||||||
name: 'React生态',
|
name: 'React生态',
|
||||||
icon: 'fab fa-react',
|
icon: 'fab fa-react',
|
||||||
sites: [
|
sites: [
|
||||||
{ name: 'React', url: 'https://reactjs.org/', icon: 'fab fa-react', description: 'React官方' },
|
{
|
||||||
|
name: 'React',
|
||||||
|
url: 'https://reactjs.org/',
|
||||||
|
icon: 'fab fa-react',
|
||||||
|
description: 'React官方',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -119,7 +139,9 @@ test('generateBookmarksYaml:生成 YAML 且可被解析', () => {
|
|||||||
{
|
{
|
||||||
name: '示例分类',
|
name: '示例分类',
|
||||||
icon: 'fas fa-folder',
|
icon: 'fas fa-folder',
|
||||||
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: '' }],
|
sites: [
|
||||||
|
{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: '' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -140,11 +162,7 @@ test('upsertBookmarksNavInSiteYml:无 navigation 时追加并幂等', () => {
|
|||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-test-'));
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-test-'));
|
||||||
const filePath = path.join(tmp, 'site.yml');
|
const filePath = path.join(tmp, 'site.yml');
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(filePath, `title: Test Site\n`, 'utf8');
|
||||||
filePath,
|
|
||||||
`title: Test Site\n`,
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const r1 = upsertBookmarksNavInSiteYml(filePath);
|
const r1 = upsertBookmarksNavInSiteYml(filePath);
|
||||||
assert.equal(r1.updated, true);
|
assert.equal(r1.updated, true);
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ function renderBookmarksWithSite(site) {
|
|||||||
{
|
{
|
||||||
name: '分类',
|
name: '分类',
|
||||||
icon: 'fas fa-folder',
|
icon: 'fas fa-folder',
|
||||||
sites: [site]
|
sites: [site],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -45,7 +45,7 @@ test('站点配置包含 faviconUrl(本地 assets 路径)时,渲染 bookma
|
|||||||
url: 'https://intranet.example/',
|
url: 'https://intranet.example/',
|
||||||
faviconUrl: 'assets/menav.svg',
|
faviconUrl: 'assets/menav.svg',
|
||||||
icon: 'fas fa-link',
|
icon: 'fas fa-link',
|
||||||
external: true
|
external: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.match(html, /data-favicon-url="assets\/menav\.svg"/);
|
assert.match(html, /data-favicon-url="assets\/menav\.svg"/);
|
||||||
@@ -60,7 +60,7 @@ test('站点配置包含 faviconUrl(在线 ico)时,渲染 bookmarks 不应
|
|||||||
url: 'https://example.com/',
|
url: 'https://example.com/',
|
||||||
faviconUrl: 'https://content.webcull.com/images/websites/icons/470/695/b788b0.ico',
|
faviconUrl: 'https://content.webcull.com/images/websites/icons/470/695/b788b0.ico',
|
||||||
icon: 'fas fa-link',
|
icon: 'fas fa-link',
|
||||||
external: true
|
external: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -73,4 +73,3 @@ test('站点配置包含 faviconUrl(在线 ico)时,渲染 bookmarks 不应
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,24 @@ test('首页(navigation 第一项)应使用 profile 覆盖 title/subtitle
|
|||||||
{ id: 'home', name: '首页', icon: 'fas fa-home' },
|
{ id: 'home', name: '首页', icon: 'fas fa-home' },
|
||||||
{ id: 'projects', name: '项目', icon: 'fas fa-project-diagram' },
|
{ id: 'projects', name: '项目', icon: 'fas fa-project-diagram' },
|
||||||
],
|
],
|
||||||
bookmarks: { title: '书签页标题', subtitle: '书签页副标题', template: 'bookmarks', categories: [] },
|
bookmarks: {
|
||||||
home: { title: 'HOME_PAGE_TITLE', subtitle: 'HOME_PAGE_SUBTITLE', template: 'page', categories: [] },
|
title: '书签页标题',
|
||||||
projects: { title: '项目页标题', subtitle: '项目页副标题', template: 'projects', categories: [] },
|
subtitle: '书签页副标题',
|
||||||
|
template: 'bookmarks',
|
||||||
|
categories: [],
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
title: 'HOME_PAGE_TITLE',
|
||||||
|
subtitle: 'HOME_PAGE_SUBTITLE',
|
||||||
|
template: 'page',
|
||||||
|
categories: [],
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
title: '项目页标题',
|
||||||
|
subtitle: '项目页副标题',
|
||||||
|
template: 'projects',
|
||||||
|
categories: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const pages = generateAllPagesHTML(config);
|
const pages = generateAllPagesHTML(config);
|
||||||
|
|||||||
@@ -46,4 +46,3 @@ test('未配置 icons.mode 时应回退为默认 favicon', () => {
|
|||||||
assert.equal(config.icons.mode, 'favicon');
|
assert.equal(config.icons.mode, 'favicon');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ test('多级结构下 sites.external 未配置时应默认 true,且 external:f
|
|||||||
' - name: DeepExternalFalse',
|
' - name: DeepExternalFalse',
|
||||||
' url: https://example.com/deep-false',
|
' url: https://example.com/deep-false',
|
||||||
' external: false',
|
' external: false',
|
||||||
''
|
'',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
@@ -67,4 +67,3 @@ test('多级结构下 sites.external 未配置时应默认 true,且 external:f
|
|||||||
assert.equal(deepSites[1].external, false);
|
assert.equal(deepSites[1].external, false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,14 @@ test('friends/articles:应恢复分类展示(扩展仍以 data-* 结构为
|
|||||||
{
|
{
|
||||||
name: '技术博主',
|
name: '技术博主',
|
||||||
icon: 'fas fa-user-friends',
|
icon: 'fas fa-user-friends',
|
||||||
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
sites: [
|
||||||
|
{
|
||||||
|
name: 'Example',
|
||||||
|
url: 'https://example.com',
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
description: 'desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -50,7 +57,14 @@ test('friends/articles:应恢复分类展示(扩展仍以 data-* 结构为
|
|||||||
{
|
{
|
||||||
name: '最新文章',
|
name: '最新文章',
|
||||||
icon: 'fas fa-pen',
|
icon: 'fas fa-pen',
|
||||||
sites: [{ name: 'Article A', url: 'https://example.com/a', icon: 'fas fa-link', description: 'summary' }],
|
sites: [
|
||||||
|
{
|
||||||
|
name: 'Article A',
|
||||||
|
url: 'https://example.com/a',
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
description: 'summary',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -61,15 +75,27 @@ test('friends/articles:应恢复分类展示(扩展仍以 data-* 结构为
|
|||||||
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
|
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
|
||||||
assert.ok(pages.friends.includes('page-template-friends'));
|
assert.ok(pages.friends.includes('page-template-friends'));
|
||||||
assert.ok(pages.friends.includes('sites-grid'));
|
assert.ok(pages.friends.includes('sites-grid'));
|
||||||
assert.ok(pages.friends.includes('class="site-card'), 'friends 应使用普通 site-card 样式(图标在左,标题在右)');
|
assert.ok(
|
||||||
assert.ok(!pages.friends.includes('site-card-friend'), 'friends 不应使用 site-card-friend 变体样式');
|
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(pages.friends.includes('category-header'), 'friends 应输出分类标题结构');
|
||||||
|
|
||||||
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
|
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
|
||||||
assert.ok(pages.articles.includes('page-template-articles'));
|
assert.ok(pages.articles.includes('page-template-articles'));
|
||||||
assert.ok(pages.articles.includes('sites-grid'));
|
assert.ok(pages.articles.includes('sites-grid'));
|
||||||
assert.ok(pages.articles.includes('class="site-card'), 'articles 应使用普通 site-card 样式(图标在左,标题在右)');
|
assert.ok(
|
||||||
assert.ok(!pages.articles.includes('site-card-article'), 'articles 不应使用 site-card-article 变体样式');
|
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 应输出分类标题结构');
|
assert.ok(pages.articles.includes('category-header'), 'articles 应输出分类标题结构');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -92,13 +118,22 @@ test('friends/articles:页面配置使用顶层 sites 时应自动映射为分
|
|||||||
title: '友情链接',
|
title: '友情链接',
|
||||||
subtitle: '朋友们',
|
subtitle: '朋友们',
|
||||||
template: 'page',
|
template: 'page',
|
||||||
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
sites: [
|
||||||
|
{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
articles: {
|
articles: {
|
||||||
title: '文章',
|
title: '文章',
|
||||||
subtitle: '文章入口',
|
subtitle: '文章入口',
|
||||||
template: 'articles',
|
template: 'articles',
|
||||||
sites: [{ name: 'Article A', url: 'https://example.com/a', icon: 'fas fa-link', description: 'summary' }],
|
sites: [
|
||||||
|
{
|
||||||
|
name: 'Article A',
|
||||||
|
url: 'https://example.com/a',
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
description: 'summary',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,15 +142,27 @@ test('friends/articles:页面配置使用顶层 sites 时应自动映射为分
|
|||||||
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
|
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
|
||||||
assert.ok(pages.friends.includes('page-template-friends'));
|
assert.ok(pages.friends.includes('page-template-friends'));
|
||||||
assert.ok(pages.friends.includes('sites-grid'));
|
assert.ok(pages.friends.includes('sites-grid'));
|
||||||
assert.ok(pages.friends.includes('class="site-card'), 'friends 应使用普通 site-card 样式(图标在左,标题在右)');
|
assert.ok(
|
||||||
assert.ok(!pages.friends.includes('site-card-friend'), 'friends 不应使用 site-card-friend 变体样式');
|
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(pages.friends.includes('category-header'), 'friends 应输出分类标题结构');
|
||||||
|
|
||||||
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
|
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
|
||||||
assert.ok(pages.articles.includes('page-template-articles'));
|
assert.ok(pages.articles.includes('page-template-articles'));
|
||||||
assert.ok(pages.articles.includes('sites-grid'));
|
assert.ok(pages.articles.includes('sites-grid'));
|
||||||
assert.ok(pages.articles.includes('class="site-card'), 'articles 应使用普通 site-card 样式(图标在左,标题在右)');
|
assert.ok(
|
||||||
assert.ok(!pages.articles.includes('site-card-article'), 'articles 不应使用 site-card-article 变体样式');
|
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 应输出分类标题结构');
|
assert.ok(pages.articles.includes('category-header'), 'articles 应输出分类标题结构');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -191,7 +238,14 @@ test('projects:应输出代码仓库风格卡片(site-card-repo)', () => {
|
|||||||
{
|
{
|
||||||
name: '项目',
|
name: '项目',
|
||||||
icon: 'fas fa-code',
|
icon: 'fas fa-code',
|
||||||
sites: [{ name: 'Proj', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
sites: [
|
||||||
|
{
|
||||||
|
name: 'Proj',
|
||||||
|
url: 'https://example.com',
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
description: 'desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -231,10 +285,10 @@ test('articles Phase 2:存在 RSS 缓存时渲染文章条目,并隐藏扩
|
|||||||
publishedAt: '2025-12-25T12:00:00.000Z',
|
publishedAt: '2025-12-25T12:00:00.000Z',
|
||||||
source: 'Example Blog',
|
source: 'Example Blog',
|
||||||
sourceUrl: 'https://example.com',
|
sourceUrl: 'https://example.com',
|
||||||
icon: 'fas fa-pen'
|
icon: 'fas fa-pen',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
stats: { totalArticles: 1 }
|
stats: { totalArticles: 1 },
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
@@ -259,7 +313,14 @@ test('articles Phase 2:存在 RSS 缓存时渲染文章条目,并隐藏扩
|
|||||||
{
|
{
|
||||||
name: '来源',
|
name: '来源',
|
||||||
icon: 'fas fa-pen',
|
icon: 'fas fa-pen',
|
||||||
sites: [{ name: 'Source A', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
sites: [
|
||||||
|
{
|
||||||
|
name: 'Source A',
|
||||||
|
url: 'https://example.com',
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
description: 'desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -269,7 +330,10 @@ test('articles Phase 2:存在 RSS 缓存时渲染文章条目,并隐藏扩
|
|||||||
const html = pages.articles;
|
const html = pages.articles;
|
||||||
|
|
||||||
assert.ok(typeof html === 'string' && html.length > 0);
|
assert.ok(typeof html === 'string' && html.length > 0);
|
||||||
assert.ok(html.includes('data-type="article"'), '文章条目卡片应为 data-type="article"(只读)');
|
assert.ok(
|
||||||
|
html.includes('data-type="article"'),
|
||||||
|
'文章条目卡片应为 data-type="article"(只读)'
|
||||||
|
);
|
||||||
assert.ok(html.includes('site-card-meta'), '文章条目应展示日期/来源元信息');
|
assert.ok(html.includes('site-card-meta'), '文章条目应展示日期/来源元信息');
|
||||||
assert.ok(html.includes('Example Blog'));
|
assert.ok(html.includes('Example Blog'));
|
||||||
assert.ok(html.includes('2025-12-25'));
|
assert.ok(html.includes('2025-12-25'));
|
||||||
|
|||||||
Reference in New Issue
Block a user