chore: 使用 Prettier 统一代码风格

This commit is contained in:
rbetree
2026-01-04 21:07:07 +08:00
parent 5ae8e99795
commit 82d6341c00
23 changed files with 3129 additions and 2805 deletions

View File

@@ -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-effortprojects 仓库信息、articles RSS 聚合 # 同步时效性数据best-effortprojects 仓库信息、articles RSS 聚合

View File

@@ -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'
``` ```
## 最佳实践 ## 最佳实践

View File

@@ -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

View File

@@ -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 URLfavicon 模式将尝试加载站点图标) url: https://linux.do/ # http/https URLfavicon 模式将尝试加载站点图标)
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

View File

@@ -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'

View File

@@ -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

View File

@@ -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` 数组顺序来设置首页(把希望作为首页的页面放到第一项)。
--- ---

View File

@@ -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 });

View File

@@ -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,
}; };

View File

@@ -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
}); });

View File

@@ -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);
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -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,
}; };

View File

@@ -123,5 +123,5 @@ module.exports = {
toLowerCase, toLowerCase,
toUpperCase, toUpperCase,
json, json,
extractDomain extractDomain,
}; };

View File

@@ -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, "&amp;") .replace(/&/g, '&amp;')
.replace(/</g, "&lt;") .replace(/</g, '&lt;')
.replace(/>/g, "&gt;") .replace(/>/g, '&gt;')
.replace(/"/g, "&quot;") .replace(/"/g, '&quot;')
.replace(/'/g, "&#039;"); .replace(/'/g, '&#039;');
}); });
// 注册非转义助手函数安全输出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,
}; };

View File

@@ -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,
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -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('templatessubgroups第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('templatessubgroups第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);

View File

@@ -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 不应
); );
}); });
}); });

View File

@@ -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);

View File

@@ -46,4 +46,3 @@ test('未配置 icons.mode 时应回退为默认 favicon', () => {
assert.equal(config.icons.mode, 'favicon'); assert.equal(config.icons.mode, 'favicon');
}); });
}); });

View File

@@ -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);
}); });
}); });

View File

@@ -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'));