chore: 使用 Prettier 统一代码风格
This commit is contained in:
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -2,7 +2,7 @@ name: Build and Deploy Site
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
# 定时刷新 RSS / projects 仓库元信息(GitHub Actions 的 cron 使用 UTC 时区)
|
# 定时刷新 RSS / projects 仓库元信息(GitHub Actions 的 cron 使用 UTC 时区)
|
||||||
@@ -16,7 +16,7 @@ permissions:
|
|||||||
|
|
||||||
# 允许一个并发部署
|
# 允许一个并发部署
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "pages"
|
group: 'pages'
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -153,7 +153,6 @@ jobs:
|
|||||||
echo "No HTML files found to clean up."
|
echo "No HTML files found to clean up."
|
||||||
fi
|
fi
|
||||||
# --- 书签处理步骤结束 ---
|
# --- 书签处理步骤结束 ---
|
||||||
|
|
||||||
- name: Push configuration changes (if any)
|
- name: Push configuration changes (if any)
|
||||||
# 只有在书签处理步骤修改了文件时才推送
|
# 只有在书签处理步骤修改了文件时才推送
|
||||||
# 使用 GITHUB_TOKEN 推送
|
# 使用 GITHUB_TOKEN 推送
|
||||||
|
|||||||
@@ -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'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 最佳实践
|
## 最佳实践
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ categories:
|
|||||||
- name: MarksVault
|
- name: MarksVault
|
||||||
icon: fab fa-github
|
icon: fab fa-github
|
||||||
description: 一个强大的浏览器扩展,用于智能管理、整理和安全备份您的书签数据
|
description: 一个强大的浏览器扩展,用于智能管理、整理和安全备份您的书签数据
|
||||||
url: "https://github.com/rbetree/MarksVault"
|
url: 'https://github.com/rbetree/MarksVault'
|
||||||
- name: star
|
- name: star
|
||||||
icon: fas fa-star
|
icon: fas fa-star
|
||||||
sites:
|
sites:
|
||||||
- name: CLIProxyAPI
|
- name: CLIProxyAPI
|
||||||
icon: fab fa-github
|
icon: fab fa-github
|
||||||
description: Wrap Gemini CLI, Antigravity, ChatGPT Codex, Claude Code, Qwen Code, iFlow as an OpenAI/Gemini/Claude/Codex compatible API service, allowing you to enjoy the free Gemini 2.5 Pro, GPT 5, Claude, Qwen model through API
|
description: Wrap Gemini CLI, Antigravity, ChatGPT Codex, Claude Code, Qwen Code, iFlow as an OpenAI/Gemini/Claude/Codex compatible API service, allowing you to enjoy the free Gemini 2.5 Pro, GPT 5, Claude, Qwen model through API
|
||||||
url: "https://github.com/router-for-me/CLIProxyAPI"
|
url: 'https://github.com/router-for-me/CLIProxyAPI'
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ security:
|
|||||||
# - system: 只使用本地/系统字体,不额外发起请求
|
# - system: 只使用本地/系统字体,不额外发起请求
|
||||||
fonts:
|
fonts:
|
||||||
source: css
|
source: css
|
||||||
cssUrl: "https://fontsapi.zeoseven.com/292/main/result.css"
|
cssUrl: 'https://fontsapi.zeoseven.com/292/main/result.css'
|
||||||
preload: true # 可选:使用 preload+onload 的方式非阻塞加载字体 CSS(更利于首屏性能)
|
preload: true # 可选:使用 preload+onload 的方式非阻塞加载字体 CSS(更利于首屏性能)
|
||||||
family: LXGW WenKai
|
family: LXGW WenKai
|
||||||
weight: normal
|
weight: normal
|
||||||
@@ -81,7 +81,7 @@ rss:
|
|||||||
# - 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)
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
用途:为 `articles` 页面提供 RSS/Atom 文章聚合数据,供 `npm run sync-articles` 联网抓取并写入缓存;`npm run build` 默认不联网,只读取缓存渲染。
|
用途:为 `articles` 页面提供 RSS/Atom 文章聚合数据,供 `npm run sync-articles` 联网抓取并写入缓存;`npm run build` 默认不联网,只读取缓存渲染。
|
||||||
|
|
||||||
关键字段(默认示例见 `config/_default/site.yml`):
|
关键字段(默认示例见 `config/_default/site.yml`):
|
||||||
|
|
||||||
- `site.rss.enabled`:是否启用 RSS 抓取能力
|
- `site.rss.enabled`:是否启用 RSS 抓取能力
|
||||||
- `site.rss.cacheDir`:缓存目录(建议 `dev/`,仓库默认 gitignore)
|
- `site.rss.cacheDir`:缓存目录(建议 `dev/`,仓库默认 gitignore)
|
||||||
- `site.rss.fetch.*`:抓取参数(超时、并发、重试、重定向等)
|
- `site.rss.fetch.*`:抓取参数(超时、并发、重试、重定向等)
|
||||||
- `site.rss.articles.*`:抓取条数与摘要长度(例如每站点最多 8 篇)
|
- `site.rss.articles.*`:抓取条数与摘要长度(例如每站点最多 8 篇)
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- RSS 抓取只影响 `articles` Phase 2(文章条目只读展示),不会影响扩展对“来源站点(sites)”的写回能力(构建会保留影子写回结构)。
|
- RSS 抓取只影响 `articles` Phase 2(文章条目只读展示),不会影响扩展对“来源站点(sites)”的写回能力(构建会保留影子写回结构)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -26,15 +28,18 @@
|
|||||||
### 1.2 `site.github.*`(projects 仓库元信息 + 热力图)
|
### 1.2 `site.github.*`(projects 仓库元信息 + 热力图)
|
||||||
|
|
||||||
用途:
|
用途:
|
||||||
|
|
||||||
- projects 卡片可展示仓库元信息(language/stars/forks 等,只读),由 `npm run sync-projects` 联网抓取并写入缓存。
|
- projects 卡片可展示仓库元信息(language/stars/forks 等,只读),由 `npm run sync-projects` 联网抓取并写入缓存。
|
||||||
- projects 标题区右侧可选展示 GitHub 贡献热力图。
|
- projects 标题区右侧可选展示 GitHub 贡献热力图。
|
||||||
|
|
||||||
关键字段(默认示例见 `config/_default/site.yml`):
|
关键字段(默认示例见 `config/_default/site.yml`):
|
||||||
|
|
||||||
- `site.github.username`:GitHub 用户名;为空则不展示热力图
|
- `site.github.username`:GitHub 用户名;为空则不展示热力图
|
||||||
- `site.github.heatmapColor`:热力图主题色(不带 `#`,如 `339af0`)
|
- `site.github.heatmapColor`:热力图主题色(不带 `#`,如 `339af0`)
|
||||||
- `site.github.cacheDir`:仓库元信息缓存目录(建议 `dev/`)
|
- `site.github.cacheDir`:仓库元信息缓存目录(建议 `dev/`)
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 仓库元信息来自 GitHub API,属于“时效性数据”,不会写回到 `pages/projects.yml`。
|
- 仓库元信息来自 GitHub API,属于“时效性数据”,不会写回到 `pages/projects.yml`。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -44,6 +49,7 @@
|
|||||||
用途:指定页面使用的模板(对应 `templates/pages/<template>.hbs`,不含扩展名)。
|
用途:指定页面使用的模板(对应 `templates/pages/<template>.hbs`,不含扩展名)。
|
||||||
|
|
||||||
行为规则:
|
行为规则:
|
||||||
|
|
||||||
- 若 `template` 缺省:优先尝试同名模板(`templates/pages/<pageId>.hbs`),不存在则回退到通用 `page` 模板。
|
- 若 `template` 缺省:优先尝试同名模板(`templates/pages/<pageId>.hbs`),不存在则回退到通用 `page` 模板。
|
||||||
- `bookmarks/projects/articles` 等特殊页建议显式配置 `template`,以减少误解。
|
- `bookmarks/projects/articles` 等特殊页建议显式配置 `template`,以减少误解。
|
||||||
|
|
||||||
@@ -56,6 +62,7 @@
|
|||||||
当前版本不再回退读取根目录 `config.yml`/`config.yaml`。
|
当前版本不再回退读取根目录 `config.yml`/`config.yaml`。
|
||||||
|
|
||||||
迁移要点:
|
迁移要点:
|
||||||
|
|
||||||
- 使用模块化配置目录:`config/user/`(优先级最高,完全替换)或 `config/_default/`(默认示例)。
|
- 使用模块化配置目录:`config/user/`(优先级最高,完全替换)或 `config/_default/`(默认示例)。
|
||||||
- 推荐迁移方式:复制 `config/_default/` → `config/user/`,再按需修改 `site.yml` 与 `pages/*.yml`。
|
- 推荐迁移方式:复制 `config/_default/` → `config/user/`,再按需修改 `site.yml` 与 `pages/*.yml`。
|
||||||
|
|
||||||
@@ -66,6 +73,7 @@
|
|||||||
当前版本仅从 `site.yml -> navigation` 读取导航配置,不再读取 `navigation.yml`。
|
当前版本仅从 `site.yml -> navigation` 读取导航配置,不再读取 `navigation.yml`。
|
||||||
|
|
||||||
迁移要点:
|
迁移要点:
|
||||||
|
|
||||||
- 将原 `navigation.yml` 的数组内容移动到 `config/user/site.yml` 的 `navigation:` 字段下。
|
- 将原 `navigation.yml` 的数组内容移动到 `config/user/site.yml` 的 `navigation:` 字段下。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -75,6 +83,7 @@
|
|||||||
当前版本不再维护“首页固定叫 `home`”的遗留逻辑(例如把 `pages/home.yml` 的分类提升到顶层 `config.categories`)。
|
当前版本不再维护“首页固定叫 `home`”的遗留逻辑(例如把 `pages/home.yml` 的分类提升到顶层 `config.categories`)。
|
||||||
|
|
||||||
迁移要点:
|
迁移要点:
|
||||||
|
|
||||||
- 不要依赖固定页面 id `home`。
|
- 不要依赖固定页面 id `home`。
|
||||||
- 首页始终由 `site.yml -> navigation` 的**第一项**决定;其分类内容应写在对应的 `pages/<homePageId>.yml` 中。
|
- 首页始终由 `site.yml -> navigation` 的**第一项**决定;其分类内容应写在对应的 `pages/<homePageId>.yml` 中。
|
||||||
|
|
||||||
@@ -85,10 +94,12 @@
|
|||||||
历史版本可能通过 `navigation[].active` 指定“默认打开页/首页”。
|
历史版本可能通过 `navigation[].active` 指定“默认打开页/首页”。
|
||||||
|
|
||||||
当前版本:
|
当前版本:
|
||||||
|
|
||||||
- 首页/默认打开页始终由 `site.yml -> navigation` 的**第一项**决定
|
- 首页/默认打开页始终由 `site.yml -> navigation` 的**第一项**决定
|
||||||
- `active` 字段将被忽略(即使写了也不会生效)
|
- `active` 字段将被忽略(即使写了也不会生效)
|
||||||
|
|
||||||
迁移要点:
|
迁移要点:
|
||||||
|
|
||||||
- 通过调整 `navigation` 数组顺序来设置首页(把希望作为首页的页面放到第一项)。
|
- 通过调整 `navigation` 数组顺序来设置首页(把希望作为首页的页面放到第一项)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const fs = require("fs");
|
const fs = require('fs');
|
||||||
const path = require("path");
|
const path = require('path');
|
||||||
|
|
||||||
const distPath = path.resolve(__dirname, "..", "dist");
|
const distPath = path.resolve(__dirname, '..', 'dist');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.rmSync(distPath, { recursive: true, force: true });
|
fs.rmSync(distPath, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ const DEFAULT_RSS_SETTINGS = {
|
|||||||
maxRedirects: 3,
|
maxRedirects: 3,
|
||||||
userAgent: 'MeNavRSSSync/1.0',
|
userAgent: 'MeNavRSSSync/1.0',
|
||||||
htmlMaxBytes: 512 * 1024,
|
htmlMaxBytes: 512 * 1024,
|
||||||
feedMaxBytes: 1024 * 1024
|
feedMaxBytes: 1024 * 1024,
|
||||||
},
|
},
|
||||||
articles: {
|
articles: {
|
||||||
perSite: 8,
|
perSite: 8,
|
||||||
total: 50,
|
total: 50,
|
||||||
summaryMaxLength: 200
|
summaryMaxLength: 200,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseBooleanEnv(value, fallback) {
|
function parseBooleanEnv(value, fallback) {
|
||||||
@@ -42,7 +42,8 @@ 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 && config.site && config.site.rss && typeof config.site.rss === 'object'
|
||||||
? config.site.rss
|
? config.site.rss
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
@@ -51,12 +52,12 @@ function getRssSettings(config) {
|
|||||||
...fromConfig,
|
...fromConfig,
|
||||||
fetch: {
|
fetch: {
|
||||||
...DEFAULT_RSS_SETTINGS.fetch,
|
...DEFAULT_RSS_SETTINGS.fetch,
|
||||||
...(fromConfig.fetch || {})
|
...(fromConfig.fetch || {}),
|
||||||
},
|
},
|
||||||
articles: {
|
articles: {
|
||||||
...DEFAULT_RSS_SETTINGS.articles,
|
...DEFAULT_RSS_SETTINGS.articles,
|
||||||
...(fromConfig.articles || {})
|
...(fromConfig.articles || {}),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 环境变量覆盖(主要给 CI 调试/降级用)
|
// 环境变量覆盖(主要给 CI 调试/降级用)
|
||||||
@@ -64,12 +65,27 @@ function getRssSettings(config) {
|
|||||||
merged.cacheDir = process.env.RSS_CACHE_DIR ? String(process.env.RSS_CACHE_DIR) : merged.cacheDir;
|
merged.cacheDir = process.env.RSS_CACHE_DIR ? String(process.env.RSS_CACHE_DIR) : merged.cacheDir;
|
||||||
|
|
||||||
merged.fetch.timeoutMs = parseIntegerEnv(process.env.RSS_FETCH_TIMEOUT, merged.fetch.timeoutMs);
|
merged.fetch.timeoutMs = parseIntegerEnv(process.env.RSS_FETCH_TIMEOUT, merged.fetch.timeoutMs);
|
||||||
merged.fetch.maxRetries = parseIntegerEnv(process.env.RSS_FETCH_MAX_RETRIES, merged.fetch.maxRetries);
|
merged.fetch.maxRetries = parseIntegerEnv(
|
||||||
merged.fetch.concurrency = parseIntegerEnv(process.env.RSS_FETCH_CONCURRENCY, merged.fetch.concurrency);
|
process.env.RSS_FETCH_MAX_RETRIES,
|
||||||
merged.fetch.totalTimeoutMs = parseIntegerEnv(process.env.RSS_TOTAL_TIMEOUT, merged.fetch.totalTimeoutMs);
|
merged.fetch.maxRetries
|
||||||
merged.fetch.maxRedirects = parseIntegerEnv(process.env.RSS_FETCH_MAX_REDIRECTS, merged.fetch.maxRedirects);
|
);
|
||||||
|
merged.fetch.concurrency = parseIntegerEnv(
|
||||||
|
process.env.RSS_FETCH_CONCURRENCY,
|
||||||
|
merged.fetch.concurrency
|
||||||
|
);
|
||||||
|
merged.fetch.totalTimeoutMs = parseIntegerEnv(
|
||||||
|
process.env.RSS_TOTAL_TIMEOUT,
|
||||||
|
merged.fetch.totalTimeoutMs
|
||||||
|
);
|
||||||
|
merged.fetch.maxRedirects = parseIntegerEnv(
|
||||||
|
process.env.RSS_FETCH_MAX_REDIRECTS,
|
||||||
|
merged.fetch.maxRedirects
|
||||||
|
);
|
||||||
|
|
||||||
merged.articles.perSite = parseIntegerEnv(process.env.RSS_ARTICLES_PER_SITE, merged.articles.perSite);
|
merged.articles.perSite = parseIntegerEnv(
|
||||||
|
process.env.RSS_ARTICLES_PER_SITE,
|
||||||
|
merged.articles.perSite
|
||||||
|
);
|
||||||
merged.articles.total = parseIntegerEnv(process.env.RSS_ARTICLES_TOTAL, merged.articles.total);
|
merged.articles.total = parseIntegerEnv(process.env.RSS_ARTICLES_TOTAL, merged.articles.total);
|
||||||
merged.articles.summaryMaxLength = parseIntegerEnv(
|
merged.articles.summaryMaxLength = parseIntegerEnv(
|
||||||
process.env.RSS_SUMMARY_MAX_LENGTH,
|
process.env.RSS_SUMMARY_MAX_LENGTH,
|
||||||
@@ -104,8 +120,9 @@ function isPrivateIp(ip) {
|
|||||||
if (!ip) return true;
|
if (!ip) return true;
|
||||||
|
|
||||||
if (net.isIP(ip) === 4) {
|
if (net.isIP(ip) === 4) {
|
||||||
const parts = ip.split('.').map(n => Number.parseInt(n, 10));
|
const parts = ip.split('.').map((n) => Number.parseInt(n, 10));
|
||||||
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n) || n < 0 || n > 255)) return true;
|
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255))
|
||||||
|
return true;
|
||||||
|
|
||||||
const [a, b] = parts;
|
const [a, b] = parts;
|
||||||
if (a === 10) return true;
|
if (a === 10) return true;
|
||||||
@@ -152,7 +169,12 @@ async function assertSafeToFetch(url, timeoutMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hostname = u.hostname.toLowerCase();
|
const hostname = u.hostname.toLowerCase();
|
||||||
if (hostname === 'localhost' || hostname === '0.0.0.0' || hostname === '127.0.0.1' || hostname === '::1') {
|
if (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname === '0.0.0.0' ||
|
||||||
|
hostname === '127.0.0.1' ||
|
||||||
|
hostname === '::1'
|
||||||
|
) {
|
||||||
throw new Error('禁止访问本机地址');
|
throw new Error('禁止访问本机地址');
|
||||||
}
|
}
|
||||||
if (hostname.endsWith('.local')) {
|
if (hostname.endsWith('.local')) {
|
||||||
@@ -175,14 +197,14 @@ async function assertSafeToFetch(url, timeoutMs) {
|
|||||||
throw new Error('DNS 解析失败或无结果');
|
throw new Error('DNS 解析失败或无结果');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPrivate = records.some(r => isPrivateIp(r.address));
|
const hasPrivate = records.some((r) => isPrivateIp(r.address));
|
||||||
if (hasPrivate) throw new Error('DNS 解析到内网/保留地址,已阻断');
|
if (hasPrivate) throw new Error('DNS 解析到内网/保留地址,已阻断');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHeaders(userAgent) {
|
function buildHeaders(userAgent) {
|
||||||
return {
|
return {
|
||||||
'user-agent': userAgent,
|
'user-agent': userAgent,
|
||||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +222,7 @@ async function fetchWithRedirects(url, { timeoutMs, maxRedirects, headers, maxBy
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
headers,
|
headers,
|
||||||
signal: controller.signal
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -283,7 +305,7 @@ function extractFeedLinksFromHtml(html, baseUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 简单排序:优先 RSS,其次 Atom
|
// 简单排序:优先 RSS,其次 Atom
|
||||||
const rank = url => (url.includes('atom') ? 2 : 1);
|
const rank = (url) => (url.includes('atom') ? 2 : 1);
|
||||||
return [...new Set(candidates)].sort((a, b) => rank(a) - rank(b));
|
return [...new Set(candidates)].sort((a, b) => rank(a) - rank(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,11 +331,15 @@ async function discoverFeedUrl(siteUrl, settings, deadlineTs) {
|
|||||||
timeoutMs: Math.min(settings.fetch.timeoutMs, timeRemaining),
|
timeoutMs: Math.min(settings.fetch.timeoutMs, timeRemaining),
|
||||||
maxRedirects: settings.fetch.maxRedirects,
|
maxRedirects: settings.fetch.maxRedirects,
|
||||||
headers: buildHeaders(settings.fetch.userAgent),
|
headers: buildHeaders(settings.fetch.userAgent),
|
||||||
maxBytes: settings.fetch.htmlMaxBytes
|
maxBytes: settings.fetch.htmlMaxBytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contentType = homepage.response.headers.get('content-type') || '';
|
const contentType = homepage.response.headers.get('content-type') || '';
|
||||||
if (/text\/html/i.test(contentType) || /application\/xhtml\+xml/i.test(contentType) || !contentType) {
|
if (
|
||||||
|
/text\/html/i.test(contentType) ||
|
||||||
|
/application\/xhtml\+xml/i.test(contentType) ||
|
||||||
|
!contentType
|
||||||
|
) {
|
||||||
const candidates = extractFeedLinksFromHtml(homepage.text, homepage.url);
|
const candidates = extractFeedLinksFromHtml(homepage.text, homepage.url);
|
||||||
if (candidates.length > 0) {
|
if (candidates.length > 0) {
|
||||||
return candidates[0];
|
return candidates[0];
|
||||||
@@ -325,7 +351,8 @@ async function discoverFeedUrl(siteUrl, settings, deadlineTs) {
|
|||||||
|
|
||||||
function stripHtmlToText(input) {
|
function stripHtmlToText(input) {
|
||||||
const raw = String(input || '');
|
const raw = String(input || '');
|
||||||
const withoutTags = raw.replace(/<script[\s\S]*?<\/script>/gi, '')
|
const withoutTags = raw
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
.replace(/<[^>]+>/g, ' ');
|
.replace(/<[^>]+>/g, ' ');
|
||||||
|
|
||||||
@@ -363,17 +390,14 @@ function normalizePublishedAt(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeArticle(item, sourceSite, settings) {
|
function normalizeArticle(item, sourceSite, settings) {
|
||||||
const title = (item && item.title !== undefined) ? String(item.title).trim() : '';
|
const title = item && item.title !== undefined ? String(item.title).trim() : '';
|
||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
|
|
||||||
const link = item && item.link ? String(item.link).trim() : '';
|
const link = item && item.link ? String(item.link).trim() : '';
|
||||||
if (!isHttpUrl(link)) return null;
|
if (!isHttpUrl(link)) return null;
|
||||||
|
|
||||||
const summaryRaw =
|
const summaryRaw =
|
||||||
(item && item.contentSnippet) ||
|
(item && item.contentSnippet) || (item && item.summary) || (item && item.content) || '';
|
||||||
(item && item.summary) ||
|
|
||||||
(item && item.content) ||
|
|
||||||
'';
|
|
||||||
const summaryText = stripHtmlToText(summaryRaw);
|
const summaryText = stripHtmlToText(summaryRaw);
|
||||||
const summary = settings.articles.summaryMaxLength
|
const summary = settings.articles.summaryMaxLength
|
||||||
? truncateText(summaryText, settings.articles.summaryMaxLength)
|
? truncateText(summaryText, settings.articles.summaryMaxLength)
|
||||||
@@ -393,7 +417,7 @@ function normalizeArticle(item, sourceSite, settings) {
|
|||||||
source,
|
source,
|
||||||
// 站点首页 URL(用于生成端按分类聚合展示;文章 url 为具体文章链接)
|
// 站点首页 URL(用于生成端按分类聚合展示;文章 url 为具体文章链接)
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
icon
|
icon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,13 +430,17 @@ async function fetchAndParseFeed(feedUrl, settings, parser, deadlineTs) {
|
|||||||
maxRedirects: settings.fetch.maxRedirects,
|
maxRedirects: settings.fetch.maxRedirects,
|
||||||
headers: {
|
headers: {
|
||||||
...buildHeaders(settings.fetch.userAgent),
|
...buildHeaders(settings.fetch.userAgent),
|
||||||
accept: 'application/rss+xml,application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8'
|
accept: 'application/rss+xml,application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8',
|
||||||
},
|
},
|
||||||
maxBytes: settings.fetch.feedMaxBytes
|
maxBytes: settings.fetch.feedMaxBytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = await parser.parseString(feed.text);
|
const parsed = await parser.parseString(feed.text);
|
||||||
return { feedUrl: feed.url, feedTitle: parsed.title || '', items: Array.isArray(parsed.items) ? parsed.items : [] };
|
return {
|
||||||
|
feedUrl: feed.url,
|
||||||
|
feedTitle: parsed.title || '',
|
||||||
|
items: Array.isArray(parsed.items) ? parsed.items : [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
||||||
@@ -425,18 +453,18 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
|||||||
feedUrl: '',
|
feedUrl: '',
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
error: '无效 URL(需为 http/https)',
|
error: '无效 URL(需为 http/https)',
|
||||||
fetchedAt: new Date().toISOString()
|
fetchedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
articles: []
|
articles: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
|
|
||||||
const tryOnce = async feedUrl => {
|
const tryOnce = async (feedUrl) => {
|
||||||
const parsed = await fetchAndParseFeed(feedUrl, settings, parser, deadlineTs);
|
const parsed = await fetchAndParseFeed(feedUrl, settings, parser, deadlineTs);
|
||||||
const normalized = parsed.items
|
const normalized = parsed.items
|
||||||
.map(item => normalizeArticle(item, sourceSite, settings))
|
.map((item) => normalizeArticle(item, sourceSite, settings))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, settings.articles.perSite);
|
.slice(0, settings.articles.perSite);
|
||||||
return { feedUrl: parsed.feedUrl, articles: normalized };
|
return { feedUrl: parsed.feedUrl, articles: normalized };
|
||||||
@@ -444,7 +472,9 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
|||||||
|
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
const discovered = await discoverFeedUrl(url, settings, deadlineTs);
|
const discovered = await discoverFeedUrl(url, settings, deadlineTs);
|
||||||
const candidates = discovered ? [discovered, ...buildCommonFeedUrls(url)] : buildCommonFeedUrls(url);
|
const candidates = discovered
|
||||||
|
? [discovered, ...buildCommonFeedUrls(url)]
|
||||||
|
: buildCommonFeedUrls(url);
|
||||||
|
|
||||||
for (const candidate of [...new Set(candidates)]) {
|
for (const candidate of [...new Set(candidates)]) {
|
||||||
try {
|
try {
|
||||||
@@ -471,9 +501,9 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
error: '',
|
error: '',
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
durationMs: Date.now() - startedAt
|
durationMs: Date.now() - startedAt,
|
||||||
},
|
},
|
||||||
articles: res.articles
|
articles: res.articles,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lastError = e;
|
lastError = e;
|
||||||
@@ -488,9 +518,9 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
|||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: lastError ? String(lastError.message || lastError) : '未知错误',
|
error: lastError ? String(lastError.message || lastError) : '未知错误',
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
durationMs: Date.now() - startedAt
|
durationMs: Date.now() - startedAt,
|
||||||
},
|
},
|
||||||
articles: []
|
articles: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,12 +554,15 @@ async function mapWithConcurrency(items, concurrency, worker) {
|
|||||||
function collectSitesRecursively(node, output) {
|
function collectSitesRecursively(node, output) {
|
||||||
if (!node || typeof node !== 'object') return;
|
if (!node || typeof node !== 'object') return;
|
||||||
|
|
||||||
if (Array.isArray(node.subcategories)) node.subcategories.forEach(child => collectSitesRecursively(child, output));
|
if (Array.isArray(node.subcategories))
|
||||||
if (Array.isArray(node.groups)) node.groups.forEach(child => collectSitesRecursively(child, output));
|
node.subcategories.forEach((child) => collectSitesRecursively(child, output));
|
||||||
if (Array.isArray(node.subgroups)) node.subgroups.forEach(child => collectSitesRecursively(child, output));
|
if (Array.isArray(node.groups))
|
||||||
|
node.groups.forEach((child) => collectSitesRecursively(child, output));
|
||||||
|
if (Array.isArray(node.subgroups))
|
||||||
|
node.subgroups.forEach((child) => collectSitesRecursively(child, output));
|
||||||
|
|
||||||
if (Array.isArray(node.sites)) {
|
if (Array.isArray(node.sites)) {
|
||||||
node.sites.forEach(site => {
|
node.sites.forEach((site) => {
|
||||||
if (site && typeof site === 'object') output.push(site);
|
if (site && typeof site === 'object') output.push(site);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -538,26 +571,26 @@ function collectSitesRecursively(node, output) {
|
|||||||
function buildFlatSitesFromCategories(categories) {
|
function buildFlatSitesFromCategories(categories) {
|
||||||
const out = [];
|
const out = [];
|
||||||
if (!Array.isArray(categories)) return out;
|
if (!Array.isArray(categories)) return out;
|
||||||
categories.forEach(category => collectSitesRecursively(category, out));
|
categories.forEach((category) => collectSitesRecursively(category, out));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
||||||
const sourceSites = Array.isArray(pageConfig && pageConfig.sites)
|
const sourceSites = Array.isArray(pageConfig && pageConfig.sites)
|
||||||
? pageConfig.sites
|
? pageConfig.sites
|
||||||
: buildFlatSitesFromCategories(pageConfig && Array.isArray(pageConfig.categories) ? pageConfig.categories : []);
|
: buildFlatSitesFromCategories(
|
||||||
|
pageConfig && Array.isArray(pageConfig.categories) ? pageConfig.categories : []
|
||||||
|
);
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const deadlineTs = startedAt + settings.fetch.totalTimeoutMs;
|
const deadlineTs = startedAt + settings.fetch.totalTimeoutMs;
|
||||||
|
|
||||||
const parser = new Parser({
|
const parser = new Parser({
|
||||||
timeout: settings.fetch.timeoutMs
|
timeout: settings.fetch.timeoutMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await mapWithConcurrency(
|
const results = await mapWithConcurrency(sourceSites, settings.fetch.concurrency, async (site) =>
|
||||||
sourceSites,
|
processSourceSite(site, settings, parser, deadlineTs)
|
||||||
settings.fetch.concurrency,
|
|
||||||
async site => processSourceSite(site, settings, parser, deadlineTs)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sites = [];
|
const sites = [];
|
||||||
@@ -585,9 +618,9 @@ async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
|||||||
|
|
||||||
const limitedArticles = articles.slice(0, settings.articles.total);
|
const limitedArticles = articles.slice(0, settings.articles.total);
|
||||||
|
|
||||||
const successSites = sites.filter(s => s.status === 'success').length;
|
const successSites = sites.filter((s) => s.status === 'success').length;
|
||||||
const failedSites = sites.filter(s => s.status === 'failed').length;
|
const failedSites = sites.filter((s) => s.status === 'failed').length;
|
||||||
const skippedSites = sites.filter(s => s.status === 'skipped').length;
|
const skippedSites = sites.filter((s) => s.status === 'skipped').length;
|
||||||
|
|
||||||
const cache = {
|
const cache = {
|
||||||
version: '1.0',
|
version: '1.0',
|
||||||
@@ -602,8 +635,8 @@ async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
|||||||
failedSites,
|
failedSites,
|
||||||
skippedSites,
|
skippedSites,
|
||||||
totalArticles: limitedArticles.length,
|
totalArticles: limitedArticles.length,
|
||||||
durationMs: Date.now() - startedAt
|
durationMs: Date.now() - startedAt,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheDir = path.resolve(process.cwd(), settings.cacheDir);
|
const cacheDir = path.resolve(process.cwd(), settings.cacheDir);
|
||||||
@@ -638,7 +671,7 @@ function pickArticlesPages(config, onlyPageId) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const pageArgIndex = args.findIndex(a => a === '--page');
|
const pageArgIndex = args.findIndex((a) => a === '--page');
|
||||||
const onlyPageId = pageArgIndex >= 0 ? args[pageArgIndex + 1] : null;
|
const onlyPageId = pageArgIndex >= 0 ? args[pageArgIndex + 1] : null;
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -661,7 +694,9 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const { cachePath, cache } = await syncArticlesForPage(pageId, pageConfig, config, settings);
|
const { cachePath, cache } = await syncArticlesForPage(pageId, pageConfig, config, settings);
|
||||||
console.log(`[INFO] 已生成缓存:${cachePath}(articles=${cache.stats.totalArticles}, sites=${cache.stats.totalSites})`);
|
console.log(
|
||||||
|
`[INFO] 已生成缓存:${cachePath}(articles=${cache.stats.totalArticles}, sites=${cache.stats.totalSites})`
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[WARN] 页面 ${pageId} 同步失败:${e.message || e}`);
|
console.warn(`[WARN] 页面 ${pageId} 同步失败:${e.message || e}`);
|
||||||
// best-effort:不阻断其他页面/后续 build
|
// best-effort:不阻断其他页面/后续 build
|
||||||
@@ -670,7 +705,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
main().catch(err => {
|
main().catch((err) => {
|
||||||
console.error('[ERROR] sync-articles 执行失败:', err);
|
console.error('[ERROR] sync-articles 执行失败:', err);
|
||||||
// best-effort:不阻断后续 build/deploy(错误已输出到日志,便于排查)
|
// best-effort:不阻断后续 build/deploy(错误已输出到日志,便于排查)
|
||||||
process.exitCode = 0;
|
process.exitCode = 0;
|
||||||
@@ -683,5 +718,5 @@ module.exports = {
|
|||||||
extractFeedLinksFromHtml,
|
extractFeedLinksFromHtml,
|
||||||
stripHtmlToText,
|
stripHtmlToText,
|
||||||
normalizeArticle,
|
normalizeArticle,
|
||||||
buildFlatSitesFromCategories
|
buildFlatSitesFromCategories,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ const DEFAULT_SETTINGS = {
|
|||||||
fetch: {
|
fetch: {
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
concurrency: 4,
|
concurrency: 4,
|
||||||
userAgent: 'MeNavProjectsSync/1.0'
|
userAgent: 'MeNavProjectsSync/1.0',
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
url: 'https://raw.githubusercontent.com/ozh/github-colors/master/colors.json',
|
url: 'https://raw.githubusercontent.com/ozh/github-colors/master/colors.json',
|
||||||
maxAgeMs: 7 * 24 * 60 * 60 * 1000
|
maxAgeMs: 7 * 24 * 60 * 60 * 1000,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseBooleanEnv(value, fallback) {
|
function parseBooleanEnv(value, fallback) {
|
||||||
@@ -34,25 +34,35 @@ function parseIntegerEnv(value, fallback) {
|
|||||||
|
|
||||||
function getSettings(config) {
|
function getSettings(config) {
|
||||||
const fromConfig =
|
const fromConfig =
|
||||||
config && config.site && config.site.github && typeof config.site.github === 'object' ? config.site.github : {};
|
config && config.site && config.site.github && typeof config.site.github === 'object'
|
||||||
|
? config.site.github
|
||||||
|
: {};
|
||||||
|
|
||||||
const merged = {
|
const merged = {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...fromConfig,
|
...fromConfig,
|
||||||
fetch: {
|
fetch: {
|
||||||
...DEFAULT_SETTINGS.fetch,
|
...DEFAULT_SETTINGS.fetch,
|
||||||
...(fromConfig.fetch || {})
|
...(fromConfig.fetch || {}),
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
...DEFAULT_SETTINGS.colors,
|
...DEFAULT_SETTINGS.colors,
|
||||||
...(fromConfig.colors || {})
|
...(fromConfig.colors || {}),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
merged.enabled = parseBooleanEnv(process.env.PROJECTS_ENABLED, merged.enabled);
|
merged.enabled = parseBooleanEnv(process.env.PROJECTS_ENABLED, merged.enabled);
|
||||||
merged.cacheDir = process.env.PROJECTS_CACHE_DIR ? String(process.env.PROJECTS_CACHE_DIR) : merged.cacheDir;
|
merged.cacheDir = process.env.PROJECTS_CACHE_DIR
|
||||||
merged.fetch.timeoutMs = parseIntegerEnv(process.env.PROJECTS_FETCH_TIMEOUT, merged.fetch.timeoutMs);
|
? String(process.env.PROJECTS_CACHE_DIR)
|
||||||
merged.fetch.concurrency = parseIntegerEnv(process.env.PROJECTS_FETCH_CONCURRENCY, merged.fetch.concurrency);
|
: merged.cacheDir;
|
||||||
|
merged.fetch.timeoutMs = parseIntegerEnv(
|
||||||
|
process.env.PROJECTS_FETCH_TIMEOUT,
|
||||||
|
merged.fetch.timeoutMs
|
||||||
|
);
|
||||||
|
merged.fetch.concurrency = parseIntegerEnv(
|
||||||
|
process.env.PROJECTS_FETCH_CONCURRENCY,
|
||||||
|
merged.fetch.concurrency
|
||||||
|
);
|
||||||
|
|
||||||
merged.fetch.timeoutMs = Math.max(1_000, merged.fetch.timeoutMs);
|
merged.fetch.timeoutMs = Math.max(1_000, merged.fetch.timeoutMs);
|
||||||
merged.fetch.concurrency = Math.max(1, Math.min(10, merged.fetch.concurrency));
|
merged.fetch.concurrency = Math.max(1, Math.min(10, merged.fetch.concurrency));
|
||||||
@@ -83,16 +93,19 @@ function isGithubRepoUrl(url) {
|
|||||||
|
|
||||||
function collectSitesRecursively(node, output) {
|
function collectSitesRecursively(node, output) {
|
||||||
if (!node || typeof node !== 'object') return;
|
if (!node || typeof node !== 'object') return;
|
||||||
if (Array.isArray(node.subcategories)) node.subcategories.forEach(child => collectSitesRecursively(child, output));
|
if (Array.isArray(node.subcategories))
|
||||||
if (Array.isArray(node.groups)) node.groups.forEach(child => collectSitesRecursively(child, output));
|
node.subcategories.forEach((child) => collectSitesRecursively(child, output));
|
||||||
if (Array.isArray(node.subgroups)) node.subgroups.forEach(child => collectSitesRecursively(child, output));
|
if (Array.isArray(node.groups))
|
||||||
if (Array.isArray(node.sites)) node.sites.forEach(site => output.push(site));
|
node.groups.forEach((child) => collectSitesRecursively(child, output));
|
||||||
|
if (Array.isArray(node.subgroups))
|
||||||
|
node.subgroups.forEach((child) => collectSitesRecursively(child, output));
|
||||||
|
if (Array.isArray(node.sites)) node.sites.forEach((site) => output.push(site));
|
||||||
}
|
}
|
||||||
|
|
||||||
function findProjectsPages(config) {
|
function findProjectsPages(config) {
|
||||||
const pages = [];
|
const pages = [];
|
||||||
const nav = Array.isArray(config.navigation) ? config.navigation : [];
|
const nav = Array.isArray(config.navigation) ? config.navigation : [];
|
||||||
nav.forEach(item => {
|
nav.forEach((item) => {
|
||||||
const pageId = item && item.id ? String(item.id) : '';
|
const pageId = item && item.id ? String(item.id) : '';
|
||||||
if (!pageId || !config[pageId]) return;
|
if (!pageId || !config[pageId]) return;
|
||||||
const page = config[pageId];
|
const page = config[pageId];
|
||||||
@@ -131,13 +144,18 @@ async function loadLanguageColors(settings, cacheBaseDir) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const headers = { 'user-agent': settings.fetch.userAgent, accept: 'application/json' };
|
const headers = { 'user-agent': settings.fetch.userAgent, accept: 'application/json' };
|
||||||
const colors = await fetchJsonWithTimeout(settings.colors.url, { timeoutMs: settings.fetch.timeoutMs, headers });
|
const colors = await fetchJsonWithTimeout(settings.colors.url, {
|
||||||
|
timeoutMs: settings.fetch.timeoutMs,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
if (colors && typeof colors === 'object') {
|
if (colors && typeof colors === 'object') {
|
||||||
fs.writeFileSync(cachePath, JSON.stringify(colors, null, 2), 'utf8');
|
fs.writeFileSync(cachePath, JSON.stringify(colors, null, 2), 'utf8');
|
||||||
return colors;
|
return colors;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WARN] 获取语言颜色表失败(将不输出 languageColor):${String(error && error.message ? error.message : error)}`);
|
console.warn(
|
||||||
|
`[WARN] 获取语言颜色表失败(将不输出 languageColor):${String(error && error.message ? error.message : error)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@@ -146,7 +164,7 @@ async function loadLanguageColors(settings, cacheBaseDir) {
|
|||||||
async function fetchRepoMeta(repo, settings, colors) {
|
async function fetchRepoMeta(repo, settings, colors) {
|
||||||
const headers = {
|
const headers = {
|
||||||
'user-agent': settings.fetch.userAgent,
|
'user-agent': settings.fetch.userAgent,
|
||||||
accept: 'application/vnd.github+json'
|
accept: 'application/vnd.github+json',
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiUrl = `https://api.github.com/repos/${repo.owner}/${repo.repo}`;
|
const apiUrl = `https://api.github.com/repos/${repo.owner}/${repo.repo}`;
|
||||||
@@ -167,7 +185,7 @@ async function fetchRepoMeta(repo, settings, colors) {
|
|||||||
language,
|
language,
|
||||||
languageColor,
|
languageColor,
|
||||||
stars,
|
stars,
|
||||||
forks
|
forks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +217,9 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheBaseDir = path.isAbsolute(settings.cacheDir) ? settings.cacheDir : path.join(process.cwd(), settings.cacheDir);
|
const cacheBaseDir = path.isAbsolute(settings.cacheDir)
|
||||||
|
? settings.cacheDir
|
||||||
|
: path.join(process.cwd(), settings.cacheDir);
|
||||||
ensureDir(cacheBaseDir);
|
ensureDir(cacheBaseDir);
|
||||||
|
|
||||||
const colors = await loadLanguageColors(settings, cacheBaseDir);
|
const colors = await loadLanguageColors(settings, cacheBaseDir);
|
||||||
@@ -213,14 +233,14 @@ async function main() {
|
|||||||
for (const { pageId, page } of pages) {
|
for (const { pageId, page } of pages) {
|
||||||
const categories = Array.isArray(page.categories) ? page.categories : [];
|
const categories = Array.isArray(page.categories) ? page.categories : [];
|
||||||
const sites = [];
|
const sites = [];
|
||||||
categories.forEach(category => collectSitesRecursively(category, sites));
|
categories.forEach((category) => collectSitesRecursively(category, sites));
|
||||||
|
|
||||||
const repos = sites
|
const repos = sites
|
||||||
.map(site => (site && site.url ? isGithubRepoUrl(site.url) : null))
|
.map((site) => (site && site.url ? isGithubRepoUrl(site.url) : null))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const unique = new Map();
|
const unique = new Map();
|
||||||
repos.forEach(r => unique.set(r.canonicalUrl, r));
|
repos.forEach((r) => unique.set(r.canonicalUrl, r));
|
||||||
const repoList = Array.from(unique.values());
|
const repoList = Array.from(unique.values());
|
||||||
|
|
||||||
if (!repoList.length) {
|
if (!repoList.length) {
|
||||||
@@ -231,14 +251,16 @@ async function main() {
|
|||||||
let success = 0;
|
let success = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
const results = await runPool(repoList, settings.fetch.concurrency, async repo => {
|
const results = await runPool(repoList, settings.fetch.concurrency, async (repo) => {
|
||||||
try {
|
try {
|
||||||
const meta = await fetchRepoMeta(repo, settings, colors);
|
const meta = await fetchRepoMeta(repo, settings, colors);
|
||||||
success += 1;
|
success += 1;
|
||||||
return meta;
|
return meta;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
console.warn(`[WARN] 拉取失败:${repo.canonicalUrl}(${String(error && error.message ? error.message : error)})`);
|
console.warn(
|
||||||
|
`[WARN] 拉取失败:${repo.canonicalUrl}(${String(error && error.message ? error.message : error)})`
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -251,19 +273,20 @@ async function main() {
|
|||||||
stats: {
|
stats: {
|
||||||
totalRepos: repoList.length,
|
totalRepos: repoList.length,
|
||||||
success,
|
success,
|
||||||
failed
|
failed,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const cachePath = path.join(cacheBaseDir, `${pageId}.repo-cache.json`);
|
const cachePath = path.join(cacheBaseDir, `${pageId}.repo-cache.json`);
|
||||||
fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2), 'utf8');
|
fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
|
||||||
console.log(`[INFO] 页面 ${pageId}:同步完成(成功 ${success} / 失败 ${failed}),写入缓存 ${cachePath}`);
|
console.log(
|
||||||
|
`[INFO] 页面 ${pageId}:同步完成(成功 ${success} / 失败 ${failed}),写入缓存 ${cachePath}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(error => {
|
main().catch((error) => {
|
||||||
console.error('[ERROR] projects 同步异常:', error);
|
console.error('[ERROR] projects 同步异常:', error);
|
||||||
process.exitCode = 0; // best-effort:不阻断后续 build
|
process.exitCode = 0; // best-effort:不阻断后续 build
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ function ensureUserConfigInitialized() {
|
|||||||
|
|
||||||
if (fs.existsSync(CONFIG_DEFAULT_DIR)) {
|
if (fs.existsSync(CONFIG_DEFAULT_DIR)) {
|
||||||
fs.cpSync(CONFIG_DEFAULT_DIR, CONFIG_USER_DIR, { recursive: true });
|
fs.cpSync(CONFIG_DEFAULT_DIR, CONFIG_USER_DIR, { recursive: true });
|
||||||
console.log('[INFO] 检测到 config/user 不存在,已从 config/_default 初始化用户配置(完全替换策略需要完整配置)。');
|
console.log(
|
||||||
|
'[INFO] 检测到 config/user 不存在,已从 config/_default 初始化用户配置(完全替换策略需要完整配置)。'
|
||||||
|
);
|
||||||
return { initialized: true, source: '_default' };
|
return { initialized: true, source: '_default' };
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(CONFIG_USER_DIR, { recursive: true });
|
fs.mkdirSync(CONFIG_USER_DIR, { recursive: true });
|
||||||
console.log('[WARN] 未找到默认配置目录 config/_default,已创建空的 config/user 目录;建议补齐 site.yml 与 pages/*.yml。');
|
console.log(
|
||||||
|
'[WARN] 未找到默认配置目录 config/_default,已创建空的 config/user 目录;建议补齐 site.yml 与 pages/*.yml。'
|
||||||
|
);
|
||||||
return { initialized: true, source: 'empty' };
|
return { initialized: true, source: 'empty' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +52,9 @@ function ensureUserSiteYmlExists() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[WARN] 未找到可用的 site.yml,无法自动更新导航;请手动在 config/user/site.yml 添加 navigation(含 id: bookmarks)。');
|
console.log(
|
||||||
|
'[WARN] 未找到可用的 site.yml,无法自动更新导航;请手动在 config/user/site.yml 添加 navigation(含 id: bookmarks)。'
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +69,7 @@ function upsertBookmarksNavInSiteYml(siteYmlPath) {
|
|||||||
|
|
||||||
const navigation = loaded.navigation;
|
const navigation = loaded.navigation;
|
||||||
|
|
||||||
if (Array.isArray(navigation) && navigation.some(item => item && item.id === 'bookmarks')) {
|
if (Array.isArray(navigation) && navigation.some((item) => item && item.id === 'bookmarks')) {
|
||||||
return { updated: false, reason: 'already_present' };
|
return { updated: false, reason: 'already_present' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@ function upsertBookmarksNavInSiteYml(siteYmlPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lines = raw.split(/\r?\n/);
|
const lines = raw.split(/\r?\n/);
|
||||||
const navLineIndex = lines.findIndex(line => /^navigation\s*:/.test(line));
|
const navLineIndex = lines.findIndex((line) => /^navigation\s*:/.test(line));
|
||||||
|
|
||||||
const itemIndent = ' ';
|
const itemIndent = ' ';
|
||||||
const propIndent = `${itemIndent} `;
|
const propIndent = `${itemIndent} `;
|
||||||
@@ -149,15 +155,15 @@ const ICON_MAPPING = {
|
|||||||
'netflix.com': 'fas fa-film',
|
'netflix.com': 'fas fa-film',
|
||||||
'trello.com': 'fab fa-trello',
|
'trello.com': 'fab fa-trello',
|
||||||
'wordpress.com': 'fab fa-wordpress',
|
'wordpress.com': 'fab fa-wordpress',
|
||||||
'jira': 'fab fa-jira',
|
jira: 'fab fa-jira',
|
||||||
'atlassian.com': 'fab fa-atlassian',
|
'atlassian.com': 'fab fa-atlassian',
|
||||||
'dropbox.com': 'fab fa-dropbox',
|
'dropbox.com': 'fab fa-dropbox',
|
||||||
'npm': 'fab fa-npm',
|
npm: 'fab fa-npm',
|
||||||
'docker.com': 'fab fa-docker',
|
'docker.com': 'fab fa-docker',
|
||||||
'python.org': 'fab fa-python',
|
'python.org': 'fab fa-python',
|
||||||
'javascript': 'fab fa-js',
|
javascript: 'fab fa-js',
|
||||||
'php.net': 'fab fa-php',
|
'php.net': 'fab fa-php',
|
||||||
'java': 'fab fa-java',
|
java: 'fab fa-java',
|
||||||
'codepen.io': 'fab fa-codepen',
|
'codepen.io': 'fab fa-codepen',
|
||||||
'behance.net': 'fab fa-behance',
|
'behance.net': 'fab fa-behance',
|
||||||
'dribbble.com': 'fab fa-dribbble',
|
'dribbble.com': 'fab fa-dribbble',
|
||||||
@@ -166,10 +172,10 @@ const ICON_MAPPING = {
|
|||||||
'flickr.com': 'fab fa-flickr',
|
'flickr.com': 'fab fa-flickr',
|
||||||
'github.io': 'fab fa-github',
|
'github.io': 'fab fa-github',
|
||||||
'airbnb.com': 'fab fa-airbnb',
|
'airbnb.com': 'fab fa-airbnb',
|
||||||
'bitcoin': 'fab fa-bitcoin',
|
bitcoin: 'fab fa-bitcoin',
|
||||||
'paypal.com': 'fab fa-paypal',
|
'paypal.com': 'fab fa-paypal',
|
||||||
'ethereum': 'fab fa-ethereum',
|
ethereum: 'fab fa-ethereum',
|
||||||
'steam': 'fab fa-steam',
|
steam: 'fab fa-steam',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取最新的书签文件
|
// 获取最新的书签文件
|
||||||
@@ -183,8 +189,9 @@ function getLatestBookmarkFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取目录中的所有HTML文件
|
// 获取目录中的所有HTML文件
|
||||||
const files = fs.readdirSync(BOOKMARKS_DIR)
|
const files = fs
|
||||||
.filter(file => file.toLowerCase().endsWith('.html'));
|
.readdirSync(BOOKMARKS_DIR)
|
||||||
|
.filter((file) => file.toLowerCase().endsWith('.html'));
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
console.log('[WARN] 未找到任何HTML书签文件');
|
console.log('[WARN] 未找到任何HTML书签文件');
|
||||||
@@ -192,9 +199,9 @@ function getLatestBookmarkFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取文件状态,按最后修改时间排序
|
// 获取文件状态,按最后修改时间排序
|
||||||
const fileStats = files.map(file => ({
|
const fileStats = files.map((file) => ({
|
||||||
file,
|
file,
|
||||||
mtime: fs.statSync(path.join(BOOKMARKS_DIR, file)).mtime
|
mtime: fs.statSync(path.join(BOOKMARKS_DIR, file)).mtime,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 找出最新的文件
|
// 找出最新的文件
|
||||||
@@ -213,20 +220,21 @@ function getLatestBookmarkFile() {
|
|||||||
|
|
||||||
// 解析书签HTML内容,支持2-4层级嵌套结构
|
// 解析书签HTML内容,支持2-4层级嵌套结构
|
||||||
function parseBookmarks(htmlContent) {
|
function parseBookmarks(htmlContent) {
|
||||||
|
|
||||||
// 正则表达式匹配文件夹和书签
|
// 正则表达式匹配文件夹和书签
|
||||||
const folderRegex = /<DT><H3([^>]*)>(.*?)<\/H3>/g;
|
const folderRegex = /<DT><H3([^>]*)>(.*?)<\/H3>/g;
|
||||||
const bookmarkRegex = /<DT><A HREF="([^"]+)"[^>]*>(.*?)<\/A>/g;
|
const bookmarkRegex = /<DT><A HREF="([^"]+)"[^>]*>(.*?)<\/A>/g;
|
||||||
|
|
||||||
// 储存解析结果
|
// 储存解析结果
|
||||||
const bookmarks = {
|
const bookmarks = {
|
||||||
categories: []
|
categories: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 提取根路径书签(书签栏容器内但不在任何子文件夹内的书签)
|
// 提取根路径书签(书签栏容器内但不在任何子文件夹内的书签)
|
||||||
function extractRootBookmarks(htmlContent) {
|
function extractRootBookmarks(htmlContent) {
|
||||||
// 找到书签栏文件夹标签
|
// 找到书签栏文件夹标签
|
||||||
const bookmarkBarMatch = htmlContent.match(/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i);
|
const bookmarkBarMatch = htmlContent.match(
|
||||||
|
/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i
|
||||||
|
);
|
||||||
if (!bookmarkBarMatch) {
|
if (!bookmarkBarMatch) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -266,7 +274,10 @@ function parseBookmarks(htmlContent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmarkBarContent = htmlContent.substring(bookmarkBarContentStart, bookmarkBarContentEnd);
|
const bookmarkBarContent = htmlContent.substring(
|
||||||
|
bookmarkBarContentStart,
|
||||||
|
bookmarkBarContentEnd
|
||||||
|
);
|
||||||
|
|
||||||
// 现在提取书签栏内所有子文件夹的范围
|
// 现在提取书签栏内所有子文件夹的范围
|
||||||
const subfolderRanges = [];
|
const subfolderRanges = [];
|
||||||
@@ -312,7 +323,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
subfolderRanges.push({
|
subfolderRanges.push({
|
||||||
name: folderName,
|
name: folderName,
|
||||||
start: folderMatch.index,
|
start: folderMatch.index,
|
||||||
end: folderContentEnd
|
end: folderContentEnd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,7 +348,6 @@ function parseBookmarks(htmlContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!inFolder) {
|
if (!inFolder) {
|
||||||
|
|
||||||
// 基于URL选择适当的图标
|
// 基于URL选择适当的图标
|
||||||
let icon = 'fas fa-link';
|
let icon = 'fas fa-link';
|
||||||
for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) {
|
for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) {
|
||||||
@@ -351,7 +361,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
name: name,
|
name: name,
|
||||||
url: url,
|
url: url,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
description: ''
|
description: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,7 +411,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
name: folderName,
|
name: folderName,
|
||||||
start: folderStart,
|
start: folderStart,
|
||||||
headerEnd: folderHeaderEnd,
|
headerEnd: folderHeaderEnd,
|
||||||
end: folderEnd
|
end: folderEnd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
pos += '</DL><p>'.length;
|
pos += '</DL><p>'.length;
|
||||||
@@ -451,7 +461,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
const folder = {
|
const folder = {
|
||||||
name: folderName,
|
name: folderName,
|
||||||
icon: 'fas fa-folder',
|
icon: 'fas fa-folder',
|
||||||
path: [...parentPath, folderName]
|
path: [...parentPath, folderName],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查是否包含子文件夹 - 创建新的正则实例避免干扰主循环
|
// 检查是否包含子文件夹 - 创建新的正则实例避免干扰主循环
|
||||||
@@ -484,10 +494,11 @@ function parseBookmarks(htmlContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 只添加包含内容的文件夹
|
// 只添加包含内容的文件夹
|
||||||
const hasContent = folder.sites && folder.sites.length > 0 ||
|
const hasContent =
|
||||||
folder.subcategories && folder.subcategories.length > 0 ||
|
(folder.sites && folder.sites.length > 0) ||
|
||||||
folder.groups && folder.groups.length > 0 ||
|
(folder.subcategories && folder.subcategories.length > 0) ||
|
||||||
folder.subgroups && folder.subgroups.length > 0;
|
(folder.groups && folder.groups.length > 0) ||
|
||||||
|
(folder.subgroups && folder.subgroups.length > 0);
|
||||||
|
|
||||||
if (hasContent) {
|
if (hasContent) {
|
||||||
folders.push(folder);
|
folders.push(folder);
|
||||||
@@ -547,7 +558,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
subfolderRanges.push({
|
subfolderRanges.push({
|
||||||
name: folderName,
|
name: folderName,
|
||||||
start: folderStart,
|
start: folderStart,
|
||||||
end: folderContentEnd
|
end: folderContentEnd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -571,7 +582,6 @@ function parseBookmarks(htmlContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!inSubfolder) {
|
if (!inSubfolder) {
|
||||||
|
|
||||||
// 基于URL选择适当的图标
|
// 基于URL选择适当的图标
|
||||||
let icon = 'fas fa-link'; // 默认图标
|
let icon = 'fas fa-link'; // 默认图标
|
||||||
for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) {
|
for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) {
|
||||||
@@ -585,7 +595,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
name: name,
|
name: name,
|
||||||
url: url,
|
url: url,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
description: ''
|
description: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,7 +607,9 @@ function parseBookmarks(htmlContent) {
|
|||||||
const rootSites = extractRootBookmarks(htmlContent);
|
const rootSites = extractRootBookmarks(htmlContent);
|
||||||
|
|
||||||
// 找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER)
|
// 找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER)
|
||||||
const bookmarkBarMatch = htmlContent.match(/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i);
|
const bookmarkBarMatch = htmlContent.match(
|
||||||
|
/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i
|
||||||
|
);
|
||||||
if (!bookmarkBarMatch) {
|
if (!bookmarkBarMatch) {
|
||||||
console.log('[WARN] 未找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER),使用备用方案');
|
console.log('[WARN] 未找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER),使用备用方案');
|
||||||
// 备用方案:使用第一个 <DL><p> 标签
|
// 备用方案:使用第一个 <DL><p> 标签
|
||||||
@@ -668,7 +680,10 @@ function parseBookmarks(htmlContent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmarkBarContent = htmlContent.substring(bookmarkBarContentStart, bookmarkBarContentEnd);
|
const bookmarkBarContent = htmlContent.substring(
|
||||||
|
bookmarkBarContentStart,
|
||||||
|
bookmarkBarContentEnd
|
||||||
|
);
|
||||||
|
|
||||||
// 解析书签栏内的子文件夹作为顶层分类(跳过书签栏本身)
|
// 解析书签栏内的子文件夹作为顶层分类(跳过书签栏本身)
|
||||||
bookmarks.categories = parseNestedFolder(bookmarkBarContent);
|
bookmarks.categories = parseNestedFolder(bookmarkBarContent);
|
||||||
@@ -684,7 +699,7 @@ function parseBookmarks(htmlContent) {
|
|||||||
name: '根目录书签',
|
name: '根目录书签',
|
||||||
icon: 'fas fa-star',
|
icon: 'fas fa-star',
|
||||||
path: ['根目录书签'],
|
path: ['根目录书签'],
|
||||||
sites: rootSites
|
sites: rootSites,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 插入到数组首位
|
// 插入到数组首位
|
||||||
@@ -702,14 +717,14 @@ function generateBookmarksYaml(bookmarks) {
|
|||||||
const bookmarksPage = {
|
const bookmarksPage = {
|
||||||
title: '我的书签',
|
title: '我的书签',
|
||||||
subtitle: '从浏览器导入的书签收藏',
|
subtitle: '从浏览器导入的书签收藏',
|
||||||
categories: bookmarks.categories
|
categories: bookmarks.categories,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 转换为YAML
|
// 转换为YAML
|
||||||
const yamlString = yaml.dump(bookmarksPage, {
|
const yamlString = yaml.dump(bookmarksPage, {
|
||||||
indent: 2,
|
indent: 2,
|
||||||
lineWidth: -1,
|
lineWidth: -1,
|
||||||
quotingType: '"'
|
quotingType: '"',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加注释(可选确定性输出,方便版本管理)
|
// 添加注释(可选确定性输出,方便版本管理)
|
||||||
@@ -718,8 +733,7 @@ function generateBookmarksYaml(bookmarks) {
|
|||||||
? ''
|
? ''
|
||||||
: `# 由bookmark-processor.js生成于 ${new Date().toISOString()}\n`;
|
: `# 由bookmark-processor.js生成于 ${new Date().toISOString()}\n`;
|
||||||
|
|
||||||
const yamlWithComment =
|
const yamlWithComment = `# 自动生成的书签配置文件
|
||||||
`# 自动生成的书签配置文件
|
|
||||||
${timestampLine}# 若要更新,请将新的书签HTML文件放入bookmarks/目录
|
${timestampLine}# 若要更新,请将新的书签HTML文件放入bookmarks/目录
|
||||||
# 此文件使用模块化配置格式,位于config/user/pages/目录下
|
# 此文件使用模块化配置格式,位于config/user/pages/目录下
|
||||||
|
|
||||||
@@ -822,14 +836,15 @@ async function main() {
|
|||||||
} else if (navUpdateResult.reason === 'already_present') {
|
} else if (navUpdateResult.reason === 'already_present') {
|
||||||
console.log('[INFO] 导航配置已包含书签入口,无需更新\n');
|
console.log('[INFO] 导航配置已包含书签入口,无需更新\n');
|
||||||
} else if (navUpdateResult.reason === 'no_navigation_config') {
|
} else if (navUpdateResult.reason === 'no_navigation_config') {
|
||||||
console.log('[WARN] 未找到可更新的导航配置文件;请确认 config/user/site.yml 存在且包含 navigation\n');
|
console.log(
|
||||||
|
'[WARN] 未找到可更新的导航配置文件;请确认 config/user/site.yml 存在且包含 navigation\n'
|
||||||
|
);
|
||||||
} else if (navUpdateResult.reason === 'error') {
|
} else if (navUpdateResult.reason === 'error') {
|
||||||
console.log('[WARN] 导航更新失败,请手动检查配置文件格式(详见错误信息)\n');
|
console.log('[WARN] 导航更新失败,请手动检查配置文件格式(详见错误信息)\n');
|
||||||
console.error(navUpdateResult.error);
|
console.error(navUpdateResult.error);
|
||||||
} else {
|
} else {
|
||||||
console.log('[INFO] 导航配置无需更新\n');
|
console.log('[INFO] 导航配置无需更新\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (writeError) {
|
} catch (writeError) {
|
||||||
console.error(`[ERROR] 写入文件时出错:`, writeError);
|
console.error(`[ERROR] 写入文件时出错:`, writeError);
|
||||||
console.error('[ERROR] 错误堆栈:', writeError.stack);
|
console.error('[ERROR] 错误堆栈:', writeError.stack);
|
||||||
@@ -839,7 +854,6 @@ async function main() {
|
|||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('[SUCCESS] 书签处理完成!');
|
console.log('[SUCCESS] 书签处理完成!');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[FATAL] 处理书签文件时发生错误:', error);
|
console.error('[FATAL] 处理书签文件时发生错误:', error);
|
||||||
console.error('[ERROR] 错误堆栈:', error.stack);
|
console.error('[ERROR] 错误堆栈:', error.stack);
|
||||||
@@ -849,7 +863,7 @@ async function main() {
|
|||||||
|
|
||||||
// 启动处理
|
// 启动处理
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
main().catch(err => {
|
main().catch((err) => {
|
||||||
console.error('Unhandled error in bookmark processing:', err);
|
console.error('Unhandled error in bookmark processing:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
273
src/generator.js
273
src/generator.js
@@ -23,7 +23,7 @@ function loadHandlebarsTemplates() {
|
|||||||
// 加载布局模板
|
// 加载布局模板
|
||||||
const layoutsDir = path.join(templatesDir, 'layouts');
|
const layoutsDir = path.join(templatesDir, 'layouts');
|
||||||
if (fs.existsSync(layoutsDir)) {
|
if (fs.existsSync(layoutsDir)) {
|
||||||
fs.readdirSync(layoutsDir).forEach(file => {
|
fs.readdirSync(layoutsDir).forEach((file) => {
|
||||||
if (file.endsWith('.hbs')) {
|
if (file.endsWith('.hbs')) {
|
||||||
const layoutName = path.basename(file, '.hbs');
|
const layoutName = path.basename(file, '.hbs');
|
||||||
const layoutPath = path.join(layoutsDir, file);
|
const layoutPath = path.join(layoutsDir, file);
|
||||||
@@ -38,7 +38,7 @@ function loadHandlebarsTemplates() {
|
|||||||
// 加载组件模板
|
// 加载组件模板
|
||||||
const componentsDir = path.join(templatesDir, 'components');
|
const componentsDir = path.join(templatesDir, 'components');
|
||||||
if (fs.existsSync(componentsDir)) {
|
if (fs.existsSync(componentsDir)) {
|
||||||
fs.readdirSync(componentsDir).forEach(file => {
|
fs.readdirSync(componentsDir).forEach((file) => {
|
||||||
if (file.endsWith('.hbs')) {
|
if (file.endsWith('.hbs')) {
|
||||||
const componentName = path.basename(file, '.hbs');
|
const componentName = path.basename(file, '.hbs');
|
||||||
const componentPath = path.join(componentsDir, file);
|
const componentPath = path.join(componentsDir, file);
|
||||||
@@ -76,7 +76,7 @@ function getDefaultLayoutTemplate() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
path: defaultLayoutPath,
|
path: defaultLayoutPath,
|
||||||
template: layoutTemplate
|
template: layoutTemplate,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Error loading default layout template: ${error.message}`);
|
throw new Error(`Error loading default layout template: ${error.message}`);
|
||||||
@@ -100,7 +100,7 @@ function renderTemplate(templateName, data, useLayout = true) {
|
|||||||
// 添加 pageId 到数据中,以便通用模板使用(优先保留原 pageId,避免回退时语义错位)
|
// 添加 pageId 到数据中,以便通用模板使用(优先保留原 pageId,避免回退时语义错位)
|
||||||
const enhancedData = {
|
const enhancedData = {
|
||||||
...data,
|
...data,
|
||||||
pageId: data && data.pageId ? data.pageId : templateName
|
pageId: data && data.pageId ? data.pageId : templateName,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染页面内容
|
// 渲染页面内容
|
||||||
@@ -118,7 +118,7 @@ function renderTemplate(templateName, data, useLayout = true) {
|
|||||||
// 准备布局数据,包含页面内容
|
// 准备布局数据,包含页面内容
|
||||||
const layoutData = {
|
const layoutData = {
|
||||||
...enhancedData,
|
...enhancedData,
|
||||||
body: pageContent
|
body: pageContent,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染完整页面
|
// 渲染完整页面
|
||||||
@@ -127,7 +127,9 @@ function renderTemplate(templateName, data, useLayout = true) {
|
|||||||
throw new Error(`Error rendering layout for ${templateName}: ${layoutError.message}`);
|
throw new Error(`Error rendering layout for ${templateName}: ${layoutError.message}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Template ${templateName}.hbs not found and generic template page.hbs not found. Cannot proceed without template.`);
|
throw new Error(
|
||||||
|
`Template ${templateName}.hbs not found and generic template page.hbs not found. Cannot proceed without template.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +152,7 @@ function renderTemplate(templateName, data, useLayout = true) {
|
|||||||
// 准备布局数据,包含页面内容
|
// 准备布局数据,包含页面内容
|
||||||
const layoutData = {
|
const layoutData = {
|
||||||
...data,
|
...data,
|
||||||
body: pageContent
|
body: pageContent,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染完整页面
|
// 渲染完整页面
|
||||||
@@ -169,11 +171,11 @@ function escapeHtml(unsafe) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return String(unsafe)
|
return String(unsafe)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, "<")
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,7 +209,9 @@ function safeLoadYamlConfig(filePath) {
|
|||||||
|
|
||||||
// 如果有多个文档,返回第一个文档(忽略后面的文档)
|
// 如果有多个文档,返回第一个文档(忽略后面的文档)
|
||||||
if (docs.length > 1) {
|
if (docs.length > 1) {
|
||||||
console.warn(`Warning: Multiple documents found in ${filePath}. Using the first document only.`);
|
console.warn(
|
||||||
|
`Warning: Multiple documents found in ${filePath}. Using the first document only.`
|
||||||
|
);
|
||||||
return docs[0];
|
return docs[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +238,7 @@ function loadModularConfig(dirPath) {
|
|||||||
fonts: {},
|
fonts: {},
|
||||||
profile: {},
|
profile: {},
|
||||||
social: [],
|
social: [],
|
||||||
categories: []
|
categories: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载基础配置
|
// 加载基础配置
|
||||||
@@ -261,10 +265,11 @@ function loadModularConfig(dirPath) {
|
|||||||
// 加载页面配置
|
// 加载页面配置
|
||||||
const pagesPath = path.join(dirPath, 'pages');
|
const pagesPath = path.join(dirPath, 'pages');
|
||||||
if (fs.existsSync(pagesPath)) {
|
if (fs.existsSync(pagesPath)) {
|
||||||
const files = fs.readdirSync(pagesPath).filter(file =>
|
const files = fs
|
||||||
file.endsWith('.yml') || file.endsWith('.yaml'));
|
.readdirSync(pagesPath)
|
||||||
|
.filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
|
||||||
|
|
||||||
files.forEach(file => {
|
files.forEach((file) => {
|
||||||
const filePath = path.join(pagesPath, file);
|
const filePath = path.join(pagesPath, file);
|
||||||
const fileConfig = safeLoadYamlConfig(filePath);
|
const fileConfig = safeLoadYamlConfig(filePath);
|
||||||
|
|
||||||
@@ -321,7 +326,7 @@ function ensureConfigDefaults(config) {
|
|||||||
result.site.theme = result.site.theme || {
|
result.site.theme = result.site.theme || {
|
||||||
primary: '#4a89dc',
|
primary: '#4a89dc',
|
||||||
background: '#f5f7fa',
|
background: '#f5f7fa',
|
||||||
modeToggle: true
|
modeToggle: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 用户资料默认值
|
// 用户资料默认值
|
||||||
@@ -359,7 +364,7 @@ function ensureConfigDefaults(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 为所有页面配置中的类别和站点设置默认值
|
// 为所有页面配置中的类别和站点设置默认值
|
||||||
Object.keys(result).forEach(key => {
|
Object.keys(result).forEach((key) => {
|
||||||
const pageConfig = result[key];
|
const pageConfig = result[key];
|
||||||
// 检查是否是页面配置对象
|
// 检查是否是页面配置对象
|
||||||
if (!pageConfig || typeof pageConfig !== 'object') return;
|
if (!pageConfig || typeof pageConfig !== 'object') return;
|
||||||
@@ -408,7 +413,8 @@ function getSubmenuForNavItem(navItem, config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 通用处理:任意页面的子菜单生成(基于 pages/<id>.yml 的 categories)
|
// 通用处理:任意页面的子菜单生成(基于 pages/<id>.yml 的 categories)
|
||||||
if (config[navItem.id] && Array.isArray(config[navItem.id].categories)) return config[navItem.id].categories;
|
if (config[navItem.id] && Array.isArray(config[navItem.id].categories))
|
||||||
|
return config[navItem.id].categories;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -440,7 +446,7 @@ function makeUniqueSlug(base, usedSlugs) {
|
|||||||
function assignCategorySlugs(categories, usedSlugs) {
|
function assignCategorySlugs(categories, usedSlugs) {
|
||||||
if (!Array.isArray(categories)) return;
|
if (!Array.isArray(categories)) return;
|
||||||
|
|
||||||
categories.forEach(category => {
|
categories.forEach((category) => {
|
||||||
if (!category || typeof category !== 'object') return;
|
if (!category || typeof category !== 'object') return;
|
||||||
|
|
||||||
const base = makeCategorySlugBase(category.name);
|
const base = makeCategorySlugBase(category.name);
|
||||||
@@ -504,11 +510,10 @@ function tryGetGitLastCommitIso(filePath) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const relativePath = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
|
const relativePath = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
|
||||||
const output = execFileSync(
|
const output = execFileSync('git', ['log', '-1', '--format=%cI', '--', relativePath], {
|
||||||
'git',
|
encoding: 'utf8',
|
||||||
['log', '-1', '--format=%cI', '--', relativePath],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
});
|
||||||
);
|
|
||||||
const raw = String(output || '').trim();
|
const raw = String(output || '').trim();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
@@ -574,7 +579,9 @@ function tryLoadArticlesFeedCache(pageId, config) {
|
|||||||
|
|
||||||
const cacheDirFromEnv = process.env.RSS_CACHE_DIR ? String(process.env.RSS_CACHE_DIR) : '';
|
const cacheDirFromEnv = process.env.RSS_CACHE_DIR ? String(process.env.RSS_CACHE_DIR) : '';
|
||||||
const cacheDirFromConfig =
|
const cacheDirFromConfig =
|
||||||
config && config.site && config.site.rss && config.site.rss.cacheDir ? String(config.site.rss.cacheDir) : '';
|
config && config.site && config.site.rss && config.site.rss.cacheDir
|
||||||
|
? String(config.site.rss.cacheDir)
|
||||||
|
: '';
|
||||||
const cacheDir = cacheDirFromEnv || cacheDirFromConfig || 'dev';
|
const cacheDir = cacheDirFromEnv || cacheDirFromConfig || 'dev';
|
||||||
|
|
||||||
const cacheBaseDir = path.isAbsolute(cacheDir) ? cacheDir : path.join(process.cwd(), cacheDir);
|
const cacheBaseDir = path.isAbsolute(cacheDir) ? cacheDir : path.join(process.cwd(), cacheDir);
|
||||||
@@ -588,7 +595,7 @@ function tryLoadArticlesFeedCache(pageId, config) {
|
|||||||
|
|
||||||
const articles = Array.isArray(parsed.articles) ? parsed.articles : [];
|
const articles = Array.isArray(parsed.articles) ? parsed.articles : [];
|
||||||
const items = articles
|
const items = articles
|
||||||
.map(a => {
|
.map((a) => {
|
||||||
const title = a && a.title ? String(a.title) : '';
|
const title = a && a.title ? String(a.title) : '';
|
||||||
const url = a && a.url ? String(a.url) : '';
|
const url = a && a.url ? String(a.url) : '';
|
||||||
if (!title || !url) return null;
|
if (!title || !url) return null;
|
||||||
@@ -607,7 +614,7 @@ function tryLoadArticlesFeedCache(pageId, config) {
|
|||||||
sourceUrl: a && a.sourceUrl ? String(a.sourceUrl) : '',
|
sourceUrl: a && a.sourceUrl ? String(a.sourceUrl) : '',
|
||||||
|
|
||||||
// 文章链接通常应在新标签页打开
|
// 文章链接通常应在新标签页打开
|
||||||
external: true
|
external: true,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -617,8 +624,11 @@ function tryLoadArticlesFeedCache(pageId, config) {
|
|||||||
meta: {
|
meta: {
|
||||||
pageId: parsed.pageId || pageId,
|
pageId: parsed.pageId || pageId,
|
||||||
generatedAt: parsed.generatedAt || '',
|
generatedAt: parsed.generatedAt || '',
|
||||||
total: parsed.stats && Number.isFinite(parsed.stats.totalArticles) ? parsed.stats.totalArticles : items.length
|
total:
|
||||||
}
|
parsed.stats && Number.isFinite(parsed.stats.totalArticles)
|
||||||
|
? parsed.stats.totalArticles
|
||||||
|
: items.length,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[WARN] articles 缓存读取失败:${cachePath}(将回退 Phase 1)`);
|
console.warn(`[WARN] articles 缓存读取失败:${cachePath}(将回退 Phase 1)`);
|
||||||
@@ -643,12 +653,15 @@ function normalizeUrlKey(input) {
|
|||||||
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -672,18 +685,18 @@ function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
|
|||||||
{
|
{
|
||||||
name: '最新文章',
|
name: '最新文章',
|
||||||
icon: 'fas fa-rss',
|
icon: 'fas fa-rss',
|
||||||
items: safeItems
|
items: safeItems,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryIndex = safeCategories.map(category => {
|
const categoryIndex = safeCategories.map((category) => {
|
||||||
const sites = [];
|
const sites = [];
|
||||||
collectSitesRecursively(category, sites);
|
collectSitesRecursively(category, sites);
|
||||||
|
|
||||||
const siteUrlKeys = new Set();
|
const siteUrlKeys = new Set();
|
||||||
const siteNameKeys = new Set();
|
const siteNameKeys = new Set();
|
||||||
sites.forEach(site => {
|
sites.forEach((site) => {
|
||||||
const urlKey = normalizeUrlKey(site && site.url ? String(site.url) : '');
|
const urlKey = normalizeUrlKey(site && site.url ? String(site.url) : '');
|
||||||
if (urlKey) siteUrlKeys.add(urlKey);
|
if (urlKey) siteUrlKeys.add(urlKey);
|
||||||
const nameKey = site && site.name ? String(site.name).trim().toLowerCase() : '';
|
const nameKey = site && site.name ? String(site.name).trim().toLowerCase() : '';
|
||||||
@@ -696,16 +709,16 @@ function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
|
|||||||
const buckets = categoryIndex.map(() => []);
|
const buckets = categoryIndex.map(() => []);
|
||||||
const uncategorized = [];
|
const uncategorized = [];
|
||||||
|
|
||||||
safeItems.forEach(item => {
|
safeItems.forEach((item) => {
|
||||||
const sourceUrlKey = normalizeUrlKey(item && item.sourceUrl ? String(item.sourceUrl) : '');
|
const sourceUrlKey = normalizeUrlKey(item && item.sourceUrl ? String(item.sourceUrl) : '');
|
||||||
const sourceNameKey = item && item.source ? String(item.source).trim().toLowerCase() : '';
|
const sourceNameKey = item && item.source ? String(item.source).trim().toLowerCase() : '';
|
||||||
|
|
||||||
let matchedIndex = -1;
|
let matchedIndex = -1;
|
||||||
if (sourceUrlKey) {
|
if (sourceUrlKey) {
|
||||||
matchedIndex = categoryIndex.findIndex(idx => idx.siteUrlKeys.has(sourceUrlKey));
|
matchedIndex = categoryIndex.findIndex((idx) => idx.siteUrlKeys.has(sourceUrlKey));
|
||||||
}
|
}
|
||||||
if (matchedIndex < 0 && sourceNameKey) {
|
if (matchedIndex < 0 && sourceNameKey) {
|
||||||
matchedIndex = categoryIndex.findIndex(idx => idx.siteNameKeys.has(sourceNameKey));
|
matchedIndex = categoryIndex.findIndex((idx) => idx.siteNameKeys.has(sourceNameKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedIndex < 0) {
|
if (matchedIndex < 0) {
|
||||||
@@ -719,14 +732,14 @@ function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
|
|||||||
const displayCategories = categoryIndex.map((idx, i) => ({
|
const displayCategories = categoryIndex.map((idx, i) => ({
|
||||||
name: idx.category && idx.category.name ? String(idx.category.name) : '未命名分类',
|
name: idx.category && idx.category.name ? String(idx.category.name) : '未命名分类',
|
||||||
icon: idx.category && idx.category.icon ? String(idx.category.icon) : 'fas fa-rss',
|
icon: idx.category && idx.category.icon ? String(idx.category.icon) : 'fas fa-rss',
|
||||||
items: buckets[i]
|
items: buckets[i],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (uncategorized.length > 0) {
|
if (uncategorized.length > 0) {
|
||||||
displayCategories.push({
|
displayCategories.push({
|
||||||
name: '其他',
|
name: '其他',
|
||||||
icon: 'fas fa-ellipsis-h',
|
icon: 'fas fa-ellipsis-h',
|
||||||
items: uncategorized
|
items: uncategorized,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,9 +749,13 @@ function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
|
|||||||
function tryLoadProjectsRepoCache(pageId, config) {
|
function tryLoadProjectsRepoCache(pageId, config) {
|
||||||
if (!pageId) return null;
|
if (!pageId) return null;
|
||||||
|
|
||||||
const cacheDirFromEnv = process.env.PROJECTS_CACHE_DIR ? String(process.env.PROJECTS_CACHE_DIR) : '';
|
const cacheDirFromEnv = process.env.PROJECTS_CACHE_DIR
|
||||||
|
? String(process.env.PROJECTS_CACHE_DIR)
|
||||||
|
: '';
|
||||||
const cacheDirFromConfig =
|
const cacheDirFromConfig =
|
||||||
config && config.site && config.site.github && config.site.github.cacheDir ? String(config.site.github.cacheDir) : '';
|
config && config.site && config.site.github && config.site.github.cacheDir
|
||||||
|
? String(config.site.github.cacheDir)
|
||||||
|
: '';
|
||||||
const cacheDir = cacheDirFromEnv || cacheDirFromConfig || 'dev';
|
const cacheDir = cacheDirFromEnv || cacheDirFromConfig || 'dev';
|
||||||
|
|
||||||
const cacheBaseDir = path.isAbsolute(cacheDir) ? cacheDir : path.join(process.cwd(), cacheDir);
|
const cacheBaseDir = path.isAbsolute(cacheDir) ? cacheDir : path.join(process.cwd(), cacheDir);
|
||||||
@@ -752,14 +769,14 @@ function tryLoadProjectsRepoCache(pageId, config) {
|
|||||||
|
|
||||||
const repos = Array.isArray(parsed.repos) ? parsed.repos : [];
|
const repos = Array.isArray(parsed.repos) ? parsed.repos : [];
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
repos.forEach(r => {
|
repos.forEach((r) => {
|
||||||
const url = r && r.url ? String(r.url) : '';
|
const url = r && r.url ? String(r.url) : '';
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
map.set(url, {
|
map.set(url, {
|
||||||
language: r && r.language ? String(r.language) : '',
|
language: r && r.language ? String(r.language) : '',
|
||||||
languageColor: r && r.languageColor ? String(r.languageColor) : '',
|
languageColor: r && r.languageColor ? String(r.languageColor) : '',
|
||||||
stars: Number.isFinite(r && r.stars) ? r.stars : null,
|
stars: Number.isFinite(r && r.stars) ? r.stars : null,
|
||||||
forks: Number.isFinite(r && r.forks) ? r.forks : null
|
forks: Number.isFinite(r && r.forks) ? r.forks : null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -767,8 +784,8 @@ function tryLoadProjectsRepoCache(pageId, config) {
|
|||||||
map,
|
map,
|
||||||
meta: {
|
meta: {
|
||||||
pageId: parsed.pageId || pageId,
|
pageId: parsed.pageId || pageId,
|
||||||
generatedAt: parsed.generatedAt || ''
|
generatedAt: parsed.generatedAt || '',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[WARN] projects 缓存读取失败:${cachePath}(将仅展示标题与描述)`);
|
console.warn(`[WARN] projects 缓存读取失败:${cachePath}(将仅展示标题与描述)`);
|
||||||
@@ -802,7 +819,7 @@ function applyRepoMetaToCategories(categories, repoMetaMap) {
|
|||||||
if (Array.isArray(node.subgroups)) node.subgroups.forEach(walk);
|
if (Array.isArray(node.subgroups)) node.subgroups.forEach(walk);
|
||||||
|
|
||||||
if (Array.isArray(node.sites)) {
|
if (Array.isArray(node.sites)) {
|
||||||
node.sites.forEach(site => {
|
node.sites.forEach((site) => {
|
||||||
if (!site || typeof site !== 'object' || !site.url) return;
|
if (!site || typeof site !== 'object' || !site.url) return;
|
||||||
const canonical = normalizeGithubRepoUrl(site.url);
|
const canonical = normalizeGithubRepoUrl(site.url);
|
||||||
if (!canonical) return;
|
if (!canonical) return;
|
||||||
@@ -833,7 +850,7 @@ function prepareRenderData(config) {
|
|||||||
renderData._meta = {
|
renderData._meta = {
|
||||||
generated_at: new Date(),
|
generated_at: new Date(),
|
||||||
version: process.env.npm_package_version || '1.0.0',
|
version: process.env.npm_package_version || '1.0.0',
|
||||||
generator: 'MeNav'
|
generator: 'MeNav',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确保navigation是数组
|
// 确保navigation是数组
|
||||||
@@ -849,7 +866,7 @@ function prepareRenderData(config) {
|
|||||||
...item,
|
...item,
|
||||||
isActive: index === 0, // 默认第一项为活动项
|
isActive: index === 0, // 默认第一项为活动项
|
||||||
id: item.id || `nav-${index}`,
|
id: item.id || `nav-${index}`,
|
||||||
active: index === 0 // 保持旧模板兼容(由顺序决定,不读取配置的 active 字段)
|
active: index === 0, // 保持旧模板兼容(由顺序决定,不读取配置的 active 字段)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用辅助函数获取子菜单
|
// 使用辅助函数获取子菜单
|
||||||
@@ -863,11 +880,12 @@ function prepareRenderData(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 首页(默认页)规则:navigation 顺序第一项即首页
|
// 首页(默认页)规则:navigation 顺序第一项即首页
|
||||||
renderData.homePageId = renderData.navigation && renderData.navigation[0] ? renderData.navigation[0].id : null;
|
renderData.homePageId =
|
||||||
|
renderData.navigation && renderData.navigation[0] ? renderData.navigation[0].id : null;
|
||||||
|
|
||||||
// 为每个页面的分类生成稳定锚点 slug(解决重名/空格/特殊字符导致的 hash 冲突)
|
// 为每个页面的分类生成稳定锚点 slug(解决重名/空格/特殊字符导致的 hash 冲突)
|
||||||
if (Array.isArray(renderData.navigation)) {
|
if (Array.isArray(renderData.navigation)) {
|
||||||
renderData.navigation.forEach(navItem => {
|
renderData.navigation.forEach((navItem) => {
|
||||||
const pageConfig = renderData[navItem.id];
|
const pageConfig = renderData[navItem.id];
|
||||||
if (pageConfig && Array.isArray(pageConfig.categories)) {
|
if (pageConfig && Array.isArray(pageConfig.categories)) {
|
||||||
assignCategorySlugs(pageConfig.categories, new Map());
|
assignCategorySlugs(pageConfig.categories, new Map());
|
||||||
@@ -880,7 +898,7 @@ function prepareRenderData(config) {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: process.env.npm_package_version || '1.0.0',
|
version: process.env.npm_package_version || '1.0.0',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
data: renderData // 使用经过处理的renderData而不是原始config
|
data: renderData, // 使用经过处理的renderData而不是原始config
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -903,7 +921,7 @@ function loadConfig() {
|
|||||||
navigation: [],
|
navigation: [],
|
||||||
fonts: {},
|
fonts: {},
|
||||||
profile: {},
|
profile: {},
|
||||||
social: []
|
social: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查模块化配置来源是否存在
|
// 检查模块化配置来源是否存在
|
||||||
@@ -915,13 +933,17 @@ function loadConfig() {
|
|||||||
// 配置采用“完全替换”策略:一旦存在 config/user/,将不会回退到 config/_default/
|
// 配置采用“完全替换”策略:一旦存在 config/user/,将不会回退到 config/_default/
|
||||||
if (!fs.existsSync('config/user/site.yml')) {
|
if (!fs.existsSync('config/user/site.yml')) {
|
||||||
console.error('[ERROR] 检测到 config/user/ 目录,但缺少 config/user/site.yml。');
|
console.error('[ERROR] 检测到 config/user/ 目录,但缺少 config/user/site.yml。');
|
||||||
console.error('[ERROR] 由于配置采用“完全替换”策略,系统不会从 config/_default/ 补齐缺失配置。');
|
console.error(
|
||||||
|
'[ERROR] 由于配置采用“完全替换”策略,系统不会从 config/_default/ 补齐缺失配置。'
|
||||||
|
);
|
||||||
console.error('[ERROR] 解决方法:先完整复制 config/_default/ 到 config/user/,再按需修改。');
|
console.error('[ERROR] 解决方法:先完整复制 config/_default/ 到 config/user/,再按需修改。');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync('config/user/pages')) {
|
if (!fs.existsSync('config/user/pages')) {
|
||||||
console.warn('[WARN] 检测到 config/user/ 目录,但缺少 config/user/pages/。部分页面内容可能为空。');
|
console.warn(
|
||||||
|
'[WARN] 检测到 config/user/ 目录,但缺少 config/user/pages/。部分页面内容可能为空。'
|
||||||
|
);
|
||||||
console.warn('[WARN] 建议:复制 config/_default/pages/ 到 config/user/pages/,再按需修改。');
|
console.warn('[WARN] 建议:复制 config/_default/pages/ 到 config/user/pages/,再按需修改。');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,7 +955,9 @@ function loadConfig() {
|
|||||||
} else {
|
} else {
|
||||||
console.error('[ERROR] 未找到可用配置:缺少 config/user/ 或 config/_default/。');
|
console.error('[ERROR] 未找到可用配置:缺少 config/user/ 或 config/_default/。');
|
||||||
console.error('[ERROR] 本版本已不再支持旧版单文件配置(config.yml / config.yaml)。');
|
console.error('[ERROR] 本版本已不再支持旧版单文件配置(config.yml / config.yaml)。');
|
||||||
console.error('[ERROR] 解决方法:使用模块化配置目录(建议从 config/_default/ 复制到 config/user/ 再修改)。');
|
console.error(
|
||||||
|
'[ERROR] 解决方法:使用模块化配置目录(建议从 config/_default/ 复制到 config/user/ 再修改)。'
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,7 +976,8 @@ function loadConfig() {
|
|||||||
|
|
||||||
// 生成导航菜单
|
// 生成导航菜单
|
||||||
function generateNavigation(navigation, config) {
|
function generateNavigation(navigation, config) {
|
||||||
return navigation.map(nav => {
|
return navigation
|
||||||
|
.map((nav) => {
|
||||||
// 根据页面ID获取对应的子菜单项(分类)
|
// 根据页面ID获取对应的子菜单项(分类)
|
||||||
let submenuItems = '';
|
let submenuItems = '';
|
||||||
|
|
||||||
@@ -963,12 +988,16 @@ function generateNavigation(navigation, config) {
|
|||||||
if (submenu && Array.isArray(submenu)) {
|
if (submenu && Array.isArray(submenu)) {
|
||||||
submenuItems = `
|
submenuItems = `
|
||||||
<div class="submenu">
|
<div class="submenu">
|
||||||
${submenu.map(category => `
|
${submenu
|
||||||
|
.map(
|
||||||
|
(category) => `
|
||||||
<a href="#${category.name}" class="submenu-item" data-page="${nav.id}" data-category="${category.name}">
|
<a href="#${category.name}" class="submenu-item" data-page="${nav.id}" data-category="${category.name}">
|
||||||
<i class="${escapeHtml(category.icon)}"></i>
|
<i class="${escapeHtml(category.icon)}"></i>
|
||||||
<span>${escapeHtml(category.name)}</span>
|
<span>${escapeHtml(category.name)}</span>
|
||||||
</a>
|
</a>
|
||||||
`).join('')}
|
`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,7 +1012,8 @@ function generateNavigation(navigation, config) {
|
|||||||
</a>
|
</a>
|
||||||
${submenuItems}
|
${submenuItems}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('\n');
|
})
|
||||||
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成网站卡片HTML
|
// 生成网站卡片HTML
|
||||||
@@ -992,12 +1022,16 @@ function generateSiteCards(sites) {
|
|||||||
return `<p class="empty-sites">暂无网站</p>`;
|
return `<p class="empty-sites">暂无网站</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sites.map(site => `
|
return sites
|
||||||
|
.map(
|
||||||
|
(site) => `
|
||||||
<a href="${escapeHtml(site.url)}" class="site-card" title="${escapeHtml(site.name)} - ${escapeHtml(site.description || '')}">
|
<a href="${escapeHtml(site.url)}" class="site-card" title="${escapeHtml(site.name)} - ${escapeHtml(site.description || '')}">
|
||||||
<i class="${escapeHtml(site.icon || 'fas fa-link')}"></i>
|
<i class="${escapeHtml(site.icon || 'fas fa-link')}"></i>
|
||||||
<h3>${escapeHtml(site.name || '未命名站点')}</h3>
|
<h3>${escapeHtml(site.name || '未命名站点')}</h3>
|
||||||
<p>${escapeHtml(site.description || '')}</p>
|
<p>${escapeHtml(site.description || '')}</p>
|
||||||
</a>`).join('\n');
|
</a>`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成分类板块
|
// 生成分类板块
|
||||||
@@ -1010,13 +1044,17 @@ function generateCategories(categories) {
|
|||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return categories.map(category => `
|
return categories
|
||||||
|
.map(
|
||||||
|
(category) => `
|
||||||
<section class="category" id="${escapeHtml(category.name)}">
|
<section class="category" id="${escapeHtml(category.name)}">
|
||||||
<h2><i class="${escapeHtml(category.icon)}"></i> ${escapeHtml(category.name)}</h2>
|
<h2><i class="${escapeHtml(category.icon)}"></i> ${escapeHtml(category.name)}</h2>
|
||||||
<div class="sites-grid">
|
<div class="sites-grid">
|
||||||
${generateSiteCards(category.sites)}
|
${generateSiteCards(category.sites)}
|
||||||
</div>
|
</div>
|
||||||
</section>`).join('\n');
|
</section>`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成社交链接HTML
|
// 生成社交链接HTML
|
||||||
@@ -1040,11 +1078,15 @@ function generateSocialLinks(social) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 回退到原始生成方法
|
// 回退到原始生成方法
|
||||||
return social.map(link => `
|
return social
|
||||||
|
.map(
|
||||||
|
(link) => `
|
||||||
<a href="${escapeHtml(link.url)}" class="social-icon" target="_blank" rel="noopener" title="${escapeHtml(link.name || '社交链接')}" aria-label="${escapeHtml(link.name || '社交链接')}" data-type="social-link" data-name="${escapeHtml(link.name || '社交链接')}" data-url="${escapeHtml(link.url)}" data-icon="${escapeHtml(link.icon || 'fas fa-link')}">
|
<a href="${escapeHtml(link.url)}" class="social-icon" target="_blank" rel="noopener" title="${escapeHtml(link.name || '社交链接')}" aria-label="${escapeHtml(link.name || '社交链接')}" data-type="social-link" data-name="${escapeHtml(link.name || '社交链接')}" data-url="${escapeHtml(link.url)}" data-icon="${escapeHtml(link.icon || 'fas fa-link')}">
|
||||||
<i class="${escapeHtml(link.icon || 'fas fa-link')}" aria-hidden="true"></i>
|
<i class="${escapeHtml(link.icon || 'fas fa-link')}" aria-hidden="true"></i>
|
||||||
<span class="nav-text visually-hidden" data-editable="social-link-name">${escapeHtml(link.name || '社交链接')}</span>
|
<span class="nav-text visually-hidden" data-editable="social-link-name">${escapeHtml(link.name || '社交链接')}</span>
|
||||||
</a>`).join('\n');
|
</a>`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成页面内容(包括首页和其他页面)
|
// 生成页面内容(包括首页和其他页面)
|
||||||
@@ -1093,12 +1135,13 @@ ${generateCategories(data.categories)}`;
|
|||||||
// 生成搜索结果页面
|
// 生成搜索结果页面
|
||||||
function generateSearchResultsPage(config) {
|
function generateSearchResultsPage(config) {
|
||||||
// 获取所有导航页面ID
|
// 获取所有导航页面ID
|
||||||
const pageIds = config.navigation.map(nav => nav.id);
|
const pageIds = config.navigation.map((nav) => nav.id);
|
||||||
|
|
||||||
// 生成所有页面的搜索结果区域
|
// 生成所有页面的搜索结果区域
|
||||||
const searchSections = pageIds.map(pageId => {
|
const searchSections = pageIds
|
||||||
|
.map((pageId) => {
|
||||||
// 根据页面ID获取对应的图标和名称
|
// 根据页面ID获取对应的图标和名称
|
||||||
const navItem = config.navigation.find(nav => nav.id === pageId);
|
const navItem = config.navigation.find((nav) => nav.id === pageId);
|
||||||
const icon = navItem ? navItem.icon : 'fas fa-file';
|
const icon = navItem ? navItem.icon : 'fas fa-file';
|
||||||
const name = navItem ? navItem.name : pageId;
|
const name = navItem ? navItem.name : pageId;
|
||||||
|
|
||||||
@@ -1107,7 +1150,8 @@ function generateSearchResultsPage(config) {
|
|||||||
<h2><i class="${escapeHtml(icon)}"></i> ${escapeHtml(name)}匹配项</h2>
|
<h2><i class="${escapeHtml(icon)}"></i> ${escapeHtml(name)}匹配项</h2>
|
||||||
<div class="sites-grid"></div>
|
<div class="sites-grid"></div>
|
||||||
</section>`;
|
</section>`;
|
||||||
}).join('\n');
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<!-- 搜索结果页 -->
|
<!-- 搜索结果页 -->
|
||||||
@@ -1173,9 +1217,9 @@ function normalizeFontFamilyForCss(input) {
|
|||||||
|
|
||||||
return raw
|
return raw
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(part => part.trim())
|
.map((part) => part.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map(part => {
|
.map((part) => {
|
||||||
const unquoted = part.replace(/^['"]|['"]$/g, '').trim();
|
const unquoted = part.replace(/^['"]|['"]$/g, '').trim();
|
||||||
if (!unquoted) return '';
|
if (!unquoted) return '';
|
||||||
if (generics.has(unquoted)) return unquoted;
|
if (generics.has(unquoted)) return unquoted;
|
||||||
@@ -1190,14 +1234,15 @@ function normalizeFontFamilyForCss(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFontSource(input) {
|
function normalizeFontSource(input) {
|
||||||
const raw = String(input || '').trim().toLowerCase();
|
const raw = String(input || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
if (raw === 'css' || raw === 'google' || raw === 'system') return raw;
|
if (raw === 'css' || raw === 'google' || raw === 'system') return raw;
|
||||||
return 'system';
|
return 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNormalizedFontsConfig(config) {
|
function getNormalizedFontsConfig(config) {
|
||||||
const fonts =
|
const fonts = config && config.fonts && typeof config.fonts === 'object' ? config.fonts : {};
|
||||||
config && config.fonts && typeof config.fonts === 'object' ? config.fonts : {};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: normalizeFontSource(fonts.source),
|
source: normalizeFontSource(fonts.source),
|
||||||
@@ -1271,7 +1316,9 @@ function generateFontCss(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeGithubHeatmapColor(input) {
|
function normalizeGithubHeatmapColor(input) {
|
||||||
const raw = String(input || '').trim().replace(/^#/, '');
|
const raw = String(input || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^#/, '');
|
||||||
const color = raw.toLowerCase();
|
const color = raw.toLowerCase();
|
||||||
if (/^[0-9a-f]{6}$/.test(color)) return color;
|
if (/^[0-9a-f]{6}$/.test(color)) return color;
|
||||||
if (/^[0-9a-f]{3}$/.test(color)) return color;
|
if (/^[0-9a-f]{3}$/.test(color)) return color;
|
||||||
@@ -1279,7 +1326,8 @@ function normalizeGithubHeatmapColor(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getGithubUsernameFromConfig(config) {
|
function getGithubUsernameFromConfig(config) {
|
||||||
const username = config && config.site && config.site.github && config.site.github.username
|
const username =
|
||||||
|
config && config.site && config.site.github && config.site.github.username
|
||||||
? String(config.site.github.username).trim()
|
? String(config.site.github.username).trim()
|
||||||
: '';
|
: '';
|
||||||
return username;
|
return username;
|
||||||
@@ -1299,8 +1347,8 @@ function buildProjectsMeta(config) {
|
|||||||
heatmap: {
|
heatmap: {
|
||||||
username,
|
username,
|
||||||
profileUrl: `https://github.com/${username}`,
|
profileUrl: `https://github.com/${username}`,
|
||||||
imageUrl: `https://ghchart.rshah.org/${color}/${username}`
|
imageUrl: `https://ghchart.rshah.org/${color}/${username}`,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,7 +1363,7 @@ function renderPage(pageId, config) {
|
|||||||
const data = {
|
const data = {
|
||||||
...(config || {}),
|
...(config || {}),
|
||||||
currentPage: pageId,
|
currentPage: pageId,
|
||||||
pageId // 同时保留pageId字段,用于通用模板
|
pageId, // 同时保留pageId字段,用于通用模板
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确保navigation是数组
|
// 确保navigation是数组
|
||||||
@@ -1324,11 +1372,11 @@ function renderPage(pageId, config) {
|
|||||||
data.navigation = [];
|
data.navigation = [];
|
||||||
} else {
|
} else {
|
||||||
// 设置当前页面为活动页,其他页面为非活动
|
// 设置当前页面为活动页,其他页面为非活动
|
||||||
data.navigation = config.navigation.map(nav => {
|
data.navigation = config.navigation.map((nav) => {
|
||||||
const navItem = {
|
const navItem = {
|
||||||
...nav,
|
...nav,
|
||||||
isActive: nav.id === pageId,
|
isActive: nav.id === pageId,
|
||||||
active: nav.id === pageId // 兼容原有逻辑
|
active: nav.id === pageId, // 兼容原有逻辑
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用辅助函数获取子菜单
|
// 使用辅助函数获取子菜单
|
||||||
@@ -1355,7 +1403,9 @@ function renderPage(pageId, config) {
|
|||||||
|
|
||||||
// 页面配置缺失时也尽量给出可用的默认值,避免渲染空标题/undefined
|
// 页面配置缺失时也尽量给出可用的默认值,避免渲染空标题/undefined
|
||||||
if (data.title === undefined) {
|
if (data.title === undefined) {
|
||||||
const navItem = Array.isArray(config.navigation) ? config.navigation.find(nav => nav.id === pageId) : null;
|
const navItem = Array.isArray(config.navigation)
|
||||||
|
? config.navigation.find((nav) => nav.id === pageId)
|
||||||
|
: null;
|
||||||
if (navItem && navItem.name !== undefined) data.title = navItem.name;
|
if (navItem && navItem.name !== undefined) data.title = navItem.name;
|
||||||
}
|
}
|
||||||
if (data.subtitle === undefined) data.subtitle = '';
|
if (data.subtitle === undefined) data.subtitle = '';
|
||||||
@@ -1366,7 +1416,12 @@ function renderPage(pageId, config) {
|
|||||||
let templateName = explicitTemplate || pageId;
|
let templateName = explicitTemplate || pageId;
|
||||||
// 未显式指定模板时:若 pages/<pageId>.hbs 不存在,则默认使用通用 page 模板(避免依赖回退日志)
|
// 未显式指定模板时:若 pages/<pageId>.hbs 不存在,则默认使用通用 page 模板(避免依赖回退日志)
|
||||||
if (!explicitTemplate) {
|
if (!explicitTemplate) {
|
||||||
const inferredTemplatePath = path.join(process.cwd(), 'templates', 'pages', `${templateName}.hbs`);
|
const inferredTemplatePath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'templates',
|
||||||
|
'pages',
|
||||||
|
`${templateName}.hbs`
|
||||||
|
);
|
||||||
if (!fs.existsSync(inferredTemplatePath)) {
|
if (!fs.existsSync(inferredTemplatePath)) {
|
||||||
templateName = 'page';
|
templateName = 'page';
|
||||||
}
|
}
|
||||||
@@ -1388,17 +1443,19 @@ function renderPage(pageId, config) {
|
|||||||
// 注意:模板名可能被统一为 page(例如 friends/home 取消专属模板后),因此这里同时按 pageId 判断。
|
// 注意:模板名可能被统一为 page(例如 friends/home 取消专属模板后),因此这里同时按 pageId 判断。
|
||||||
const isFriendsPage = pageId === 'friends' || templateName === 'friends';
|
const isFriendsPage = pageId === 'friends' || templateName === 'friends';
|
||||||
const isArticlesPage = pageId === 'articles' || templateName === 'articles';
|
const isArticlesPage = pageId === 'articles' || templateName === 'articles';
|
||||||
if ((isFriendsPage || isArticlesPage)
|
if (
|
||||||
&& (!Array.isArray(data.categories) || data.categories.length === 0)
|
(isFriendsPage || isArticlesPage) &&
|
||||||
&& Array.isArray(data.sites)
|
(!Array.isArray(data.categories) || data.categories.length === 0) &&
|
||||||
&& data.sites.length > 0) {
|
Array.isArray(data.sites) &&
|
||||||
|
data.sites.length > 0
|
||||||
|
) {
|
||||||
const implicitName = isFriendsPage ? '全部友链' : '全部来源';
|
const implicitName = isFriendsPage ? '全部友链' : '全部来源';
|
||||||
data.categories = [
|
data.categories = [
|
||||||
{
|
{
|
||||||
name: implicitName,
|
name: implicitName,
|
||||||
icon: 'fas fa-link',
|
icon: 'fas fa-link',
|
||||||
sites: data.sites
|
sites: data.sites,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1422,9 +1479,10 @@ function renderPage(pageId, config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 首页标题规则:使用 site.yml 的 profile 覆盖首页(导航第一项)的 title/subtitle 显示
|
// 首页标题规则:使用 site.yml 的 profile 覆盖首页(导航第一项)的 title/subtitle 显示
|
||||||
const homePageId = config.homePageId
|
const homePageId =
|
||||||
|| (Array.isArray(config.navigation) && config.navigation[0] ? config.navigation[0].id : null)
|
config.homePageId ||
|
||||||
|| 'home';
|
(Array.isArray(config.navigation) && config.navigation[0] ? config.navigation[0].id : null) ||
|
||||||
|
'home';
|
||||||
// 供模板判断“当前是否首页”
|
// 供模板判断“当前是否首页”
|
||||||
data.homePageId = homePageId;
|
data.homePageId = homePageId;
|
||||||
if (pageId === homePageId && config.profile) {
|
if (pageId === homePageId && config.profile) {
|
||||||
@@ -1456,7 +1514,7 @@ function generateAllPagesHTML(config) {
|
|||||||
|
|
||||||
// 渲染配置中定义的所有页面
|
// 渲染配置中定义的所有页面
|
||||||
if (Array.isArray(config.navigation)) {
|
if (Array.isArray(config.navigation)) {
|
||||||
config.navigation.forEach(navItem => {
|
config.navigation.forEach((navItem) => {
|
||||||
const pageId = navItem.id;
|
const pageId = navItem.id;
|
||||||
|
|
||||||
// 渲染页面内容
|
// 渲染页面内容
|
||||||
@@ -1485,7 +1543,7 @@ function generateHTML(config) {
|
|||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
// 准备导航数据,添加submenu字段
|
// 准备导航数据,添加submenu字段
|
||||||
const navigationData = config.navigation.map(nav => {
|
const navigationData = config.navigation.map((nav) => {
|
||||||
const navItem = { ...nav };
|
const navItem = { ...nav };
|
||||||
|
|
||||||
// 使用辅助函数获取子菜单
|
// 使用辅助函数获取子菜单
|
||||||
@@ -1517,7 +1575,7 @@ function generateHTML(config) {
|
|||||||
social: Array.isArray(config.social) ? config.social : [], // 兼容旧版
|
social: Array.isArray(config.social) ? config.social : [], // 兼容旧版
|
||||||
|
|
||||||
// 确保配置数据可用于浏览器扩展
|
// 确保配置数据可用于浏览器扩展
|
||||||
configJSON: config.configJSON // 从prepareRenderData函数中获取的配置数据
|
configJSON: config.configJSON, // 从prepareRenderData函数中获取的配置数据
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1617,14 +1675,14 @@ function copyStaticFiles(config) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (config && Array.isArray(config.navigation)) {
|
if (config && Array.isArray(config.navigation)) {
|
||||||
config.navigation.forEach(navItem => {
|
config.navigation.forEach((navItem) => {
|
||||||
const pageId = navItem && navItem.id ? String(navItem.id) : '';
|
const pageId = navItem && navItem.id ? String(navItem.id) : '';
|
||||||
if (!pageId) return;
|
if (!pageId) return;
|
||||||
const pageConfig = config[pageId];
|
const pageConfig = config[pageId];
|
||||||
if (!pageConfig || typeof pageConfig !== 'object') return;
|
if (!pageConfig || typeof pageConfig !== 'object') return;
|
||||||
|
|
||||||
if (Array.isArray(pageConfig.sites)) {
|
if (Array.isArray(pageConfig.sites)) {
|
||||||
pageConfig.sites.forEach(site => {
|
pageConfig.sites.forEach((site) => {
|
||||||
if (!site || typeof site !== 'object') return;
|
if (!site || typeof site !== 'object') return;
|
||||||
copyLocalAsset(site.faviconUrl);
|
copyLocalAsset(site.faviconUrl);
|
||||||
});
|
});
|
||||||
@@ -1632,8 +1690,8 @@ function copyStaticFiles(config) {
|
|||||||
|
|
||||||
if (Array.isArray(pageConfig.categories)) {
|
if (Array.isArray(pageConfig.categories)) {
|
||||||
const sites = [];
|
const sites = [];
|
||||||
pageConfig.categories.forEach(category => collectSitesRecursively(category, sites));
|
pageConfig.categories.forEach((category) => collectSitesRecursively(category, sites));
|
||||||
sites.forEach(site => {
|
sites.forEach((site) => {
|
||||||
if (!site || typeof site !== 'object') return;
|
if (!site || typeof site !== 'object') return;
|
||||||
copyLocalAsset(site.faviconUrl);
|
copyLocalAsset(site.faviconUrl);
|
||||||
});
|
});
|
||||||
@@ -1648,7 +1706,10 @@ function copyStaticFiles(config) {
|
|||||||
if (config.site.favicon) {
|
if (config.site.favicon) {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(`assets/${config.site.favicon}`)) {
|
if (fs.existsSync(`assets/${config.site.favicon}`)) {
|
||||||
fs.copyFileSync(`assets/${config.site.favicon}`, `dist/${path.basename(config.site.favicon)}`);
|
fs.copyFileSync(
|
||||||
|
`assets/${config.site.favicon}`,
|
||||||
|
`dist/${path.basename(config.site.favicon)}`
|
||||||
|
);
|
||||||
} else if (fs.existsSync(config.site.favicon)) {
|
} else if (fs.existsSync(config.site.favicon)) {
|
||||||
fs.copyFileSync(config.site.favicon, `dist/${path.basename(config.site.favicon)}`);
|
fs.copyFileSync(config.site.favicon, `dist/${path.basename(config.site.favicon)}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -1700,5 +1761,5 @@ module.exports = {
|
|||||||
generateCategories,
|
generateCategories,
|
||||||
loadHandlebarsTemplates,
|
loadHandlebarsTemplates,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
generateAllPagesHTML
|
generateAllPagesHTML,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,25 +39,25 @@ function ifNotEquals(v1, v2, options) {
|
|||||||
function ifCond(v1, operator, v2, options) {
|
function ifCond(v1, operator, v2, options) {
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
case '==':
|
case '==':
|
||||||
return (v1 == v2) ? options.fn(this) : options.inverse(this);
|
return v1 == v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '===':
|
case '===':
|
||||||
return (v1 === v2) ? options.fn(this) : options.inverse(this);
|
return v1 === v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '!=':
|
case '!=':
|
||||||
return (v1 != v2) ? options.fn(this) : options.inverse(this);
|
return v1 != v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '!==':
|
case '!==':
|
||||||
return (v1 !== v2) ? options.fn(this) : options.inverse(this);
|
return v1 !== v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '<':
|
case '<':
|
||||||
return (v1 < v2) ? options.fn(this) : options.inverse(this);
|
return v1 < v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '<=':
|
case '<=':
|
||||||
return (v1 <= v2) ? options.fn(this) : options.inverse(this);
|
return v1 <= v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '>':
|
case '>':
|
||||||
return (v1 > v2) ? options.fn(this) : options.inverse(this);
|
return v1 > v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '>=':
|
case '>=':
|
||||||
return (v1 >= v2) ? options.fn(this) : options.inverse(this);
|
return v1 >= v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '&&':
|
case '&&':
|
||||||
return (v1 && v2) ? options.fn(this) : options.inverse(this);
|
return v1 && v2 ? options.fn(this) : options.inverse(this);
|
||||||
case '||':
|
case '||':
|
||||||
return (v1 || v2) ? options.fn(this) : options.inverse(this);
|
return v1 || v2 ? options.fn(this) : options.inverse(this);
|
||||||
default:
|
default:
|
||||||
return options.inverse(this);
|
return options.inverse(this);
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ function isEmpty(value, options) {
|
|||||||
function isNotEmpty(value, options) {
|
function isNotEmpty(value, options) {
|
||||||
return isEmpty(value, {
|
return isEmpty(value, {
|
||||||
fn: options.inverse,
|
fn: options.inverse,
|
||||||
inverse: options.fn
|
inverse: options.fn,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ function isNotEmpty(value, options) {
|
|||||||
* @example {{#and isPremium isActive}}高级活跃用户{{else}}其他用户{{/and}}
|
* @example {{#and isPremium isActive}}高级活跃用户{{else}}其他用户{{/and}}
|
||||||
*/
|
*/
|
||||||
function and(a, b, options) {
|
function and(a, b, options) {
|
||||||
return (a && b) ? options.fn(this) : options.inverse(this);
|
return a && b ? options.fn(this) : options.inverse(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,7 +125,7 @@ function and(a, b, options) {
|
|||||||
* @example {{#or isPremium isAdmin}}有权限{{else}}无权限{{/or}}
|
* @example {{#or isPremium isAdmin}}有权限{{else}}无权限{{/or}}
|
||||||
*/
|
*/
|
||||||
function or(a, b, options) {
|
function or(a, b, options) {
|
||||||
return (a || b) ? options.fn(this) : options.inverse(this);
|
return a || b ? options.fn(this) : options.inverse(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,5 +184,5 @@ module.exports = {
|
|||||||
or,
|
or,
|
||||||
orHelper,
|
orHelper,
|
||||||
not,
|
not,
|
||||||
ifHttpUrl
|
ifHttpUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,5 +123,5 @@ module.exports = {
|
|||||||
toLowerCase,
|
toLowerCase,
|
||||||
toUpperCase,
|
toUpperCase,
|
||||||
json,
|
json,
|
||||||
extractDomain
|
extractDomain,
|
||||||
};
|
};
|
||||||
@@ -29,20 +29,20 @@ function registerAllHelpers(handlebars) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 注册HTML转义函数(作为助手函数,方便在模板中调用)
|
// 注册HTML转义函数(作为助手函数,方便在模板中调用)
|
||||||
handlebars.registerHelper('escapeHtml', function(text) {
|
handlebars.registerHelper('escapeHtml', function (text) {
|
||||||
if (text === undefined || text === null) {
|
if (text === undefined || text === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return String(text)
|
return String(text)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, "<")
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, ''');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 注册非转义助手函数(安全输出HTML)
|
// 注册非转义助手函数(安全输出HTML)
|
||||||
handlebars.registerHelper('safeHtml', function(text) {
|
handlebars.registerHelper('safeHtml', function (text) {
|
||||||
if (text === undefined || text === null) {
|
if (text === undefined || text === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -55,5 +55,5 @@ module.exports = {
|
|||||||
formatters,
|
formatters,
|
||||||
conditions,
|
conditions,
|
||||||
utils,
|
utils,
|
||||||
registerAllHelpers
|
registerAllHelpers,
|
||||||
};
|
};
|
||||||
@@ -36,7 +36,7 @@ function concat() {
|
|||||||
const options = args.pop(); // 最后一个参数是Handlebars的options对象
|
const options = args.pop(); // 最后一个参数是Handlebars的options对象
|
||||||
|
|
||||||
// 过滤掉非数组参数
|
// 过滤掉非数组参数
|
||||||
const validArrays = args.filter(arg => Array.isArray(arg));
|
const validArrays = args.filter((arg) => Array.isArray(arg));
|
||||||
|
|
||||||
if (validArrays.length === 0) {
|
if (validArrays.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -143,7 +143,7 @@ function pick() {
|
|||||||
|
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
||||||
keys.forEach(key => {
|
keys.forEach((key) => {
|
||||||
if (obj.hasOwnProperty(key)) {
|
if (obj.hasOwnProperty(key)) {
|
||||||
result[key] = obj[key];
|
result[key] = obj[key];
|
||||||
}
|
}
|
||||||
@@ -274,15 +274,23 @@ function safeUrl(url, options) {
|
|||||||
options.data.root.site.security &&
|
options.data.root.site.security &&
|
||||||
options.data.root.site.security.allowedSchemes;
|
options.data.root.site.security.allowedSchemes;
|
||||||
|
|
||||||
const allowedSchemes = Array.isArray(allowedFromConfig) && allowedFromConfig.length > 0
|
const allowedSchemes =
|
||||||
|
Array.isArray(allowedFromConfig) && allowedFromConfig.length > 0
|
||||||
? allowedFromConfig
|
? allowedFromConfig
|
||||||
.map(s => String(s || '').trim().toLowerCase().replace(/:$/, ''))
|
.map((s) =>
|
||||||
|
String(s || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/:$/, '')
|
||||||
|
)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: ['http', 'https', 'mailto', 'tel'];
|
: ['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,
|
||||||
};
|
};
|
||||||
|
|||||||
317
src/script.js
317
src/script.js
@@ -25,10 +25,19 @@ function menavGetAllowedUrlSchemes() {
|
|||||||
? window.MeNav.getConfig()
|
? window.MeNav.getConfig()
|
||||||
: null;
|
: null;
|
||||||
const fromConfig =
|
const fromConfig =
|
||||||
cfg && cfg.data && cfg.data.site && cfg.data.site.security && cfg.data.site.security.allowedSchemes;
|
cfg &&
|
||||||
|
cfg.data &&
|
||||||
|
cfg.data.site &&
|
||||||
|
cfg.data.site.security &&
|
||||||
|
cfg.data.site.security.allowedSchemes;
|
||||||
if (Array.isArray(fromConfig) && fromConfig.length > 0) {
|
if (Array.isArray(fromConfig) && fromConfig.length > 0) {
|
||||||
return fromConfig
|
return fromConfig
|
||||||
.map(s => String(s || '').trim().toLowerCase().replace(/:$/, ''))
|
.map((s) =>
|
||||||
|
String(s || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/:$/, '')
|
||||||
|
)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -39,7 +48,13 @@ function menavGetAllowedUrlSchemes() {
|
|||||||
|
|
||||||
function menavIsRelativeUrl(url) {
|
function menavIsRelativeUrl(url) {
|
||||||
const s = String(url || '').trim();
|
const s = String(url || '').trim();
|
||||||
return s.startsWith('#') || s.startsWith('/') || s.startsWith('./') || s.startsWith('../') || s.startsWith('?');
|
return (
|
||||||
|
s.startsWith('#') ||
|
||||||
|
s.startsWith('/') ||
|
||||||
|
s.startsWith('./') ||
|
||||||
|
s.startsWith('../') ||
|
||||||
|
s.startsWith('?')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function menavSanitizeUrl(rawUrl, contextLabel) {
|
function menavSanitizeUrl(rawUrl, contextLabel) {
|
||||||
@@ -57,7 +72,9 @@ function menavSanitizeUrl(rawUrl, contextLabel) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
const scheme = String(parsed.protocol || '').toLowerCase().replace(/:$/, '');
|
const scheme = String(parsed.protocol || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/:$/, '');
|
||||||
const allowed = menavGetAllowedUrlSchemes();
|
const allowed = menavGetAllowedUrlSchemes();
|
||||||
if (allowed.includes(scheme)) return url;
|
if (allowed.includes(scheme)) return url;
|
||||||
console.warn(`[MeNav][安全] 已拦截不安全 URL scheme:${contextLabel || ''}`, url);
|
console.warn(`[MeNav][安全] 已拦截不安全 URL scheme:${contextLabel || ''}`, url);
|
||||||
@@ -76,9 +93,9 @@ function menavSanitizeClassList(rawClassList, contextLabel) {
|
|||||||
|
|
||||||
const tokens = input
|
const tokens = input
|
||||||
.split(/\s+/g)
|
.split(/\s+/g)
|
||||||
.map(t => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map(t => t.replace(/[^\w-]/g, ''))
|
.map((t) => t.replace(/[^\w-]/g, ''))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const sanitized = tokens.join(' ');
|
const sanitized = tokens.join(' ');
|
||||||
@@ -132,7 +149,7 @@ window.MeNav = {
|
|||||||
version: menavDetectVersion(),
|
version: menavDetectVersion(),
|
||||||
|
|
||||||
// 获取配置数据
|
// 获取配置数据
|
||||||
getConfig: function(options) {
|
getConfig: function (options) {
|
||||||
const configData = document.getElementById('menav-config-data');
|
const configData = document.getElementById('menav-config-data');
|
||||||
if (!configData) return null;
|
if (!configData) return null;
|
||||||
|
|
||||||
@@ -154,7 +171,7 @@ window.MeNav = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 获取元素的唯一标识符
|
// 获取元素的唯一标识符
|
||||||
_getElementId: function(element) {
|
_getElementId: function (element) {
|
||||||
const type = element.getAttribute('data-type');
|
const type = element.getAttribute('data-type');
|
||||||
if (type === 'nav-item') {
|
if (type === 'nav-item') {
|
||||||
return element.getAttribute('data-id');
|
return element.getAttribute('data-id');
|
||||||
@@ -167,7 +184,7 @@ window.MeNav = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 根据类型和ID查找元素
|
// 根据类型和ID查找元素
|
||||||
_findElement: function(type, id) {
|
_findElement: function (type, id) {
|
||||||
let selector;
|
let selector;
|
||||||
if (type === 'nav-item') {
|
if (type === 'nav-item') {
|
||||||
selector = `[data-type="${type}"][data-id="${id}"]`;
|
selector = `[data-type="${type}"][data-id="${id}"]`;
|
||||||
@@ -191,7 +208,7 @@ window.MeNav = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 更新DOM元素
|
// 更新DOM元素
|
||||||
updateElement: function(type, id, newData) {
|
updateElement: function (type, id, newData) {
|
||||||
const element = this._findElement(type, id);
|
const element = this._findElement(type, id);
|
||||||
if (!element) return false;
|
if (!element) return false;
|
||||||
|
|
||||||
@@ -231,10 +248,13 @@ window.MeNav = {
|
|||||||
|
|
||||||
if (nextIconClass) {
|
if (nextIconClass) {
|
||||||
iconElement.className = nextIconClass;
|
iconElement.className = nextIconClass;
|
||||||
preservedClasses.forEach(cls => iconElement.classList.add(cls));
|
preservedClasses.forEach((cls) => iconElement.classList.add(cls));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(site).data-icon'));
|
element.setAttribute(
|
||||||
|
'data-icon',
|
||||||
|
menavSanitizeClassList(newData.icon, 'updateElement(site).data-icon')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (newData.title) element.title = newData.title;
|
if (newData.title) element.title = newData.title;
|
||||||
|
|
||||||
@@ -242,7 +262,7 @@ window.MeNav = {
|
|||||||
this.events.emit('elementUpdated', {
|
this.events.emit('elementUpdated', {
|
||||||
id: id,
|
id: id,
|
||||||
type: 'site',
|
type: 'site',
|
||||||
data: newData
|
data: newData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -253,7 +273,10 @@ window.MeNav = {
|
|||||||
if (titleElement) {
|
if (titleElement) {
|
||||||
const iconElement = titleElement.querySelector('i');
|
const iconElement = titleElement.querySelector('i');
|
||||||
const iconClass = iconElement ? iconElement.className : '';
|
const iconClass = iconElement ? iconElement.className : '';
|
||||||
const nextIcon = menavSanitizeClassList(newData.icon || iconClass, 'updateElement(category).icon');
|
const nextIcon = menavSanitizeClassList(
|
||||||
|
newData.icon || iconClass,
|
||||||
|
'updateElement(category).icon'
|
||||||
|
);
|
||||||
|
|
||||||
// 用 DOM API 重建标题,避免 innerHTML 注入
|
// 用 DOM API 重建标题,避免 innerHTML 注入
|
||||||
titleElement.textContent = '';
|
titleElement.textContent = '';
|
||||||
@@ -265,14 +288,17 @@ window.MeNav = {
|
|||||||
element.setAttribute('data-name', newData.name);
|
element.setAttribute('data-name', newData.name);
|
||||||
}
|
}
|
||||||
if (newData.icon) {
|
if (newData.icon) {
|
||||||
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(category).data-icon'));
|
element.setAttribute(
|
||||||
|
'data-icon',
|
||||||
|
menavSanitizeClassList(newData.icon, 'updateElement(category).data-icon')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发元素更新事件
|
// 触发元素更新事件
|
||||||
this.events.emit('elementUpdated', {
|
this.events.emit('elementUpdated', {
|
||||||
id: id,
|
id: id,
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: newData
|
data: newData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -288,16 +314,22 @@ window.MeNav = {
|
|||||||
if (newData.icon) {
|
if (newData.icon) {
|
||||||
const iconElement = element.querySelector('i');
|
const iconElement = element.querySelector('i');
|
||||||
if (iconElement) {
|
if (iconElement) {
|
||||||
iconElement.className = menavSanitizeClassList(newData.icon, 'updateElement(nav-item).icon');
|
iconElement.className = menavSanitizeClassList(
|
||||||
|
newData.icon,
|
||||||
|
'updateElement(nav-item).icon'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(nav-item).data-icon'));
|
element.setAttribute(
|
||||||
|
'data-icon',
|
||||||
|
menavSanitizeClassList(newData.icon, 'updateElement(nav-item).data-icon')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发元素更新事件
|
// 触发元素更新事件
|
||||||
this.events.emit('elementUpdated', {
|
this.events.emit('elementUpdated', {
|
||||||
id: id,
|
id: id,
|
||||||
type: 'nav-item',
|
type: 'nav-item',
|
||||||
data: newData
|
data: newData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -319,16 +351,22 @@ window.MeNav = {
|
|||||||
if (newData.icon) {
|
if (newData.icon) {
|
||||||
const iconElement = element.querySelector('i');
|
const iconElement = element.querySelector('i');
|
||||||
if (iconElement) {
|
if (iconElement) {
|
||||||
iconElement.className = menavSanitizeClassList(newData.icon, 'updateElement(social-link).icon');
|
iconElement.className = menavSanitizeClassList(
|
||||||
|
newData.icon,
|
||||||
|
'updateElement(social-link).icon'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(social-link).data-icon'));
|
element.setAttribute(
|
||||||
|
'data-icon',
|
||||||
|
menavSanitizeClassList(newData.icon, 'updateElement(social-link).data-icon')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发元素更新事件
|
// 触发元素更新事件
|
||||||
this.events.emit('elementUpdated', {
|
this.events.emit('elementUpdated', {
|
||||||
id: id,
|
id: id,
|
||||||
type: 'social-link',
|
type: 'social-link',
|
||||||
data: newData
|
data: newData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -338,7 +376,7 @@ window.MeNav = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 添加新元素
|
// 添加新元素
|
||||||
addElement: function(type, parentId, data) {
|
addElement: function (type, parentId, data) {
|
||||||
let parent;
|
let parent;
|
||||||
|
|
||||||
if (type === 'site') {
|
if (type === 'site') {
|
||||||
@@ -381,7 +419,8 @@ window.MeNav = {
|
|||||||
const siteIcon = data.icon || 'fas fa-link';
|
const siteIcon = data.icon || 'fas fa-link';
|
||||||
const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : '');
|
const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : '');
|
||||||
const siteFaviconUrl = data && data.faviconUrl ? String(data.faviconUrl).trim() : '';
|
const siteFaviconUrl = data && data.faviconUrl ? String(data.faviconUrl).trim() : '';
|
||||||
const siteForceIconModeRaw = data && data.forceIconMode ? String(data.forceIconMode).trim() : '';
|
const siteForceIconModeRaw =
|
||||||
|
data && data.forceIconMode ? String(data.forceIconMode).trim() : '';
|
||||||
const siteForceIconMode =
|
const siteForceIconMode =
|
||||||
siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon'
|
siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon'
|
||||||
? siteForceIconModeRaw
|
? siteForceIconModeRaw
|
||||||
@@ -392,7 +431,10 @@ window.MeNav = {
|
|||||||
|
|
||||||
newSite.setAttribute('href', safeSiteUrl);
|
newSite.setAttribute('href', safeSiteUrl);
|
||||||
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
|
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
|
||||||
newSite.setAttribute('data-tooltip', siteName + (siteDescription ? ' - ' + siteDescription : '')); // 添加自定义 tooltip
|
newSite.setAttribute(
|
||||||
|
'data-tooltip',
|
||||||
|
siteName + (siteDescription ? ' - ' + siteDescription : '')
|
||||||
|
); // 添加自定义 tooltip
|
||||||
if (/^https?:\/\//i.test(safeSiteUrl)) {
|
if (/^https?:\/\//i.test(safeSiteUrl)) {
|
||||||
newSite.target = '_blank';
|
newSite.target = '_blank';
|
||||||
newSite.rel = 'noopener';
|
newSite.rel = 'noopener';
|
||||||
@@ -431,12 +473,7 @@ window.MeNav = {
|
|||||||
newSite.appendChild(repoHeader);
|
newSite.appendChild(repoHeader);
|
||||||
newSite.appendChild(repoDesc);
|
newSite.appendChild(repoDesc);
|
||||||
|
|
||||||
const hasStats =
|
const hasStats = data && (data.language || data.stars || data.forks || data.issues);
|
||||||
data &&
|
|
||||||
(data.language ||
|
|
||||||
data.stars ||
|
|
||||||
data.forks ||
|
|
||||||
data.issues);
|
|
||||||
|
|
||||||
if (hasStats) {
|
if (hasStats) {
|
||||||
const repoStats = document.createElement('div');
|
const repoStats = document.createElement('div');
|
||||||
@@ -571,12 +608,18 @@ window.MeNav = {
|
|||||||
iconContainer.appendChild(favicon);
|
iconContainer.appendChild(favicon);
|
||||||
iconContainer.appendChild(fallback);
|
iconContainer.appendChild(fallback);
|
||||||
iconWrapper.appendChild(iconContainer);
|
iconWrapper.appendChild(iconContainer);
|
||||||
} else if (effectiveIconsMode === 'favicon' && safeSiteUrl && /^https?:\/\//i.test(safeSiteUrl)) {
|
} else if (
|
||||||
|
effectiveIconsMode === 'favicon' &&
|
||||||
|
safeSiteUrl &&
|
||||||
|
/^https?:\/\//i.test(safeSiteUrl)
|
||||||
|
) {
|
||||||
// 根据 icons.region 配置决定优先使用哪个域名
|
// 根据 icons.region 配置决定优先使用哪个域名
|
||||||
const faviconUrlPrimary = iconsRegion === 'cn'
|
const faviconUrlPrimary =
|
||||||
|
iconsRegion === 'cn'
|
||||||
? `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`
|
? `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`
|
||||||
: `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`;
|
: `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`;
|
||||||
const faviconUrlFallback = iconsRegion === 'cn'
|
const faviconUrlFallback =
|
||||||
|
iconsRegion === 'cn'
|
||||||
? `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`
|
? `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`
|
||||||
: `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`;
|
: `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`;
|
||||||
|
|
||||||
@@ -655,8 +698,6 @@ window.MeNav = {
|
|||||||
// 添加到DOM
|
// 添加到DOM
|
||||||
sitesGrid.appendChild(newSite);
|
sitesGrid.appendChild(newSite);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 移除"暂无网站"提示(如果存在)
|
// 移除"暂无网站"提示(如果存在)
|
||||||
const emptyMessage = sitesGrid.querySelector('.empty-sites');
|
const emptyMessage = sitesGrid.querySelector('.empty-sites');
|
||||||
if (emptyMessage) {
|
if (emptyMessage) {
|
||||||
@@ -668,7 +709,7 @@ window.MeNav = {
|
|||||||
id: siteName,
|
id: siteName,
|
||||||
type: 'site',
|
type: 'site',
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
data: data
|
data: data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return siteName;
|
return siteName;
|
||||||
@@ -684,14 +725,20 @@ window.MeNav = {
|
|||||||
// 设置数据属性
|
// 设置数据属性
|
||||||
newCategory.setAttribute('data-type', 'category');
|
newCategory.setAttribute('data-type', 'category');
|
||||||
newCategory.setAttribute('data-name', data.name || '未命名分类');
|
newCategory.setAttribute('data-name', data.name || '未命名分类');
|
||||||
newCategory.setAttribute('data-icon', menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).data-icon'));
|
newCategory.setAttribute(
|
||||||
|
'data-icon',
|
||||||
|
menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).data-icon')
|
||||||
|
);
|
||||||
newCategory.setAttribute('data-container', 'categories');
|
newCategory.setAttribute('data-container', 'categories');
|
||||||
|
|
||||||
// 添加内容(用 DOM API 构建,避免 innerHTML 注入)
|
// 添加内容(用 DOM API 构建,避免 innerHTML 注入)
|
||||||
const titleEl = document.createElement('h2');
|
const titleEl = document.createElement('h2');
|
||||||
titleEl.setAttribute('data-editable', 'category-name');
|
titleEl.setAttribute('data-editable', 'category-name');
|
||||||
const iconEl = document.createElement('i');
|
const iconEl = document.createElement('i');
|
||||||
iconEl.className = menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).icon');
|
iconEl.className = menavSanitizeClassList(
|
||||||
|
data.icon || 'fas fa-folder',
|
||||||
|
'addElement(category).icon'
|
||||||
|
);
|
||||||
titleEl.appendChild(iconEl);
|
titleEl.appendChild(iconEl);
|
||||||
titleEl.appendChild(document.createTextNode(' ' + String(data.name || '未命名分类')));
|
titleEl.appendChild(document.createTextNode(' ' + String(data.name || '未命名分类')));
|
||||||
|
|
||||||
@@ -713,7 +760,7 @@ window.MeNav = {
|
|||||||
this.events.emit('elementAdded', {
|
this.events.emit('elementAdded', {
|
||||||
id: data.name,
|
id: data.name,
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: data
|
data: data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data.name;
|
return data.name;
|
||||||
@@ -723,7 +770,7 @@ window.MeNav = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 删除元素
|
// 删除元素
|
||||||
removeElement: function(type, id) {
|
removeElement: function (type, id) {
|
||||||
const element = this._findElement(type, id);
|
const element = this._findElement(type, id);
|
||||||
if (!element) return false;
|
if (!element) return false;
|
||||||
|
|
||||||
@@ -743,20 +790,20 @@ window.MeNav = {
|
|||||||
this.events.emit('elementRemoved', {
|
this.events.emit('elementRemoved', {
|
||||||
id: id,
|
id: id,
|
||||||
type: type,
|
type: type,
|
||||||
parentId: parentId
|
parentId: parentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取所有元素
|
// 获取所有元素
|
||||||
getAllElements: function(type) {
|
getAllElements: function (type) {
|
||||||
return Array.from(document.querySelectorAll(`[data-type="${type}"]`)).map(el => {
|
return Array.from(document.querySelectorAll(`[data-type="${type}"]`)).map((el) => {
|
||||||
const id = this._getElementId(el);
|
const id = this._getElementId(el);
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
type: type,
|
type: type,
|
||||||
element: el
|
element: el,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -766,7 +813,7 @@ window.MeNav = {
|
|||||||
listeners: {},
|
listeners: {},
|
||||||
|
|
||||||
// 添加事件监听器
|
// 添加事件监听器
|
||||||
on: function(event, callback) {
|
on: function (event, callback) {
|
||||||
if (!this.listeners[event]) {
|
if (!this.listeners[event]) {
|
||||||
this.listeners[event] = [];
|
this.listeners[event] = [];
|
||||||
}
|
}
|
||||||
@@ -775,25 +822,25 @@ window.MeNav = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 触发事件
|
// 触发事件
|
||||||
emit: function(event, data) {
|
emit: function (event, data) {
|
||||||
if (this.listeners[event]) {
|
if (this.listeners[event]) {
|
||||||
this.listeners[event].forEach(callback => callback(data));
|
this.listeners[event].forEach((callback) => callback(data));
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 移除事件监听器
|
// 移除事件监听器
|
||||||
off: function(event, callback) {
|
off: function (event, callback) {
|
||||||
if (this.listeners[event]) {
|
if (this.listeners[event]) {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
|
this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback);
|
||||||
} else {
|
} else {
|
||||||
delete this.listeners[event];
|
delete this.listeners[event];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 多层级嵌套书签功能
|
// 多层级嵌套书签功能
|
||||||
@@ -802,7 +849,9 @@ function getCollapsibleNestedContainers(root) {
|
|||||||
const headers = root.querySelectorAll(
|
const headers = root.querySelectorAll(
|
||||||
'.category > .category-header[data-toggle="category"], .group > .group-header[data-toggle="group"]'
|
'.category > .category-header[data-toggle="category"], .group > .group-header[data-toggle="group"]'
|
||||||
);
|
);
|
||||||
return Array.from(headers).map(header => header.parentElement).filter(Boolean);
|
return Array.from(headers)
|
||||||
|
.map((header) => header.parentElement)
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNestedContainerCollapsible(container) {
|
function isNestedContainerCollapsible(container) {
|
||||||
@@ -819,20 +868,20 @@ function isNestedContainerCollapsible(container) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.MeNav.expandAll = function() {
|
window.MeNav.expandAll = function () {
|
||||||
const activePage = document.querySelector('.page.active');
|
const activePage = document.querySelector('.page.active');
|
||||||
if (activePage) {
|
if (activePage) {
|
||||||
getCollapsibleNestedContainers(activePage).forEach(element => {
|
getCollapsibleNestedContainers(activePage).forEach((element) => {
|
||||||
element.classList.remove('collapsed');
|
element.classList.remove('collapsed');
|
||||||
saveToggleState(element, 'expanded');
|
saveToggleState(element, 'expanded');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.MeNav.collapseAll = function() {
|
window.MeNav.collapseAll = function () {
|
||||||
const activePage = document.querySelector('.page.active');
|
const activePage = document.querySelector('.page.active');
|
||||||
if (activePage) {
|
if (activePage) {
|
||||||
getCollapsibleNestedContainers(activePage).forEach(element => {
|
getCollapsibleNestedContainers(activePage).forEach((element) => {
|
||||||
element.classList.add('collapsed');
|
element.classList.add('collapsed');
|
||||||
saveToggleState(element, 'collapsed');
|
saveToggleState(element, 'collapsed');
|
||||||
});
|
});
|
||||||
@@ -840,12 +889,14 @@ window.MeNav.collapseAll = function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 智能切换分类展开/收起状态
|
// 智能切换分类展开/收起状态
|
||||||
window.MeNav.toggleCategories = function() {
|
window.MeNav.toggleCategories = function () {
|
||||||
const activePage = document.querySelector('.page.active');
|
const activePage = document.querySelector('.page.active');
|
||||||
if (!activePage) return;
|
if (!activePage) return;
|
||||||
|
|
||||||
const allElements = getCollapsibleNestedContainers(activePage);
|
const allElements = getCollapsibleNestedContainers(activePage);
|
||||||
const collapsedElements = allElements.filter(element => element.classList.contains('collapsed'));
|
const collapsedElements = allElements.filter((element) =>
|
||||||
|
element.classList.contains('collapsed')
|
||||||
|
);
|
||||||
if (allElements.length === 0) return;
|
if (allElements.length === 0) return;
|
||||||
|
|
||||||
// 如果收起的数量 >= 总数的一半,执行展开;否则执行收起
|
// 如果收起的数量 >= 总数的一半,执行展开;否则执行收起
|
||||||
@@ -875,7 +926,12 @@ function updateCategoryToggleIcon(state) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.MeNav.toggleCategory = function(categoryName, subcategoryName = null, groupName = null, subgroupName = null) {
|
window.MeNav.toggleCategory = function (
|
||||||
|
categoryName,
|
||||||
|
subcategoryName = null,
|
||||||
|
groupName = null,
|
||||||
|
subgroupName = null
|
||||||
|
) {
|
||||||
let selector = `[data-name="${categoryName}"]`;
|
let selector = `[data-name="${categoryName}"]`;
|
||||||
|
|
||||||
if (subcategoryName) selector += ` [data-name="${subcategoryName}"]`;
|
if (subcategoryName) selector += ` [data-name="${subcategoryName}"]`;
|
||||||
@@ -888,10 +944,10 @@ window.MeNav.toggleCategory = function(categoryName, subcategoryName = null, gro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.MeNav.getNestedStructure = function() {
|
window.MeNav.getNestedStructure = function () {
|
||||||
// 返回完整的嵌套结构数据
|
// 返回完整的嵌套结构数据
|
||||||
const categories = [];
|
const categories = [];
|
||||||
document.querySelectorAll('.category-level-1').forEach(cat => {
|
document.querySelectorAll('.category-level-1').forEach((cat) => {
|
||||||
categories.push(extractNestedData(cat));
|
categories.push(extractNestedData(cat));
|
||||||
});
|
});
|
||||||
return categories;
|
return categories;
|
||||||
@@ -916,8 +972,8 @@ function toggleNestedElement(container) {
|
|||||||
element: container,
|
element: container,
|
||||||
type: container.dataset.type,
|
type: container.dataset.type,
|
||||||
name: container.dataset.name,
|
name: container.dataset.name,
|
||||||
isCollapsed: !isCollapsed
|
isCollapsed: !isCollapsed,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
@@ -947,8 +1003,8 @@ function restoreToggleState(element) {
|
|||||||
// 初始化嵌套分类
|
// 初始化嵌套分类
|
||||||
function initializeNestedCategories() {
|
function initializeNestedCategories() {
|
||||||
// 为所有可折叠元素添加切换功能
|
// 为所有可折叠元素添加切换功能
|
||||||
document.querySelectorAll('[data-toggle="category"], [data-toggle="group"]').forEach(header => {
|
document.querySelectorAll('[data-toggle="category"], [data-toggle="group"]').forEach((header) => {
|
||||||
header.addEventListener('click', function(e) {
|
header.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const container = this.parentElement;
|
const container = this.parentElement;
|
||||||
toggleNestedElement(container);
|
toggleNestedElement(container);
|
||||||
@@ -965,32 +1021,40 @@ function extractNestedData(element) {
|
|||||||
name: element.dataset.name,
|
name: element.dataset.name,
|
||||||
type: element.dataset.type,
|
type: element.dataset.type,
|
||||||
level: element.dataset.level,
|
level: element.dataset.level,
|
||||||
isCollapsed: element.classList.contains('collapsed')
|
isCollapsed: element.classList.contains('collapsed'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 提取子元素数据
|
// 提取子元素数据
|
||||||
const subcategories = element.querySelectorAll(':scope > .category-content > .subcategories-container > .category');
|
const subcategories = element.querySelectorAll(
|
||||||
|
':scope > .category-content > .subcategories-container > .category'
|
||||||
|
);
|
||||||
if (subcategories.length > 0) {
|
if (subcategories.length > 0) {
|
||||||
data.subcategories = Array.from(subcategories).map(sub => extractNestedData(sub));
|
data.subcategories = Array.from(subcategories).map((sub) => extractNestedData(sub));
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = element.querySelectorAll(':scope > .category-content > .groups-container > .group');
|
const groups = element.querySelectorAll(
|
||||||
|
':scope > .category-content > .groups-container > .group'
|
||||||
|
);
|
||||||
if (groups.length > 0) {
|
if (groups.length > 0) {
|
||||||
data.groups = Array.from(groups).map(group => extractNestedData(group));
|
data.groups = Array.from(groups).map((group) => extractNestedData(group));
|
||||||
}
|
}
|
||||||
|
|
||||||
const subgroups = element.querySelectorAll(':scope > .group-content > .subgroups-container > .group');
|
const subgroups = element.querySelectorAll(
|
||||||
|
':scope > .group-content > .subgroups-container > .group'
|
||||||
|
);
|
||||||
if (subgroups.length > 0) {
|
if (subgroups.length > 0) {
|
||||||
data.subgroups = Array.from(subgroups).map(subgroup => extractNestedData(subgroup));
|
data.subgroups = Array.from(subgroups).map((subgroup) => extractNestedData(subgroup));
|
||||||
}
|
}
|
||||||
|
|
||||||
const sites = element.querySelectorAll(':scope > .category-content > .sites-grid > .site-card, :scope > .group-content > .sites-grid > .site-card');
|
const sites = element.querySelectorAll(
|
||||||
|
':scope > .category-content > .sites-grid > .site-card, :scope > .group-content > .sites-grid > .site-card'
|
||||||
|
);
|
||||||
if (sites.length > 0) {
|
if (sites.length > 0) {
|
||||||
data.sites = Array.from(sites).map(site => ({
|
data.sites = Array.from(sites).map((site) => ({
|
||||||
name: site.dataset.name,
|
name: site.dataset.name,
|
||||||
url: site.dataset.url,
|
url: site.dataset.url,
|
||||||
icon: site.dataset.icon,
|
icon: site.dataset.icon,
|
||||||
description: site.dataset.description
|
description: site.dataset.description,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1004,14 +1068,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const homePageId = (() => {
|
const homePageId = (() => {
|
||||||
// 1) 优先从生成端注入的配置数据读取(保持与实际导航顺序一致)
|
// 1) 优先从生成端注入的配置数据读取(保持与实际导航顺序一致)
|
||||||
try {
|
try {
|
||||||
const config = window.MeNav && typeof window.MeNav.getConfig === 'function'
|
const config =
|
||||||
|
window.MeNav && typeof window.MeNav.getConfig === 'function'
|
||||||
? window.MeNav.getConfig()
|
? window.MeNav.getConfig()
|
||||||
: null;
|
: null;
|
||||||
const injectedHomePageId = config && config.data && config.data.homePageId
|
const injectedHomePageId =
|
||||||
|
config && config.data && config.data.homePageId
|
||||||
? String(config.data.homePageId).trim()
|
? String(config.data.homePageId).trim()
|
||||||
: '';
|
: '';
|
||||||
if (injectedHomePageId) return injectedHomePageId;
|
if (injectedHomePageId) return injectedHomePageId;
|
||||||
const nav = config && config.data && Array.isArray(config.data.navigation)
|
const nav =
|
||||||
|
config && config.data && Array.isArray(config.data.navigation)
|
||||||
? config.data.navigation
|
? config.data.navigation
|
||||||
: null;
|
: null;
|
||||||
const firstId = nav && nav[0] && nav[0].id ? String(nav[0].id).trim() : '';
|
const firstId = nav && nav[0] && nav[0].id ? String(nav[0].id).trim() : '';
|
||||||
@@ -1046,7 +1113,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// 搜索索引,用于提高搜索效率
|
// 搜索索引,用于提高搜索效率
|
||||||
let searchIndex = {
|
let searchIndex = {
|
||||||
initialized: false,
|
initialized: false,
|
||||||
items: []
|
items: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 搜索引擎配置
|
// 搜索引擎配置
|
||||||
@@ -1054,25 +1121,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
local: {
|
local: {
|
||||||
name: '本地搜索',
|
name: '本地搜索',
|
||||||
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" focusable="false"><path fill="#616161" d="M29.171,32.001L32,29.172l12.001,12l-2.828,2.828L29.171,32.001z"></path><path fill="#616161" d="M36,20c0,8.837-7.163,16-16,16S4,28.837,4,20S11.163,4,20,4S36,11.163,36,20"></path><path fill="#37474f" d="M32.476,35.307l2.828-2.828l8.693,8.693L41.17,44L32.476,35.307z"></path><path fill="#64b5f6" d="M7,20c0-7.18,5.82-13,13-13s13,5.82,13,13s-5.82,13-13,13S7,27.18,7,20"></path></svg>`,
|
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" focusable="false"><path fill="#616161" d="M29.171,32.001L32,29.172l12.001,12l-2.828,2.828L29.171,32.001z"></path><path fill="#616161" d="M36,20c0,8.837-7.163,16-16,16S4,28.837,4,20S11.163,4,20,4S36,11.163,36,20"></path><path fill="#37474f" d="M32.476,35.307l2.828-2.828l8.693,8.693L41.17,44L32.476,35.307z"></path><path fill="#64b5f6" d="M7,20c0-7.18,5.82-13,13-13s13,5.82,13,13s-5.82,13-13,13S7,27.18,7,20"></path></svg>`,
|
||||||
url: null // 本地搜索不需要URL
|
url: null, // 本地搜索不需要URL
|
||||||
},
|
},
|
||||||
google: {
|
google: {
|
||||||
name: 'Google搜索',
|
name: 'Google搜索',
|
||||||
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" focusable="false"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path></svg>`,
|
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" focusable="false"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path></svg>`,
|
||||||
url: 'https://www.google.com/search?q='
|
url: 'https://www.google.com/search?q=',
|
||||||
},
|
},
|
||||||
bing: {
|
bing: {
|
||||||
name: 'Bing搜索',
|
name: 'Bing搜索',
|
||||||
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" focusable="false"><g fill="#2877fb" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M45,26.10156v-5.10156c0,-0.89844 -0.60156,-1.69922 -1.39844,-1.89844l-4.60156,-1.40234c-5.30078,-1.59766 -10.30078,-3 -15.60156,-4.69922h-0.09766c-0.80078,-0.19922 -1.60156,0.69922 -1.19922,1.5c1.89844,3.89844 3.89844,9.5 3.89844,9.5l6.69922,2.60156c-0.30078,0 -21.69922,11.39844 -21.69922,11.39844l9,-8v-23c0,-0.89844 -0.60156,-1.80078 -1.39844,-2c0,0 -4.90234,-1.89844 -8,-2.89844c-0.20312,-0.10156 -0.40234,-0.10156 -0.60156,-0.10156c-0.39844,0 -0.80078,0.10156 -1.19922,0.39844c-0.5,0.40234 -0.80078,1 -0.80078,1.60156v34.69922c0,0.69922 0.30078,1.30078 0.89844,1.60156c2.10156,1.5 4.30078,3 6.40234,4.5l3,2.09766c0.30078,0.20313 0.69922,0.40234 1.09766,0.40234c0.40234,0 0.70313,-0.10156 1,-0.30078c4.30078,-2.60156 8.70313,-5.19922 13,-7.80078l10.60156,-6.30078c0.60156,-0.39844 1,-1 1,-1.69922z"></path></g></g></svg>`,
|
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" focusable="false"><g fill="#2877fb" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M45,26.10156v-5.10156c0,-0.89844 -0.60156,-1.69922 -1.39844,-1.89844l-4.60156,-1.40234c-5.30078,-1.59766 -10.30078,-3 -15.60156,-4.69922h-0.09766c-0.80078,-0.19922 -1.60156,0.69922 -1.19922,1.5c1.89844,3.89844 3.89844,9.5 3.89844,9.5l6.69922,2.60156c-0.30078,0 -21.69922,11.39844 -21.69922,11.39844l9,-8v-23c0,-0.89844 -0.60156,-1.80078 -1.39844,-2c0,0 -4.90234,-1.89844 -8,-2.89844c-0.20312,-0.10156 -0.40234,-0.10156 -0.60156,-0.10156c-0.39844,0 -0.80078,0.10156 -1.19922,0.39844c-0.5,0.40234 -0.80078,1 -0.80078,1.60156v34.69922c0,0.69922 0.30078,1.30078 0.89844,1.60156c2.10156,1.5 4.30078,3 6.40234,4.5l3,2.09766c0.30078,0.20313 0.69922,0.40234 1.09766,0.40234c0.40234,0 0.70313,-0.10156 1,-0.30078c4.30078,-2.60156 8.70313,-5.19922 13,-7.80078l10.60156,-6.30078c0.60156,-0.39844 1,-1 1,-1.69922z"></path></g></g></svg>`,
|
||||||
url: 'https://www.bing.com/search?q='
|
url: 'https://www.bing.com/search?q=',
|
||||||
},
|
},
|
||||||
duckduckgo: {
|
duckduckgo: {
|
||||||
name: 'DuckDuckGo搜索',
|
name: 'DuckDuckGo搜索',
|
||||||
shortName: 'duckgo',
|
shortName: 'duckgo',
|
||||||
// DuckDuckGo 使用内联 SVG,避免依赖不存在的 Font Awesome 品牌图标
|
// DuckDuckGo 使用内联 SVG,避免依赖不存在的 Font Awesome 品牌图标
|
||||||
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="4 4 40 40" focusable="false"><path fill="#ff3d00" d="M44,24c0,11-9,20-20,20S4,35,4,24S13,4,24,4S44,13,44,24z"></path><path fill="#fff" d="M26,16.2c-0.6-0.6-1.5-0.9-2.5-1.1c-0.4-0.5-1-1-1.9-1.5c-1.6-0.8-3.5-1.2-5.3-0.9h-0.4 c-0.1,0-0.2,0.1-0.4,0.1c0.2,0,1,0.4,1.6,0.6c-0.3,0.2-0.8,0.2-1.1,0.4c0,0,0,0-0.1,0L15.7,14c-0.1,0.2-0.2,0.4-0.2,0.5 c1.3-0.1,3.2,0,4.6,0.4C19,15,18,15.3,17.3,15.7c-0.5,0.3-1,0.6-1.3,1.1c-1.2,1.3-1.7,3.5-1.3,5.9c0.5,2.7,2.4,11.4,3.4,16.3 l0.3,1.6c0,0,3.5,0.4,5.6,0.4c1.2,0,3.2,0.3,3.7-0.2c-0.1,0-0.6-0.6-0.8-1.1c-0.5-1-1-1.9-1.4-2.6c-1.2-2.5-2.5-5.9-1.9-8.1 c0.1-0.4,0.1-2.1,0.4-2.3c2.6-1.7,2.4-0.1,3.5-0.8c0.5-0.4,1-0.9,1.2-1.5C29.4,22.1,27.8,18,26,16.2z"></path><path fill="#fff" d="M24,42c-9.9,0-18-8.1-18-18c0-9.9,8.1-18,18-18c9.9,0,18,8.1,18,18C42,33.9,33.9,42,24,42z M24,8 C15.2,8,8,15.2,8,24s7.2,16,16,16s16-7.2,16-16S32.8,8,24,8z"></path><path fill="#0277bd" d="M19,21.1c-0.6,0-1.2,0.5-1.2,1.2c0,0.6,0.5,1.2,1.2,1.2c0.6,0,1.2-0.5,1.2-1.2 C20.1,21.7,19.6,21.1,19,21.1z M19.5,22.2c-0.2,0-0.3-0.1-0.3-0.3c0-0.2,0.1-0.3,0.3-0.3s0.3,0.1,0.3,0.3 C19.8,22.1,19.6,22.2,19.5,22.2z M26.8,20.6c-0.6,0-1,0.5-1,1c0,0.6,0.5,1,1,1c0.6,0,1-0.5,1-1S27.3,20.6,26.8,20.6z M27.2,21.5 c-0.1,0-0.3-0.1-0.3-0.3c0-0.1,0.1-0.3,0.3-0.3c0.1,0,0.3,0.1,0.3,0.3S27.4,21.5,27.2,21.5z M19.3,18.9c0,0-0.9-0.4-1.7,0.1 c-0.9,0.5-0.8,1.1-0.8,1.1s-0.5-1,0.8-1.5C18.7,18.1,19.3,18.9,19.3,18.9 M27.4,18.8c0,0-0.6-0.4-1.1-0.4c-1,0-1.3,0.5-1.3,0.5 s0.2-1.1,1.5-0.9C27.1,18.2,27.4,18.8,27.4,18.8"></path><path fill="#8bc34a" d="M23.3,35.7c0,0-4.3-2.3-4.4-1.4c-0.1,0.9,0,4.7,0.5,5s4.1-1.9,4.1-1.9L23.3,35.7z M25,35.6 c0,0,2.9-2.2,3.6-2.1c0.6,0.1,0.8,4.7,0.2,4.9c-0.6,0.2-3.9-1.2-3.9-1.2L25,35.6z"></path><path fill="#689f38" d="M22.5,35.7c0,1.5-0.2,2.1,0.4,2.3c0.6,0.1,1.9,0,2.3-0.3c0.4-0.3,0.1-2.2-0.1-2.6 C25,34.8,22.5,35.1,22.5,35.7"></path><path fill="#ffca28" d="M22.3,26.8c0.1-0.7,2-2.1,3.3-2.2c1.3-0.1,1.7-0.1,2.8-0.3c1.1-0.3,3.9-1,4.7-1.3 c0.8-0.4,4.1,0.2,1.8,1.5c-1,0.6-3.7,1.6-5.7,2.2c-1.9,0.6-3.1-0.6-3.8,0.4c-0.5,0.8-0.1,1.8,2.2,2c3.1,0.3,6.2-1.4,6.5-0.5 c0.3,0.9-2.7,2-4.6,2.1c-1.8,0-5.6-1.2-6.1-1.6C22.9,28.7,22.2,27.8,22.3,26.8"></path></svg>`,
|
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="4 4 40 40" focusable="false"><path fill="#ff3d00" d="M44,24c0,11-9,20-20,20S4,35,4,24S13,4,24,4S44,13,44,24z"></path><path fill="#fff" d="M26,16.2c-0.6-0.6-1.5-0.9-2.5-1.1c-0.4-0.5-1-1-1.9-1.5c-1.6-0.8-3.5-1.2-5.3-0.9h-0.4 c-0.1,0-0.2,0.1-0.4,0.1c0.2,0,1,0.4,1.6,0.6c-0.3,0.2-0.8,0.2-1.1,0.4c0,0,0,0-0.1,0L15.7,14c-0.1,0.2-0.2,0.4-0.2,0.5 c1.3-0.1,3.2,0,4.6,0.4C19,15,18,15.3,17.3,15.7c-0.5,0.3-1,0.6-1.3,1.1c-1.2,1.3-1.7,3.5-1.3,5.9c0.5,2.7,2.4,11.4,3.4,16.3 l0.3,1.6c0,0,3.5,0.4,5.6,0.4c1.2,0,3.2,0.3,3.7-0.2c-0.1,0-0.6-0.6-0.8-1.1c-0.5-1-1-1.9-1.4-2.6c-1.2-2.5-2.5-5.9-1.9-8.1 c0.1-0.4,0.1-2.1,0.4-2.3c2.6-1.7,2.4-0.1,3.5-0.8c0.5-0.4,1-0.9,1.2-1.5C29.4,22.1,27.8,18,26,16.2z"></path><path fill="#fff" d="M24,42c-9.9,0-18-8.1-18-18c0-9.9,8.1-18,18-18c9.9,0,18,8.1,18,18C42,33.9,33.9,42,24,42z M24,8 C15.2,8,8,15.2,8,24s7.2,16,16,16s16-7.2,16-16S32.8,8,24,8z"></path><path fill="#0277bd" d="M19,21.1c-0.6,0-1.2,0.5-1.2,1.2c0,0.6,0.5,1.2,1.2,1.2c0.6,0,1.2-0.5,1.2-1.2 C20.1,21.7,19.6,21.1,19,21.1z M19.5,22.2c-0.2,0-0.3-0.1-0.3-0.3c0-0.2,0.1-0.3,0.3-0.3s0.3,0.1,0.3,0.3 C19.8,22.1,19.6,22.2,19.5,22.2z M26.8,20.6c-0.6,0-1,0.5-1,1c0,0.6,0.5,1,1,1c0.6,0,1-0.5,1-1S27.3,20.6,26.8,20.6z M27.2,21.5 c-0.1,0-0.3-0.1-0.3-0.3c0-0.1,0.1-0.3,0.3-0.3c0.1,0,0.3,0.1,0.3,0.3S27.4,21.5,27.2,21.5z M19.3,18.9c0,0-0.9-0.4-1.7,0.1 c-0.9,0.5-0.8,1.1-0.8,1.1s-0.5-1,0.8-1.5C18.7,18.1,19.3,18.9,19.3,18.9 M27.4,18.8c0,0-0.6-0.4-1.1-0.4c-1,0-1.3,0.5-1.3,0.5 s0.2-1.1,1.5-0.9C27.1,18.2,27.4,18.8,27.4,18.8"></path><path fill="#8bc34a" d="M23.3,35.7c0,0-4.3-2.3-4.4-1.4c-0.1,0.9,0,4.7,0.5,5s4.1-1.9,4.1-1.9L23.3,35.7z M25,35.6 c0,0,2.9-2.2,3.6-2.1c0.6,0.1,0.8,4.7,0.2,4.9c-0.6,0.2-3.9-1.2-3.9-1.2L25,35.6z"></path><path fill="#689f38" d="M22.5,35.7c0,1.5-0.2,2.1,0.4,2.3c0.6,0.1,1.9,0,2.3-0.3c0.4-0.3,0.1-2.2-0.1-2.6 C25,34.8,22.5,35.1,22.5,35.7"></path><path fill="#ffca28" d="M22.3,26.8c0.1-0.7,2-2.1,3.3-2.2c1.3-0.1,1.7-0.1,2.8-0.3c1.1-0.3,3.9-1,4.7-1.3 c0.8-0.4,4.1,0.2,1.8,1.5c-1,0.6-3.7,1.6-5.7,2.2c-1.9,0.6-3.1-0.6-3.8,0.4c-0.5,0.8-0.1,1.8,2.2,2c3.1,0.3,6.2-1.4,6.5-0.5 c0.3,0.9-2.7,2-4.6,2.1c-1.8,0-5.6-1.2-6.1-1.6C22.9,28.7,22.2,27.8,22.3,26.8"></path></svg>`,
|
||||||
url: 'https://duckduckgo.com/?q='
|
url: 'https://duckduckgo.com/?q=',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取DOM元素 - 基本元素
|
// 获取DOM元素 - 基本元素
|
||||||
@@ -1217,19 +1284,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
pages = document.querySelectorAll('.page');
|
pages = document.querySelectorAll('.page');
|
||||||
}
|
}
|
||||||
|
|
||||||
pages.forEach(page => {
|
pages.forEach((page) => {
|
||||||
if (page.id === 'search-results') return;
|
if (page.id === 'search-results') return;
|
||||||
|
|
||||||
const pageId = page.id;
|
const pageId = page.id;
|
||||||
|
|
||||||
page.querySelectorAll('.site-card').forEach(card => {
|
page.querySelectorAll('.site-card').forEach((card) => {
|
||||||
try {
|
try {
|
||||||
// 排除“扩展写回影子结构”等不应参与搜索的卡片
|
// 排除“扩展写回影子结构”等不应参与搜索的卡片
|
||||||
if (card.closest('[data-search-exclude="true"]')) return;
|
if (card.closest('[data-search-exclude="true"]')) return;
|
||||||
|
|
||||||
// 兼容不同页面/卡片样式:优先取可见文本,其次回退到 data-*(确保 projects repo 卡片也能被搜索)
|
// 兼容不同页面/卡片样式:优先取可见文本,其次回退到 data-*(确保 projects repo 卡片也能被搜索)
|
||||||
const dataTitle = card.dataset?.name || card.getAttribute('data-name') || '';
|
const dataTitle = card.dataset?.name || card.getAttribute('data-name') || '';
|
||||||
const dataDescription = card.dataset?.description || card.getAttribute('data-description') || '';
|
const dataDescription =
|
||||||
|
card.dataset?.description || card.getAttribute('data-description') || '';
|
||||||
|
|
||||||
const titleText =
|
const titleText =
|
||||||
card.querySelector('h3')?.textContent ||
|
card.querySelector('h3')?.textContent ||
|
||||||
@@ -1243,7 +1311,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const title = String(titleText || '').toLowerCase();
|
const title = String(titleText || '').toLowerCase();
|
||||||
const description = String(descriptionText || '').toLowerCase();
|
const description = String(descriptionText || '').toLowerCase();
|
||||||
const url = card.href || card.getAttribute('href') || '#';
|
const url = card.href || card.getAttribute('href') || '#';
|
||||||
const icon = card.querySelector('i.icon-fallback')?.className || card.querySelector('i')?.className || '';
|
const icon =
|
||||||
|
card.querySelector('i.icon-fallback')?.className ||
|
||||||
|
card.querySelector('i')?.className ||
|
||||||
|
'';
|
||||||
|
|
||||||
// 将卡片信息添加到索引中
|
// 将卡片信息添加到索引中
|
||||||
searchIndex.items.push({
|
searchIndex.items.push({
|
||||||
@@ -1254,7 +1325,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
icon,
|
icon,
|
||||||
element: card,
|
element: card,
|
||||||
// 预先计算搜索文本,提高搜索效率
|
// 预先计算搜索文本,提高搜索效率
|
||||||
searchText: (title + ' ' + description).toLowerCase()
|
searchText: (title + ' ' + description).toLowerCase(),
|
||||||
});
|
});
|
||||||
} catch (cardError) {
|
} catch (cardError) {
|
||||||
console.error('Error processing card:', cardError);
|
console.error('Error processing card:', cardError);
|
||||||
@@ -1308,7 +1379,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
overlay.addEventListener('click', closeAllPanels);
|
overlay.addEventListener('click', closeAllPanels);
|
||||||
|
|
||||||
// 全局快捷键:Ctrl/Cmd + K 聚焦搜索
|
// 全局快捷键:Ctrl/Cmd + K 聚焦搜索
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', (e) => {
|
||||||
const key = (e.key || '').toLowerCase();
|
const key = (e.key || '').toLowerCase();
|
||||||
if (key !== 'k') return;
|
if (key !== 'k') return;
|
||||||
if ((!e.ctrlKey && !e.metaKey) || e.altKey) return;
|
if ((!e.ctrlKey && !e.metaKey) || e.altKey) return;
|
||||||
@@ -1378,7 +1449,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
pages = document.querySelectorAll('.page');
|
pages = document.querySelectorAll('.page');
|
||||||
}
|
}
|
||||||
|
|
||||||
pages.forEach(page => {
|
pages.forEach((page) => {
|
||||||
const shouldBeActive = page.id === pageId;
|
const shouldBeActive = page.id === pageId;
|
||||||
if (shouldBeActive !== page.classList.contains('active')) {
|
if (shouldBeActive !== page.classList.contains('active')) {
|
||||||
page.classList.toggle('active', shouldBeActive);
|
page.classList.toggle('active', shouldBeActive);
|
||||||
@@ -1428,12 +1499,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
let hasResults = false;
|
let hasResults = false;
|
||||||
|
|
||||||
// 使用更高效的搜索算法
|
// 使用更高效的搜索算法
|
||||||
const matchedItems = searchIndex.items.filter(item => {
|
const matchedItems = searchIndex.items.filter((item) => {
|
||||||
return item.searchText.includes(searchTerm) || PinyinMatch.match(item.searchText, searchTerm);;
|
return (
|
||||||
|
item.searchText.includes(searchTerm) || PinyinMatch.match(item.searchText, searchTerm)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 按页面分组结果
|
// 按页面分组结果
|
||||||
matchedItems.forEach(item => {
|
matchedItems.forEach((item) => {
|
||||||
if (!searchResults.has(item.pageId)) {
|
if (!searchResults.has(item.pageId)) {
|
||||||
searchResults.set(item.pageId, []);
|
searchResults.set(item.pageId, []);
|
||||||
}
|
}
|
||||||
@@ -1446,7 +1519,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
try {
|
||||||
// 清空并隐藏所有搜索区域
|
// 清空并隐藏所有搜索区域
|
||||||
searchSections.forEach(section => {
|
searchSections.forEach((section) => {
|
||||||
try {
|
try {
|
||||||
const grid = section.querySelector('.sites-grid');
|
const grid = section.querySelector('.sites-grid');
|
||||||
if (grid) {
|
if (grid) {
|
||||||
@@ -1467,7 +1540,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (grid) {
|
if (grid) {
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
matches.forEach(card => {
|
matches.forEach((card) => {
|
||||||
// 高亮匹配文本
|
// 高亮匹配文本
|
||||||
highlightSearchTerm(card, searchTerm);
|
highlightSearchTerm(card, searchTerm);
|
||||||
fragment.appendChild(card);
|
fragment.appendChild(card);
|
||||||
@@ -1493,7 +1566,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// 显示搜索结果页面
|
// 显示搜索结果页面
|
||||||
if (currentPageId !== 'search-results') {
|
if (currentPageId !== 'search-results') {
|
||||||
currentPageId = 'search-results';
|
currentPageId = 'search-results';
|
||||||
pages.forEach(page => {
|
pages.forEach((page) => {
|
||||||
page.classList.toggle('active', page.id === 'search-results');
|
page.classList.toggle('active', page.id === 'search-results');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1519,7 +1592,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const titleElement = card.querySelector('h3') || card.querySelector('.repo-title');
|
const titleElement = card.querySelector('h3') || card.querySelector('.repo-title');
|
||||||
const descriptionElement = card.querySelector('p') || card.querySelector('.repo-desc');
|
const descriptionElement = card.querySelector('p') || card.querySelector('.repo-desc');
|
||||||
|
|
||||||
const hasPinyinMatch = typeof PinyinMatch !== 'undefined' && PinyinMatch && typeof PinyinMatch.match === 'function';
|
const hasPinyinMatch =
|
||||||
|
typeof PinyinMatch !== 'undefined' &&
|
||||||
|
PinyinMatch &&
|
||||||
|
typeof PinyinMatch.match === 'function';
|
||||||
|
|
||||||
const applyRangeHighlight = (element, start, end) => {
|
const applyRangeHighlight = (element, start, end) => {
|
||||||
const text = element.textContent || '';
|
const text = element.textContent || '';
|
||||||
@@ -1542,7 +1618,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
element.appendChild(fragment);
|
element.appendChild(fragment);
|
||||||
};
|
};
|
||||||
|
|
||||||
const highlightInElement = element => {
|
const highlightInElement = (element) => {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
const rawText = element.textContent || '';
|
const rawText = element.textContent || '';
|
||||||
@@ -1557,7 +1633,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
while ((match = regex.exec(rawText)) !== null) {
|
while ((match = regex.exec(rawText)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
fragment.appendChild(document.createTextNode(rawText.substring(lastIndex, match.index)));
|
fragment.appendChild(
|
||||||
|
document.createTextNode(rawText.substring(lastIndex, match.index))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
@@ -1613,7 +1691,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
try {
|
||||||
// 清空搜索结果
|
// 清空搜索结果
|
||||||
searchSections.forEach(section => {
|
searchSections.forEach((section) => {
|
||||||
try {
|
try {
|
||||||
const grid = section.querySelector('.sites-grid');
|
const grid = section.querySelector('.sites-grid');
|
||||||
if (grid) {
|
if (grid) {
|
||||||
@@ -1637,14 +1715,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
if (targetPageId && currentPageId !== targetPageId) {
|
if (targetPageId && currentPageId !== targetPageId) {
|
||||||
currentPageId = targetPageId;
|
currentPageId = targetPageId;
|
||||||
pages.forEach(page => {
|
pages.forEach((page) => {
|
||||||
page.classList.toggle('active', page.id === targetPageId);
|
page.classList.toggle('active', page.id === targetPageId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果没有激活的导航项,默认显示首页
|
// 如果没有激活的导航项,默认显示首页
|
||||||
currentPageId = homePageId;
|
currentPageId = homePageId;
|
||||||
pages.forEach(page => {
|
pages.forEach((page) => {
|
||||||
page.classList.toggle('active', page.id === homePageId);
|
page.classList.toggle('active', page.id === homePageId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1724,7 +1802,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 点击搜索引擎选项
|
// 点击搜索引擎选项
|
||||||
searchEngineOptions.forEach(option => {
|
searchEngineOptions.forEach((option) => {
|
||||||
// 初始化激活状态
|
// 初始化激活状态
|
||||||
if (option.getAttribute('data-engine') === currentSearchEngine) {
|
if (option.getAttribute('data-engine') === currentSearchEngine) {
|
||||||
option.classList.add('active');
|
option.classList.add('active');
|
||||||
@@ -1773,7 +1851,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// 更新搜索引擎UI显示
|
// 更新搜索引擎UI显示
|
||||||
function updateSearchEngineUI() {
|
function updateSearchEngineUI() {
|
||||||
// 移除所有选项的激活状态
|
// 移除所有选项的激活状态
|
||||||
searchEngineOptions.forEach(option => {
|
searchEngineOptions.forEach((option) => {
|
||||||
option.classList.remove('active');
|
option.classList.remove('active');
|
||||||
|
|
||||||
// 如果是当前选中的搜索引擎,添加激活状态
|
// 如果是当前选中的搜索引擎,添加激活状态
|
||||||
@@ -1885,7 +1963,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 初始展开当前页面的子菜单:高亮项如果有子菜单,需要同步展开
|
// 初始展开当前页面的子菜单:高亮项如果有子菜单,需要同步展开
|
||||||
document.querySelectorAll('.nav-item.active').forEach(activeItem => {
|
document.querySelectorAll('.nav-item.active').forEach((activeItem) => {
|
||||||
const activeWrapper = activeItem.closest('.nav-item-wrapper');
|
const activeWrapper = activeItem.closest('.nav-item-wrapper');
|
||||||
if (!activeWrapper) return;
|
if (!activeWrapper) return;
|
||||||
|
|
||||||
@@ -1896,7 +1974,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 导航项点击效果
|
// 导航项点击效果
|
||||||
navItems.forEach(item => {
|
navItems.forEach((item) => {
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
if (item.getAttribute('target') === '_blank') return;
|
if (item.getAttribute('target') === '_blank') return;
|
||||||
|
|
||||||
@@ -1913,7 +1991,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
wrapper.classList.toggle('expanded');
|
wrapper.classList.toggle('expanded');
|
||||||
} else {
|
} else {
|
||||||
// 关闭所有已展开的子菜单
|
// 关闭所有已展开的子菜单
|
||||||
navItemWrappers.forEach(navWrapper => {
|
navItemWrappers.forEach((navWrapper) => {
|
||||||
if (navWrapper !== wrapper) {
|
if (navWrapper !== wrapper) {
|
||||||
navWrapper.classList.remove('expanded');
|
navWrapper.classList.remove('expanded');
|
||||||
}
|
}
|
||||||
@@ -1925,7 +2003,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 激活导航项
|
// 激活导航项
|
||||||
navItems.forEach(nav => {
|
navItems.forEach((nav) => {
|
||||||
nav.classList.toggle('active', nav === item);
|
nav.classList.toggle('active', nav === item);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1942,7 +2020,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 子菜单项点击效果
|
// 子菜单项点击效果
|
||||||
submenuItems.forEach(item => {
|
submenuItems.forEach((item) => {
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -1951,7 +2029,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const categoryName = item.getAttribute('data-category');
|
const categoryName = item.getAttribute('data-category');
|
||||||
const categoryId = item.getAttribute('data-category-id');
|
const categoryId = item.getAttribute('data-category-id');
|
||||||
|
|
||||||
const escapeSelector = value => {
|
const escapeSelector = (value) => {
|
||||||
if (value === null || value === undefined) return '';
|
if (value === null || value === undefined) return '';
|
||||||
const text = String(value);
|
const text = String(value);
|
||||||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
|
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
|
||||||
@@ -1959,14 +2037,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return text.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
|
return text.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
|
||||||
};
|
};
|
||||||
|
|
||||||
const escapeAttrValue = value => {
|
const escapeAttrValue = (value) => {
|
||||||
if (value === null || value === undefined) return '';
|
if (value === null || value === undefined) return '';
|
||||||
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (pageId) {
|
if (pageId) {
|
||||||
// 清除所有子菜单项的激活状态
|
// 清除所有子菜单项的激活状态
|
||||||
submenuItems.forEach(subItem => {
|
submenuItems.forEach((subItem) => {
|
||||||
subItem.classList.remove('active');
|
subItem.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1974,7 +2052,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
item.classList.add('active');
|
item.classList.add('active');
|
||||||
|
|
||||||
// 激活相应的导航项
|
// 激活相应的导航项
|
||||||
navItems.forEach(nav => {
|
navItems.forEach((nav) => {
|
||||||
nav.classList.toggle('active', nav.getAttribute('data-page') === pageId);
|
nav.classList.toggle('active', nav.getAttribute('data-page') === pageId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2001,7 +2079,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// 回退:旧逻辑按文本包含匹配(兼容旧页面/旧数据)
|
// 回退:旧逻辑按文本包含匹配(兼容旧页面/旧数据)
|
||||||
if (!targetCategory && categoryName) {
|
if (!targetCategory && categoryName) {
|
||||||
targetCategory = Array.from(targetPage.querySelectorAll('.category h2')).find(
|
targetCategory = Array.from(targetPage.querySelectorAll('.category h2')).find(
|
||||||
heading => heading.textContent.trim().includes(categoryName)
|
(heading) => heading.textContent.trim().includes(categoryName)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2030,12 +2108,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const desiredPosition = containerRect.height / 4;
|
const desiredPosition = containerRect.height / 4;
|
||||||
|
|
||||||
// 计算需要滚动的位置
|
// 计算需要滚动的位置
|
||||||
const scrollPosition = contentElement.scrollTop + rect.top - containerRect.top - desiredPosition;
|
const scrollPosition =
|
||||||
|
contentElement.scrollTop + rect.top - containerRect.top - desiredPosition;
|
||||||
|
|
||||||
// 执行滚动
|
// 执行滚动
|
||||||
contentElement.scrollTo({
|
contentElement.scrollTo({
|
||||||
top: scrollPosition,
|
top: scrollPosition,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 回退到基本滚动方式
|
// 回退到基本滚动方式
|
||||||
@@ -2064,7 +2143,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// 初始化分类切换按钮
|
// 初始化分类切换按钮
|
||||||
const categoryToggleBtn = document.getElementById('category-toggle');
|
const categoryToggleBtn = document.getElementById('category-toggle');
|
||||||
if (categoryToggleBtn) {
|
if (categoryToggleBtn) {
|
||||||
categoryToggleBtn.addEventListener('click', function() {
|
categoryToggleBtn.addEventListener('click', function () {
|
||||||
window.MeNav.toggleCategories();
|
window.MeNav.toggleCategories();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const {
|
|||||||
function stripYamlComments(yamlText) {
|
function stripYamlComments(yamlText) {
|
||||||
return yamlText
|
return yamlText
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter(line => !/^\s*#/.test(line))
|
.filter((line) => !/^\s*#/.test(line))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ test('parseBookmarks:解析书签栏、根目录书签与图标映射', () =>
|
|||||||
assert.equal(bookmarks.categories[0].sites[0].name, 'GitHub');
|
assert.equal(bookmarks.categories[0].sites[0].name, 'GitHub');
|
||||||
assert.equal(bookmarks.categories[0].sites[0].icon, 'fab fa-github');
|
assert.equal(bookmarks.categories[0].sites[0].icon, 'fab fa-github');
|
||||||
|
|
||||||
const tools = bookmarks.categories.find(c => c.name === '工具');
|
const tools = bookmarks.categories.find((c) => c.name === '工具');
|
||||||
assert.ok(tools, '应解析出“工具”分类');
|
assert.ok(tools, '应解析出“工具”分类');
|
||||||
assert.ok(Array.isArray(tools.sites));
|
assert.ok(Array.isArray(tools.sites));
|
||||||
assert.equal(tools.sites[0].name, 'Google');
|
assert.equal(tools.sites[0].name, 'Google');
|
||||||
@@ -62,11 +62,26 @@ test('templates:subgroups(第4层)应可渲染到页面', () => {
|
|||||||
const hbs = Handlebars.create();
|
const hbs = Handlebars.create();
|
||||||
registerAllHelpers(hbs);
|
registerAllHelpers(hbs);
|
||||||
|
|
||||||
const category = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'category.hbs'), 'utf8');
|
const category = fs.readFileSync(
|
||||||
const group = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'group.hbs'), 'utf8');
|
path.join(__dirname, '..', 'templates', 'components', 'category.hbs'),
|
||||||
const pageHeader = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'page-header.hbs'), 'utf8');
|
'utf8'
|
||||||
const siteCard = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'site-card.hbs'), 'utf8');
|
);
|
||||||
const page = fs.readFileSync(path.join(__dirname, '..', 'templates', 'pages', 'bookmarks.hbs'), 'utf8');
|
const group = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', 'templates', 'components', 'group.hbs'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const pageHeader = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', 'templates', 'components', 'page-header.hbs'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const siteCard = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', 'templates', 'components', 'site-card.hbs'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const page = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', 'templates', 'pages', 'bookmarks.hbs'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
hbs.registerPartial('category', category);
|
hbs.registerPartial('category', category);
|
||||||
hbs.registerPartial('group', group);
|
hbs.registerPartial('group', group);
|
||||||
@@ -96,7 +111,12 @@ test('templates:subgroups(第4层)应可渲染到页面', () => {
|
|||||||
name: 'React生态',
|
name: 'React生态',
|
||||||
icon: 'fab fa-react',
|
icon: 'fab fa-react',
|
||||||
sites: [
|
sites: [
|
||||||
{ name: 'React', url: 'https://reactjs.org/', icon: 'fab fa-react', description: 'React官方' },
|
{
|
||||||
|
name: 'React',
|
||||||
|
url: 'https://reactjs.org/',
|
||||||
|
icon: 'fab fa-react',
|
||||||
|
description: 'React官方',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -119,7 +139,9 @@ test('generateBookmarksYaml:生成 YAML 且可被解析', () => {
|
|||||||
{
|
{
|
||||||
name: '示例分类',
|
name: '示例分类',
|
||||||
icon: 'fas fa-folder',
|
icon: 'fas fa-folder',
|
||||||
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: '' }],
|
sites: [
|
||||||
|
{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: '' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -140,11 +162,7 @@ test('upsertBookmarksNavInSiteYml:无 navigation 时追加并幂等', () => {
|
|||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-test-'));
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-test-'));
|
||||||
const filePath = path.join(tmp, 'site.yml');
|
const filePath = path.join(tmp, 'site.yml');
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(filePath, `title: Test Site\n`, 'utf8');
|
||||||
filePath,
|
|
||||||
`title: Test Site\n`,
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const r1 = upsertBookmarksNavInSiteYml(filePath);
|
const r1 = upsertBookmarksNavInSiteYml(filePath);
|
||||||
assert.equal(r1.updated, true);
|
assert.equal(r1.updated, true);
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ function renderBookmarksWithSite(site) {
|
|||||||
{
|
{
|
||||||
name: '分类',
|
name: '分类',
|
||||||
icon: 'fas fa-folder',
|
icon: 'fas fa-folder',
|
||||||
sites: [site]
|
sites: [site],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -45,7 +45,7 @@ test('站点配置包含 faviconUrl(本地 assets 路径)时,渲染 bookma
|
|||||||
url: 'https://intranet.example/',
|
url: 'https://intranet.example/',
|
||||||
faviconUrl: 'assets/menav.svg',
|
faviconUrl: 'assets/menav.svg',
|
||||||
icon: 'fas fa-link',
|
icon: 'fas fa-link',
|
||||||
external: true
|
external: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.match(html, /data-favicon-url="assets\/menav\.svg"/);
|
assert.match(html, /data-favicon-url="assets\/menav\.svg"/);
|
||||||
@@ -60,7 +60,7 @@ test('站点配置包含 faviconUrl(在线 ico)时,渲染 bookmarks 不应
|
|||||||
url: 'https://example.com/',
|
url: 'https://example.com/',
|
||||||
faviconUrl: 'https://content.webcull.com/images/websites/icons/470/695/b788b0.ico',
|
faviconUrl: 'https://content.webcull.com/images/websites/icons/470/695/b788b0.ico',
|
||||||
icon: 'fas fa-link',
|
icon: 'fas fa-link',
|
||||||
external: true
|
external: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -73,4 +73,3 @@ test('站点配置包含 faviconUrl(在线 ico)时,渲染 bookmarks 不应
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,24 @@ test('首页(navigation 第一项)应使用 profile 覆盖 title/subtitle
|
|||||||
{ id: 'home', name: '首页', icon: 'fas fa-home' },
|
{ id: 'home', name: '首页', icon: 'fas fa-home' },
|
||||||
{ id: 'projects', name: '项目', icon: 'fas fa-project-diagram' },
|
{ id: 'projects', name: '项目', icon: 'fas fa-project-diagram' },
|
||||||
],
|
],
|
||||||
bookmarks: { title: '书签页标题', subtitle: '书签页副标题', template: 'bookmarks', categories: [] },
|
bookmarks: {
|
||||||
home: { title: 'HOME_PAGE_TITLE', subtitle: 'HOME_PAGE_SUBTITLE', template: 'page', categories: [] },
|
title: '书签页标题',
|
||||||
projects: { title: '项目页标题', subtitle: '项目页副标题', template: 'projects', categories: [] },
|
subtitle: '书签页副标题',
|
||||||
|
template: 'bookmarks',
|
||||||
|
categories: [],
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
title: 'HOME_PAGE_TITLE',
|
||||||
|
subtitle: 'HOME_PAGE_SUBTITLE',
|
||||||
|
template: 'page',
|
||||||
|
categories: [],
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
title: '项目页标题',
|
||||||
|
subtitle: '项目页副标题',
|
||||||
|
template: 'projects',
|
||||||
|
categories: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const pages = generateAllPagesHTML(config);
|
const pages = generateAllPagesHTML(config);
|
||||||
|
|||||||
@@ -46,4 +46,3 @@ test('未配置 icons.mode 时应回退为默认 favicon', () => {
|
|||||||
assert.equal(config.icons.mode, 'favicon');
|
assert.equal(config.icons.mode, 'favicon');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ test('多级结构下 sites.external 未配置时应默认 true,且 external:f
|
|||||||
' - name: DeepExternalFalse',
|
' - name: DeepExternalFalse',
|
||||||
' url: https://example.com/deep-false',
|
' url: https://example.com/deep-false',
|
||||||
' external: false',
|
' external: false',
|
||||||
''
|
'',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
@@ -67,4 +67,3 @@ test('多级结构下 sites.external 未配置时应默认 true,且 external:f
|
|||||||
assert.equal(deepSites[1].external, false);
|
assert.equal(deepSites[1].external, false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,14 @@ test('friends/articles:应恢复分类展示(扩展仍以 data-* 结构为
|
|||||||
{
|
{
|
||||||
name: '技术博主',
|
name: '技术博主',
|
||||||
icon: 'fas fa-user-friends',
|
icon: 'fas fa-user-friends',
|
||||||
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
sites: [
|
||||||
|
{
|
||||||
|
name: 'Example',
|
||||||
|
url: 'https://example.com',
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
description: 'desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -50,7 +57,14 @@ test('friends/articles:应恢复分类展示(扩展仍以 data-* 结构为
|
|||||||
{
|
{
|
||||||
name: '最新文章',
|
name: '最新文章',
|
||||||
icon: 'fas fa-pen',
|
icon: 'fas fa-pen',
|
||||||
sites: [{ name: 'Article A', url: 'https://example.com/a', icon: 'fas fa-link', description: 'summary' }],
|
sites: [
|
||||||
|
{
|
||||||
|
name: 'Article A',
|
||||||
|
url: 'https://example.com/a',
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
description: 'summary',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -61,15 +75,27 @@ test('friends/articles:应恢复分类展示(扩展仍以 data-* 结构为
|
|||||||
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
|
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
|
||||||
assert.ok(pages.friends.includes('page-template-friends'));
|
assert.ok(pages.friends.includes('page-template-friends'));
|
||||||
assert.ok(pages.friends.includes('sites-grid'));
|
assert.ok(pages.friends.includes('sites-grid'));
|
||||||
assert.ok(pages.friends.includes('class="site-card'), 'friends 应使用普通 site-card 样式(图标在左,标题在右)');
|
assert.ok(
|
||||||
assert.ok(!pages.friends.includes('site-card-friend'), 'friends 不应使用 site-card-friend 变体样式');
|
pages.friends.includes('class="site-card'),
|
||||||
|
'friends 应使用普通 site-card 样式(图标在左,标题在右)'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!pages.friends.includes('site-card-friend'),
|
||||||
|
'friends 不应使用 site-card-friend 变体样式'
|
||||||
|
);
|
||||||
assert.ok(pages.friends.includes('category-header'), 'friends 应输出分类标题结构');
|
assert.ok(pages.friends.includes('category-header'), 'friends 应输出分类标题结构');
|
||||||
|
|
||||||
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
|
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
|
||||||
assert.ok(pages.articles.includes('page-template-articles'));
|
assert.ok(pages.articles.includes('page-template-articles'));
|
||||||
assert.ok(pages.articles.includes('sites-grid'));
|
assert.ok(pages.articles.includes('sites-grid'));
|
||||||
assert.ok(pages.articles.includes('class="site-card'), 'articles 应使用普通 site-card 样式(图标在左,标题在右)');
|
assert.ok(
|
||||||
assert.ok(!pages.articles.includes('site-card-article'), 'articles 不应使用 site-card-article 变体样式');
|
pages.articles.includes('class="site-card'),
|
||||||
|
'articles 应使用普通 site-card 样式(图标在左,标题在右)'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!pages.articles.includes('site-card-article'),
|
||||||
|
'articles 不应使用 site-card-article 变体样式'
|
||||||
|
);
|
||||||
assert.ok(pages.articles.includes('category-header'), 'articles 应输出分类标题结构');
|
assert.ok(pages.articles.includes('category-header'), 'articles 应输出分类标题结构');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -92,13 +118,22 @@ test('friends/articles:页面配置使用顶层 sites 时应自动映射为分
|
|||||||
title: '友情链接',
|
title: '友情链接',
|
||||||
subtitle: '朋友们',
|
subtitle: '朋友们',
|
||||||
template: 'page',
|
template: 'page',
|
||||||
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
sites: [
|
||||||
|
{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
articles: {
|
articles: {
|
||||||
title: '文章',
|
title: '文章',
|
||||||
subtitle: '文章入口',
|
subtitle: '文章入口',
|
||||||
template: 'articles',
|
template: 'articles',
|
||||||
sites: [{ name: 'Article A', url: 'https://example.com/a', icon: 'fas fa-link', description: 'summary' }],
|
sites: [
|
||||||
|
{
|
||||||
|
name: 'Article A',
|
||||||
|
url: 'https://example.com/a',
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
description: 'summary',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,15 +142,27 @@ test('friends/articles:页面配置使用顶层 sites 时应自动映射为分
|
|||||||
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
|
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
|
||||||
assert.ok(pages.friends.includes('page-template-friends'));
|
assert.ok(pages.friends.includes('page-template-friends'));
|
||||||
assert.ok(pages.friends.includes('sites-grid'));
|
assert.ok(pages.friends.includes('sites-grid'));
|
||||||
assert.ok(pages.friends.includes('class="site-card'), 'friends 应使用普通 site-card 样式(图标在左,标题在右)');
|
assert.ok(
|
||||||
assert.ok(!pages.friends.includes('site-card-friend'), 'friends 不应使用 site-card-friend 变体样式');
|
pages.friends.includes('class="site-card'),
|
||||||
|
'friends 应使用普通 site-card 样式(图标在左,标题在右)'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!pages.friends.includes('site-card-friend'),
|
||||||
|
'friends 不应使用 site-card-friend 变体样式'
|
||||||
|
);
|
||||||
assert.ok(pages.friends.includes('category-header'), 'friends 应输出分类标题结构');
|
assert.ok(pages.friends.includes('category-header'), 'friends 应输出分类标题结构');
|
||||||
|
|
||||||
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
|
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
|
||||||
assert.ok(pages.articles.includes('page-template-articles'));
|
assert.ok(pages.articles.includes('page-template-articles'));
|
||||||
assert.ok(pages.articles.includes('sites-grid'));
|
assert.ok(pages.articles.includes('sites-grid'));
|
||||||
assert.ok(pages.articles.includes('class="site-card'), 'articles 应使用普通 site-card 样式(图标在左,标题在右)');
|
assert.ok(
|
||||||
assert.ok(!pages.articles.includes('site-card-article'), 'articles 不应使用 site-card-article 变体样式');
|
pages.articles.includes('class="site-card'),
|
||||||
|
'articles 应使用普通 site-card 样式(图标在左,标题在右)'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!pages.articles.includes('site-card-article'),
|
||||||
|
'articles 不应使用 site-card-article 变体样式'
|
||||||
|
);
|
||||||
assert.ok(pages.articles.includes('category-header'), 'articles 应输出分类标题结构');
|
assert.ok(pages.articles.includes('category-header'), 'articles 应输出分类标题结构');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -191,7 +238,14 @@ test('projects:应输出代码仓库风格卡片(site-card-repo)', () => {
|
|||||||
{
|
{
|
||||||
name: '项目',
|
name: '项目',
|
||||||
icon: 'fas fa-code',
|
icon: 'fas fa-code',
|
||||||
sites: [{ name: 'Proj', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
sites: [
|
||||||
|
{
|
||||||
|
name: 'Proj',
|
||||||
|
url: 'https://example.com',
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
description: 'desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -231,10 +285,10 @@ test('articles Phase 2:存在 RSS 缓存时渲染文章条目,并隐藏扩
|
|||||||
publishedAt: '2025-12-25T12:00:00.000Z',
|
publishedAt: '2025-12-25T12:00:00.000Z',
|
||||||
source: 'Example Blog',
|
source: 'Example Blog',
|
||||||
sourceUrl: 'https://example.com',
|
sourceUrl: 'https://example.com',
|
||||||
icon: 'fas fa-pen'
|
icon: 'fas fa-pen',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
stats: { totalArticles: 1 }
|
stats: { totalArticles: 1 },
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
@@ -259,7 +313,14 @@ test('articles Phase 2:存在 RSS 缓存时渲染文章条目,并隐藏扩
|
|||||||
{
|
{
|
||||||
name: '来源',
|
name: '来源',
|
||||||
icon: 'fas fa-pen',
|
icon: 'fas fa-pen',
|
||||||
sites: [{ name: 'Source A', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
sites: [
|
||||||
|
{
|
||||||
|
name: 'Source A',
|
||||||
|
url: 'https://example.com',
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
description: 'desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -269,7 +330,10 @@ test('articles Phase 2:存在 RSS 缓存时渲染文章条目,并隐藏扩
|
|||||||
const html = pages.articles;
|
const html = pages.articles;
|
||||||
|
|
||||||
assert.ok(typeof html === 'string' && html.length > 0);
|
assert.ok(typeof html === 'string' && html.length > 0);
|
||||||
assert.ok(html.includes('data-type="article"'), '文章条目卡片应为 data-type="article"(只读)');
|
assert.ok(
|
||||||
|
html.includes('data-type="article"'),
|
||||||
|
'文章条目卡片应为 data-type="article"(只读)'
|
||||||
|
);
|
||||||
assert.ok(html.includes('site-card-meta'), '文章条目应展示日期/来源元信息');
|
assert.ok(html.includes('site-card-meta'), '文章条目应展示日期/来源元信息');
|
||||||
assert.ok(html.includes('Example Blog'));
|
assert.ok(html.includes('Example Blog'));
|
||||||
assert.ok(html.includes('2025-12-25'));
|
assert.ok(html.includes('2025-12-25'));
|
||||||
|
|||||||
Reference in New Issue
Block a user