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