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:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# 定时刷新 RSS / projects 仓库元信息(GitHub Actions 的 cron 使用 UTC 时区)
|
||||
@@ -16,7 +16,7 @@ permissions:
|
||||
|
||||
# 允许一个并发部署
|
||||
concurrency:
|
||||
group: "pages"
|
||||
group: 'pages'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -153,7 +153,6 @@ jobs:
|
||||
echo "No HTML files found to clean up."
|
||||
fi
|
||||
# --- 书签处理步骤结束 ---
|
||||
|
||||
- name: Push configuration changes (if any)
|
||||
# 只有在书签处理步骤修改了文件时才推送
|
||||
# 使用 GITHUB_TOKEN 推送
|
||||
|
||||
@@ -117,13 +117,13 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优
|
||||
- 示例:
|
||||
```yml
|
||||
sites:
|
||||
- name: "Ant Design"
|
||||
url: "https://ant.design/"
|
||||
icon: "fas fa-th"
|
||||
- name: 'Ant Design'
|
||||
url: 'https://ant.design/'
|
||||
icon: 'fas fa-th'
|
||||
forceIconMode: manual # 强制使用手动图标,绕过 favicon 默认"地球"图标
|
||||
- name: "Example"
|
||||
url: "https://example.com/"
|
||||
faviconUrl: "https://example.com/favicon.png" # 单站点自定义 favicon
|
||||
- name: 'Example'
|
||||
url: 'https://example.com/'
|
||||
faviconUrl: 'https://example.com/favicon.png' # 单站点自定义 favicon
|
||||
```
|
||||
|
||||
3. **安全策略(链接白名单)**
|
||||
@@ -254,46 +254,46 @@ MeNav 配置系统采用“完全替换”策略:只会选择一套目录加
|
||||
|
||||
```yaml
|
||||
# 网站基本信息
|
||||
title: "我的个人导航"
|
||||
description: "个人收藏的网站导航页"
|
||||
keywords: "导航,网址,书签,个人主页"
|
||||
title: '我的个人导航'
|
||||
description: '个人收藏的网站导航页'
|
||||
keywords: '导航,网址,书签,个人主页'
|
||||
|
||||
# 个人资料配置
|
||||
profile:
|
||||
title: "个人导航站"
|
||||
subtitle: "我收藏的精选网站"
|
||||
title: '个人导航站'
|
||||
subtitle: '我收藏的精选网站'
|
||||
|
||||
# 字体:全站基础字体
|
||||
fonts:
|
||||
source: css
|
||||
cssUrl: "https://fontsapi.zeoseven.com/292/main/result.css"
|
||||
cssUrl: 'https://fontsapi.zeoseven.com/292/main/result.css'
|
||||
preload: true
|
||||
family: "LXGW WenKai"
|
||||
family: 'LXGW WenKai'
|
||||
weight: normal
|
||||
|
||||
# 社交媒体链接
|
||||
social:
|
||||
- name: "GitHub"
|
||||
url: "https://github.com/username"
|
||||
icon: "fab fa-github"
|
||||
- name: "Twitter"
|
||||
url: "https://twitter.com/username"
|
||||
icon: "fab fa-twitter"
|
||||
- name: 'GitHub'
|
||||
url: 'https://github.com/username'
|
||||
icon: 'fab fa-github'
|
||||
- name: 'Twitter'
|
||||
url: 'https://twitter.com/username'
|
||||
icon: 'fab fa-twitter'
|
||||
|
||||
# 导航配置
|
||||
navigation:
|
||||
- name: "常用"
|
||||
icon: "fas fa-star"
|
||||
id: "common"
|
||||
- name: "项目"
|
||||
icon: "fas fa-project-diagram"
|
||||
id: "projects"
|
||||
- name: "文章"
|
||||
icon: "fas fa-book"
|
||||
id: "articles"
|
||||
- name: "书签"
|
||||
icon: "fas fa-bookmark"
|
||||
id: "bookmarks"
|
||||
- name: '常用'
|
||||
icon: 'fas fa-star'
|
||||
id: 'common'
|
||||
- name: '项目'
|
||||
icon: 'fas fa-project-diagram'
|
||||
id: 'projects'
|
||||
- name: '文章'
|
||||
icon: 'fas fa-book'
|
||||
id: 'articles'
|
||||
- name: '书签'
|
||||
icon: 'fas fa-bookmark'
|
||||
id: 'bookmarks'
|
||||
```
|
||||
|
||||
### 通用页面配置示例(例如 common.yml)
|
||||
@@ -301,25 +301,25 @@ navigation:
|
||||
```yaml
|
||||
# 页面分类配置
|
||||
categories:
|
||||
- name: "常用工具"
|
||||
icon: "fas fa-tools"
|
||||
- name: '常用工具'
|
||||
icon: 'fas fa-tools'
|
||||
sites:
|
||||
- name: "Google"
|
||||
url: "https://www.google.com"
|
||||
description: "全球最大的搜索引擎"
|
||||
icon: "fab fa-google"
|
||||
- name: "GitHub"
|
||||
url: "https://github.com"
|
||||
description: "代码托管平台"
|
||||
icon: "fab fa-github"
|
||||
- name: 'Google'
|
||||
url: 'https://www.google.com'
|
||||
description: '全球最大的搜索引擎'
|
||||
icon: 'fab fa-google'
|
||||
- name: 'GitHub'
|
||||
url: 'https://github.com'
|
||||
description: '代码托管平台'
|
||||
icon: 'fab fa-github'
|
||||
|
||||
- name: "学习资源"
|
||||
icon: "fas fa-graduation-cap"
|
||||
- name: '学习资源'
|
||||
icon: 'fas fa-graduation-cap'
|
||||
sites:
|
||||
- name: "MDN Web Docs"
|
||||
url: "https://developer.mozilla.org"
|
||||
description: "Web开发技术文档"
|
||||
icon: "fab fa-firefox-browser"
|
||||
- name: 'MDN Web Docs'
|
||||
url: 'https://developer.mozilla.org'
|
||||
description: 'Web开发技术文档'
|
||||
icon: 'fab fa-firefox-browser'
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
@@ -23,11 +23,11 @@ categories:
|
||||
- name: MarksVault
|
||||
icon: fab fa-github
|
||||
description: 一个强大的浏览器扩展,用于智能管理、整理和安全备份您的书签数据
|
||||
url: "https://github.com/rbetree/MarksVault"
|
||||
url: 'https://github.com/rbetree/MarksVault'
|
||||
- name: star
|
||||
icon: fas fa-star
|
||||
sites:
|
||||
- name: CLIProxyAPI
|
||||
icon: fab fa-github
|
||||
description: Wrap Gemini CLI, Antigravity, ChatGPT Codex, Claude Code, Qwen Code, iFlow as an OpenAI/Gemini/Claude/Codex compatible API service, allowing you to enjoy the free Gemini 2.5 Pro, GPT 5, Claude, Qwen model through API
|
||||
url: "https://github.com/router-for-me/CLIProxyAPI"
|
||||
url: 'https://github.com/router-for-me/CLIProxyAPI'
|
||||
|
||||
@@ -41,7 +41,7 @@ security:
|
||||
# - system: 只使用本地/系统字体,不额外发起请求
|
||||
fonts:
|
||||
source: css
|
||||
cssUrl: "https://fontsapi.zeoseven.com/292/main/result.css"
|
||||
cssUrl: 'https://fontsapi.zeoseven.com/292/main/result.css'
|
||||
preload: true # 可选:使用 preload+onload 的方式非阻塞加载字体 CSS(更利于首屏性能)
|
||||
family: LXGW WenKai
|
||||
weight: normal
|
||||
@@ -81,7 +81,7 @@ rss:
|
||||
# - username:你的 GitHub 用户名(例如 torvalds)
|
||||
# - heatmapColor:热力图主题色(不带 #,例如 339af0)
|
||||
github:
|
||||
username: "rbetree" # 你的 GitHub 用户名(例如 torvalds;为空则 projects 页不展示热力图)
|
||||
username: 'rbetree' # 你的 GitHub 用户名(例如 torvalds;为空则 projects 页不展示热力图)
|
||||
heatmapColor: 339af0
|
||||
cacheDir: dev # projects 仓库元信息缓存目录(默认 dev,仓库默认 gitignore)
|
||||
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
用途:为 `articles` 页面提供 RSS/Atom 文章聚合数据,供 `npm run sync-articles` 联网抓取并写入缓存;`npm run build` 默认不联网,只读取缓存渲染。
|
||||
|
||||
关键字段(默认示例见 `config/_default/site.yml`):
|
||||
|
||||
- `site.rss.enabled`:是否启用 RSS 抓取能力
|
||||
- `site.rss.cacheDir`:缓存目录(建议 `dev/`,仓库默认 gitignore)
|
||||
- `site.rss.fetch.*`:抓取参数(超时、并发、重试、重定向等)
|
||||
- `site.rss.articles.*`:抓取条数与摘要长度(例如每站点最多 8 篇)
|
||||
|
||||
说明:
|
||||
|
||||
- RSS 抓取只影响 `articles` Phase 2(文章条目只读展示),不会影响扩展对“来源站点(sites)”的写回能力(构建会保留影子写回结构)。
|
||||
|
||||
---
|
||||
@@ -26,15 +28,18 @@
|
||||
### 1.2 `site.github.*`(projects 仓库元信息 + 热力图)
|
||||
|
||||
用途:
|
||||
|
||||
- projects 卡片可展示仓库元信息(language/stars/forks 等,只读),由 `npm run sync-projects` 联网抓取并写入缓存。
|
||||
- projects 标题区右侧可选展示 GitHub 贡献热力图。
|
||||
|
||||
关键字段(默认示例见 `config/_default/site.yml`):
|
||||
|
||||
- `site.github.username`:GitHub 用户名;为空则不展示热力图
|
||||
- `site.github.heatmapColor`:热力图主题色(不带 `#`,如 `339af0`)
|
||||
- `site.github.cacheDir`:仓库元信息缓存目录(建议 `dev/`)
|
||||
|
||||
说明:
|
||||
|
||||
- 仓库元信息来自 GitHub API,属于“时效性数据”,不会写回到 `pages/projects.yml`。
|
||||
|
||||
---
|
||||
@@ -44,6 +49,7 @@
|
||||
用途:指定页面使用的模板(对应 `templates/pages/<template>.hbs`,不含扩展名)。
|
||||
|
||||
行为规则:
|
||||
|
||||
- 若 `template` 缺省:优先尝试同名模板(`templates/pages/<pageId>.hbs`),不存在则回退到通用 `page` 模板。
|
||||
- `bookmarks/projects/articles` 等特殊页建议显式配置 `template`,以减少误解。
|
||||
|
||||
@@ -56,6 +62,7 @@
|
||||
当前版本不再回退读取根目录 `config.yml`/`config.yaml`。
|
||||
|
||||
迁移要点:
|
||||
|
||||
- 使用模块化配置目录:`config/user/`(优先级最高,完全替换)或 `config/_default/`(默认示例)。
|
||||
- 推荐迁移方式:复制 `config/_default/` → `config/user/`,再按需修改 `site.yml` 与 `pages/*.yml`。
|
||||
|
||||
@@ -66,6 +73,7 @@
|
||||
当前版本仅从 `site.yml -> navigation` 读取导航配置,不再读取 `navigation.yml`。
|
||||
|
||||
迁移要点:
|
||||
|
||||
- 将原 `navigation.yml` 的数组内容移动到 `config/user/site.yml` 的 `navigation:` 字段下。
|
||||
|
||||
---
|
||||
@@ -75,6 +83,7 @@
|
||||
当前版本不再维护“首页固定叫 `home`”的遗留逻辑(例如把 `pages/home.yml` 的分类提升到顶层 `config.categories`)。
|
||||
|
||||
迁移要点:
|
||||
|
||||
- 不要依赖固定页面 id `home`。
|
||||
- 首页始终由 `site.yml -> navigation` 的**第一项**决定;其分类内容应写在对应的 `pages/<homePageId>.yml` 中。
|
||||
|
||||
@@ -85,10 +94,12 @@
|
||||
历史版本可能通过 `navigation[].active` 指定“默认打开页/首页”。
|
||||
|
||||
当前版本:
|
||||
|
||||
- 首页/默认打开页始终由 `site.yml -> navigation` 的**第一项**决定
|
||||
- `active` 字段将被忽略(即使写了也不会生效)
|
||||
|
||||
迁移要点:
|
||||
|
||||
- 通过调整 `navigation` 数组顺序来设置首页(把希望作为首页的页面放到第一项)。
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const distPath = path.resolve(__dirname, "..", "dist");
|
||||
const distPath = path.resolve(__dirname, '..', 'dist');
|
||||
|
||||
try {
|
||||
fs.rmSync(distPath, { recursive: true, force: true });
|
||||
|
||||
@@ -18,13 +18,13 @@ const DEFAULT_RSS_SETTINGS = {
|
||||
maxRedirects: 3,
|
||||
userAgent: 'MeNavRSSSync/1.0',
|
||||
htmlMaxBytes: 512 * 1024,
|
||||
feedMaxBytes: 1024 * 1024
|
||||
feedMaxBytes: 1024 * 1024,
|
||||
},
|
||||
articles: {
|
||||
perSite: 8,
|
||||
total: 50,
|
||||
summaryMaxLength: 200
|
||||
}
|
||||
summaryMaxLength: 200,
|
||||
},
|
||||
};
|
||||
|
||||
function parseBooleanEnv(value, fallback) {
|
||||
@@ -42,7 +42,8 @@ function parseIntegerEnv(value, fallback) {
|
||||
}
|
||||
|
||||
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
|
||||
: {};
|
||||
|
||||
@@ -51,12 +52,12 @@ function getRssSettings(config) {
|
||||
...fromConfig,
|
||||
fetch: {
|
||||
...DEFAULT_RSS_SETTINGS.fetch,
|
||||
...(fromConfig.fetch || {})
|
||||
...(fromConfig.fetch || {}),
|
||||
},
|
||||
articles: {
|
||||
...DEFAULT_RSS_SETTINGS.articles,
|
||||
...(fromConfig.articles || {})
|
||||
}
|
||||
...(fromConfig.articles || {}),
|
||||
},
|
||||
};
|
||||
|
||||
// 环境变量覆盖(主要给 CI 调试/降级用)
|
||||
@@ -64,12 +65,27 @@ function getRssSettings(config) {
|
||||
merged.cacheDir = process.env.RSS_CACHE_DIR ? String(process.env.RSS_CACHE_DIR) : merged.cacheDir;
|
||||
|
||||
merged.fetch.timeoutMs = parseIntegerEnv(process.env.RSS_FETCH_TIMEOUT, merged.fetch.timeoutMs);
|
||||
merged.fetch.maxRetries = parseIntegerEnv(process.env.RSS_FETCH_MAX_RETRIES, merged.fetch.maxRetries);
|
||||
merged.fetch.concurrency = parseIntegerEnv(process.env.RSS_FETCH_CONCURRENCY, merged.fetch.concurrency);
|
||||
merged.fetch.totalTimeoutMs = parseIntegerEnv(process.env.RSS_TOTAL_TIMEOUT, merged.fetch.totalTimeoutMs);
|
||||
merged.fetch.maxRedirects = parseIntegerEnv(process.env.RSS_FETCH_MAX_REDIRECTS, merged.fetch.maxRedirects);
|
||||
merged.fetch.maxRetries = parseIntegerEnv(
|
||||
process.env.RSS_FETCH_MAX_RETRIES,
|
||||
merged.fetch.maxRetries
|
||||
);
|
||||
merged.fetch.concurrency = parseIntegerEnv(
|
||||
process.env.RSS_FETCH_CONCURRENCY,
|
||||
merged.fetch.concurrency
|
||||
);
|
||||
merged.fetch.totalTimeoutMs = parseIntegerEnv(
|
||||
process.env.RSS_TOTAL_TIMEOUT,
|
||||
merged.fetch.totalTimeoutMs
|
||||
);
|
||||
merged.fetch.maxRedirects = parseIntegerEnv(
|
||||
process.env.RSS_FETCH_MAX_REDIRECTS,
|
||||
merged.fetch.maxRedirects
|
||||
);
|
||||
|
||||
merged.articles.perSite = parseIntegerEnv(process.env.RSS_ARTICLES_PER_SITE, merged.articles.perSite);
|
||||
merged.articles.perSite = parseIntegerEnv(
|
||||
process.env.RSS_ARTICLES_PER_SITE,
|
||||
merged.articles.perSite
|
||||
);
|
||||
merged.articles.total = parseIntegerEnv(process.env.RSS_ARTICLES_TOTAL, merged.articles.total);
|
||||
merged.articles.summaryMaxLength = parseIntegerEnv(
|
||||
process.env.RSS_SUMMARY_MAX_LENGTH,
|
||||
@@ -104,8 +120,9 @@ function isPrivateIp(ip) {
|
||||
if (!ip) return true;
|
||||
|
||||
if (net.isIP(ip) === 4) {
|
||||
const parts = ip.split('.').map(n => Number.parseInt(n, 10));
|
||||
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n) || n < 0 || n > 255)) return true;
|
||||
const parts = ip.split('.').map((n) => Number.parseInt(n, 10));
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255))
|
||||
return true;
|
||||
|
||||
const [a, b] = parts;
|
||||
if (a === 10) return true;
|
||||
@@ -152,7 +169,12 @@ async function assertSafeToFetch(url, timeoutMs) {
|
||||
}
|
||||
|
||||
const hostname = u.hostname.toLowerCase();
|
||||
if (hostname === 'localhost' || hostname === '0.0.0.0' || hostname === '127.0.0.1' || hostname === '::1') {
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1'
|
||||
) {
|
||||
throw new Error('禁止访问本机地址');
|
||||
}
|
||||
if (hostname.endsWith('.local')) {
|
||||
@@ -175,14 +197,14 @@ async function assertSafeToFetch(url, timeoutMs) {
|
||||
throw new Error('DNS 解析失败或无结果');
|
||||
}
|
||||
|
||||
const hasPrivate = records.some(r => isPrivateIp(r.address));
|
||||
const hasPrivate = records.some((r) => isPrivateIp(r.address));
|
||||
if (hasPrivate) throw new Error('DNS 解析到内网/保留地址,已阻断');
|
||||
}
|
||||
|
||||
function buildHeaders(userAgent) {
|
||||
return {
|
||||
'user-agent': userAgent,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,7 +222,7 @@ async function fetchWithRedirects(url, { timeoutMs, maxRedirects, headers, maxBy
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
headers,
|
||||
signal: controller.signal
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
@@ -283,7 +305,7 @@ function extractFeedLinksFromHtml(html, baseUrl) {
|
||||
}
|
||||
|
||||
// 简单排序:优先 RSS,其次 Atom
|
||||
const rank = url => (url.includes('atom') ? 2 : 1);
|
||||
const rank = (url) => (url.includes('atom') ? 2 : 1);
|
||||
return [...new Set(candidates)].sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
@@ -309,11 +331,15 @@ async function discoverFeedUrl(siteUrl, settings, deadlineTs) {
|
||||
timeoutMs: Math.min(settings.fetch.timeoutMs, timeRemaining),
|
||||
maxRedirects: settings.fetch.maxRedirects,
|
||||
headers: buildHeaders(settings.fetch.userAgent),
|
||||
maxBytes: settings.fetch.htmlMaxBytes
|
||||
maxBytes: settings.fetch.htmlMaxBytes,
|
||||
});
|
||||
|
||||
const contentType = homepage.response.headers.get('content-type') || '';
|
||||
if (/text\/html/i.test(contentType) || /application\/xhtml\+xml/i.test(contentType) || !contentType) {
|
||||
if (
|
||||
/text\/html/i.test(contentType) ||
|
||||
/application\/xhtml\+xml/i.test(contentType) ||
|
||||
!contentType
|
||||
) {
|
||||
const candidates = extractFeedLinksFromHtml(homepage.text, homepage.url);
|
||||
if (candidates.length > 0) {
|
||||
return candidates[0];
|
||||
@@ -325,7 +351,8 @@ async function discoverFeedUrl(siteUrl, settings, deadlineTs) {
|
||||
|
||||
function stripHtmlToText(input) {
|
||||
const raw = String(input || '');
|
||||
const withoutTags = raw.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
const withoutTags = raw
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ');
|
||||
|
||||
@@ -363,17 +390,14 @@ function normalizePublishedAt(item) {
|
||||
}
|
||||
|
||||
function normalizeArticle(item, sourceSite, settings) {
|
||||
const title = (item && item.title !== undefined) ? String(item.title).trim() : '';
|
||||
const title = item && item.title !== undefined ? String(item.title).trim() : '';
|
||||
if (!title) return null;
|
||||
|
||||
const link = item && item.link ? String(item.link).trim() : '';
|
||||
if (!isHttpUrl(link)) return null;
|
||||
|
||||
const summaryRaw =
|
||||
(item && item.contentSnippet) ||
|
||||
(item && item.summary) ||
|
||||
(item && item.content) ||
|
||||
'';
|
||||
(item && item.contentSnippet) || (item && item.summary) || (item && item.content) || '';
|
||||
const summaryText = stripHtmlToText(summaryRaw);
|
||||
const summary = settings.articles.summaryMaxLength
|
||||
? truncateText(summaryText, settings.articles.summaryMaxLength)
|
||||
@@ -393,7 +417,7 @@ function normalizeArticle(item, sourceSite, settings) {
|
||||
source,
|
||||
// 站点首页 URL(用于生成端按分类聚合展示;文章 url 为具体文章链接)
|
||||
sourceUrl,
|
||||
icon
|
||||
icon,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -406,13 +430,17 @@ async function fetchAndParseFeed(feedUrl, settings, parser, deadlineTs) {
|
||||
maxRedirects: settings.fetch.maxRedirects,
|
||||
headers: {
|
||||
...buildHeaders(settings.fetch.userAgent),
|
||||
accept: 'application/rss+xml,application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8'
|
||||
accept: 'application/rss+xml,application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
maxBytes: settings.fetch.feedMaxBytes
|
||||
maxBytes: settings.fetch.feedMaxBytes,
|
||||
});
|
||||
|
||||
const parsed = await parser.parseString(feed.text);
|
||||
return { feedUrl: feed.url, feedTitle: parsed.title || '', items: Array.isArray(parsed.items) ? parsed.items : [] };
|
||||
return {
|
||||
feedUrl: feed.url,
|
||||
feedTitle: parsed.title || '',
|
||||
items: Array.isArray(parsed.items) ? parsed.items : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
||||
@@ -425,18 +453,18 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
||||
feedUrl: '',
|
||||
status: 'skipped',
|
||||
error: '无效 URL(需为 http/https)',
|
||||
fetchedAt: new Date().toISOString()
|
||||
fetchedAt: new Date().toISOString(),
|
||||
},
|
||||
articles: []
|
||||
articles: [],
|
||||
};
|
||||
}
|
||||
|
||||
let lastError = null;
|
||||
|
||||
const tryOnce = async feedUrl => {
|
||||
const tryOnce = async (feedUrl) => {
|
||||
const parsed = await fetchAndParseFeed(feedUrl, settings, parser, deadlineTs);
|
||||
const normalized = parsed.items
|
||||
.map(item => normalizeArticle(item, sourceSite, settings))
|
||||
.map((item) => normalizeArticle(item, sourceSite, settings))
|
||||
.filter(Boolean)
|
||||
.slice(0, settings.articles.perSite);
|
||||
return { feedUrl: parsed.feedUrl, articles: normalized };
|
||||
@@ -444,7 +472,9 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
||||
|
||||
const attempt = async () => {
|
||||
const discovered = await discoverFeedUrl(url, settings, deadlineTs);
|
||||
const candidates = discovered ? [discovered, ...buildCommonFeedUrls(url)] : buildCommonFeedUrls(url);
|
||||
const candidates = discovered
|
||||
? [discovered, ...buildCommonFeedUrls(url)]
|
||||
: buildCommonFeedUrls(url);
|
||||
|
||||
for (const candidate of [...new Set(candidates)]) {
|
||||
try {
|
||||
@@ -471,9 +501,9 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
||||
status: 'success',
|
||||
error: '',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
durationMs: Date.now() - startedAt
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
articles: res.articles
|
||||
articles: res.articles,
|
||||
};
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
@@ -488,9 +518,9 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) {
|
||||
status: 'failed',
|
||||
error: lastError ? String(lastError.message || lastError) : '未知错误',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
durationMs: Date.now() - startedAt
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
articles: []
|
||||
articles: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -524,12 +554,15 @@ async function mapWithConcurrency(items, concurrency, worker) {
|
||||
function collectSitesRecursively(node, output) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
if (Array.isArray(node.subcategories)) node.subcategories.forEach(child => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.groups)) node.groups.forEach(child => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.subgroups)) node.subgroups.forEach(child => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.subcategories))
|
||||
node.subcategories.forEach((child) => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.groups))
|
||||
node.groups.forEach((child) => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.subgroups))
|
||||
node.subgroups.forEach((child) => collectSitesRecursively(child, output));
|
||||
|
||||
if (Array.isArray(node.sites)) {
|
||||
node.sites.forEach(site => {
|
||||
node.sites.forEach((site) => {
|
||||
if (site && typeof site === 'object') output.push(site);
|
||||
});
|
||||
}
|
||||
@@ -538,26 +571,26 @@ function collectSitesRecursively(node, output) {
|
||||
function buildFlatSitesFromCategories(categories) {
|
||||
const out = [];
|
||||
if (!Array.isArray(categories)) return out;
|
||||
categories.forEach(category => collectSitesRecursively(category, out));
|
||||
categories.forEach((category) => collectSitesRecursively(category, out));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
||||
const sourceSites = Array.isArray(pageConfig && pageConfig.sites)
|
||||
? pageConfig.sites
|
||||
: buildFlatSitesFromCategories(pageConfig && Array.isArray(pageConfig.categories) ? pageConfig.categories : []);
|
||||
: buildFlatSitesFromCategories(
|
||||
pageConfig && Array.isArray(pageConfig.categories) ? pageConfig.categories : []
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const deadlineTs = startedAt + settings.fetch.totalTimeoutMs;
|
||||
|
||||
const parser = new Parser({
|
||||
timeout: settings.fetch.timeoutMs
|
||||
timeout: settings.fetch.timeoutMs,
|
||||
});
|
||||
|
||||
const results = await mapWithConcurrency(
|
||||
sourceSites,
|
||||
settings.fetch.concurrency,
|
||||
async site => processSourceSite(site, settings, parser, deadlineTs)
|
||||
const results = await mapWithConcurrency(sourceSites, settings.fetch.concurrency, async (site) =>
|
||||
processSourceSite(site, settings, parser, deadlineTs)
|
||||
);
|
||||
|
||||
const sites = [];
|
||||
@@ -585,9 +618,9 @@ async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
||||
|
||||
const limitedArticles = articles.slice(0, settings.articles.total);
|
||||
|
||||
const successSites = sites.filter(s => s.status === 'success').length;
|
||||
const failedSites = sites.filter(s => s.status === 'failed').length;
|
||||
const skippedSites = sites.filter(s => s.status === 'skipped').length;
|
||||
const successSites = sites.filter((s) => s.status === 'success').length;
|
||||
const failedSites = sites.filter((s) => s.status === 'failed').length;
|
||||
const skippedSites = sites.filter((s) => s.status === 'skipped').length;
|
||||
|
||||
const cache = {
|
||||
version: '1.0',
|
||||
@@ -602,8 +635,8 @@ async function syncArticlesForPage(pageId, pageConfig, config, settings) {
|
||||
failedSites,
|
||||
skippedSites,
|
||||
totalArticles: limitedArticles.length,
|
||||
durationMs: Date.now() - startedAt
|
||||
}
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
};
|
||||
|
||||
const cacheDir = path.resolve(process.cwd(), settings.cacheDir);
|
||||
@@ -638,7 +671,7 @@ function pickArticlesPages(config, onlyPageId) {
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const pageArgIndex = args.findIndex(a => a === '--page');
|
||||
const pageArgIndex = args.findIndex((a) => a === '--page');
|
||||
const onlyPageId = pageArgIndex >= 0 ? args[pageArgIndex + 1] : null;
|
||||
|
||||
const config = loadConfig();
|
||||
@@ -661,7 +694,9 @@ async function main() {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { cachePath, cache } = await syncArticlesForPage(pageId, pageConfig, config, settings);
|
||||
console.log(`[INFO] 已生成缓存:${cachePath}(articles=${cache.stats.totalArticles}, sites=${cache.stats.totalSites})`);
|
||||
console.log(
|
||||
`[INFO] 已生成缓存:${cachePath}(articles=${cache.stats.totalArticles}, sites=${cache.stats.totalSites})`
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`[WARN] 页面 ${pageId} 同步失败:${e.message || e}`);
|
||||
// best-effort:不阻断其他页面/后续 build
|
||||
@@ -670,7 +705,7 @@ async function main() {
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(err => {
|
||||
main().catch((err) => {
|
||||
console.error('[ERROR] sync-articles 执行失败:', err);
|
||||
// best-effort:不阻断后续 build/deploy(错误已输出到日志,便于排查)
|
||||
process.exitCode = 0;
|
||||
@@ -683,5 +718,5 @@ module.exports = {
|
||||
extractFeedLinksFromHtml,
|
||||
stripHtmlToText,
|
||||
normalizeArticle,
|
||||
buildFlatSitesFromCategories
|
||||
buildFlatSitesFromCategories,
|
||||
};
|
||||
|
||||
@@ -10,12 +10,12 @@ const DEFAULT_SETTINGS = {
|
||||
fetch: {
|
||||
timeoutMs: 10_000,
|
||||
concurrency: 4,
|
||||
userAgent: 'MeNavProjectsSync/1.0'
|
||||
userAgent: 'MeNavProjectsSync/1.0',
|
||||
},
|
||||
colors: {
|
||||
url: 'https://raw.githubusercontent.com/ozh/github-colors/master/colors.json',
|
||||
maxAgeMs: 7 * 24 * 60 * 60 * 1000
|
||||
}
|
||||
maxAgeMs: 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
};
|
||||
|
||||
function parseBooleanEnv(value, fallback) {
|
||||
@@ -34,25 +34,35 @@ function parseIntegerEnv(value, fallback) {
|
||||
|
||||
function getSettings(config) {
|
||||
const fromConfig =
|
||||
config && config.site && config.site.github && typeof config.site.github === 'object' ? config.site.github : {};
|
||||
config && config.site && config.site.github && typeof config.site.github === 'object'
|
||||
? config.site.github
|
||||
: {};
|
||||
|
||||
const merged = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...fromConfig,
|
||||
fetch: {
|
||||
...DEFAULT_SETTINGS.fetch,
|
||||
...(fromConfig.fetch || {})
|
||||
...(fromConfig.fetch || {}),
|
||||
},
|
||||
colors: {
|
||||
...DEFAULT_SETTINGS.colors,
|
||||
...(fromConfig.colors || {})
|
||||
}
|
||||
...(fromConfig.colors || {}),
|
||||
},
|
||||
};
|
||||
|
||||
merged.enabled = parseBooleanEnv(process.env.PROJECTS_ENABLED, merged.enabled);
|
||||
merged.cacheDir = process.env.PROJECTS_CACHE_DIR ? String(process.env.PROJECTS_CACHE_DIR) : merged.cacheDir;
|
||||
merged.fetch.timeoutMs = parseIntegerEnv(process.env.PROJECTS_FETCH_TIMEOUT, merged.fetch.timeoutMs);
|
||||
merged.fetch.concurrency = parseIntegerEnv(process.env.PROJECTS_FETCH_CONCURRENCY, merged.fetch.concurrency);
|
||||
merged.cacheDir = process.env.PROJECTS_CACHE_DIR
|
||||
? String(process.env.PROJECTS_CACHE_DIR)
|
||||
: merged.cacheDir;
|
||||
merged.fetch.timeoutMs = parseIntegerEnv(
|
||||
process.env.PROJECTS_FETCH_TIMEOUT,
|
||||
merged.fetch.timeoutMs
|
||||
);
|
||||
merged.fetch.concurrency = parseIntegerEnv(
|
||||
process.env.PROJECTS_FETCH_CONCURRENCY,
|
||||
merged.fetch.concurrency
|
||||
);
|
||||
|
||||
merged.fetch.timeoutMs = Math.max(1_000, merged.fetch.timeoutMs);
|
||||
merged.fetch.concurrency = Math.max(1, Math.min(10, merged.fetch.concurrency));
|
||||
@@ -83,16 +93,19 @@ function isGithubRepoUrl(url) {
|
||||
|
||||
function collectSitesRecursively(node, output) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node.subcategories)) node.subcategories.forEach(child => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.groups)) node.groups.forEach(child => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.subgroups)) node.subgroups.forEach(child => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.sites)) node.sites.forEach(site => output.push(site));
|
||||
if (Array.isArray(node.subcategories))
|
||||
node.subcategories.forEach((child) => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.groups))
|
||||
node.groups.forEach((child) => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.subgroups))
|
||||
node.subgroups.forEach((child) => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.sites)) node.sites.forEach((site) => output.push(site));
|
||||
}
|
||||
|
||||
function findProjectsPages(config) {
|
||||
const pages = [];
|
||||
const nav = Array.isArray(config.navigation) ? config.navigation : [];
|
||||
nav.forEach(item => {
|
||||
nav.forEach((item) => {
|
||||
const pageId = item && item.id ? String(item.id) : '';
|
||||
if (!pageId || !config[pageId]) return;
|
||||
const page = config[pageId];
|
||||
@@ -131,13 +144,18 @@ async function loadLanguageColors(settings, cacheBaseDir) {
|
||||
|
||||
try {
|
||||
const headers = { 'user-agent': settings.fetch.userAgent, accept: 'application/json' };
|
||||
const colors = await fetchJsonWithTimeout(settings.colors.url, { timeoutMs: settings.fetch.timeoutMs, headers });
|
||||
const colors = await fetchJsonWithTimeout(settings.colors.url, {
|
||||
timeoutMs: settings.fetch.timeoutMs,
|
||||
headers,
|
||||
});
|
||||
if (colors && typeof colors === 'object') {
|
||||
fs.writeFileSync(cachePath, JSON.stringify(colors, null, 2), 'utf8');
|
||||
return colors;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[WARN] 获取语言颜色表失败(将不输出 languageColor):${String(error && error.message ? error.message : error)}`);
|
||||
console.warn(
|
||||
`[WARN] 获取语言颜色表失败(将不输出 languageColor):${String(error && error.message ? error.message : error)}`
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
@@ -146,7 +164,7 @@ async function loadLanguageColors(settings, cacheBaseDir) {
|
||||
async function fetchRepoMeta(repo, settings, colors) {
|
||||
const headers = {
|
||||
'user-agent': settings.fetch.userAgent,
|
||||
accept: 'application/vnd.github+json'
|
||||
accept: 'application/vnd.github+json',
|
||||
};
|
||||
|
||||
const apiUrl = `https://api.github.com/repos/${repo.owner}/${repo.repo}`;
|
||||
@@ -167,7 +185,7 @@ async function fetchRepoMeta(repo, settings, colors) {
|
||||
language,
|
||||
languageColor,
|
||||
stars,
|
||||
forks
|
||||
forks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,7 +217,9 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheBaseDir = path.isAbsolute(settings.cacheDir) ? settings.cacheDir : path.join(process.cwd(), settings.cacheDir);
|
||||
const cacheBaseDir = path.isAbsolute(settings.cacheDir)
|
||||
? settings.cacheDir
|
||||
: path.join(process.cwd(), settings.cacheDir);
|
||||
ensureDir(cacheBaseDir);
|
||||
|
||||
const colors = await loadLanguageColors(settings, cacheBaseDir);
|
||||
@@ -213,14 +233,14 @@ async function main() {
|
||||
for (const { pageId, page } of pages) {
|
||||
const categories = Array.isArray(page.categories) ? page.categories : [];
|
||||
const sites = [];
|
||||
categories.forEach(category => collectSitesRecursively(category, sites));
|
||||
categories.forEach((category) => collectSitesRecursively(category, sites));
|
||||
|
||||
const repos = sites
|
||||
.map(site => (site && site.url ? isGithubRepoUrl(site.url) : null))
|
||||
.map((site) => (site && site.url ? isGithubRepoUrl(site.url) : null))
|
||||
.filter(Boolean);
|
||||
|
||||
const unique = new Map();
|
||||
repos.forEach(r => unique.set(r.canonicalUrl, r));
|
||||
repos.forEach((r) => unique.set(r.canonicalUrl, r));
|
||||
const repoList = Array.from(unique.values());
|
||||
|
||||
if (!repoList.length) {
|
||||
@@ -231,14 +251,16 @@ async function main() {
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
const results = await runPool(repoList, settings.fetch.concurrency, async repo => {
|
||||
const results = await runPool(repoList, settings.fetch.concurrency, async (repo) => {
|
||||
try {
|
||||
const meta = await fetchRepoMeta(repo, settings, colors);
|
||||
success += 1;
|
||||
return meta;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.warn(`[WARN] 拉取失败:${repo.canonicalUrl}(${String(error && error.message ? error.message : error)})`);
|
||||
console.warn(
|
||||
`[WARN] 拉取失败:${repo.canonicalUrl}(${String(error && error.message ? error.message : error)})`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
@@ -251,19 +273,20 @@ async function main() {
|
||||
stats: {
|
||||
totalRepos: repoList.length,
|
||||
success,
|
||||
failed
|
||||
}
|
||||
failed,
|
||||
},
|
||||
};
|
||||
|
||||
const cachePath = path.join(cacheBaseDir, `${pageId}.repo-cache.json`);
|
||||
fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
|
||||
console.log(`[INFO] 页面 ${pageId}:同步完成(成功 ${success} / 失败 ${failed}),写入缓存 ${cachePath}`);
|
||||
console.log(
|
||||
`[INFO] 页面 ${pageId}:同步完成(成功 ${success} / 失败 ${failed}),写入缓存 ${cachePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
main().catch((error) => {
|
||||
console.error('[ERROR] projects 同步异常:', error);
|
||||
process.exitCode = 0; // best-effort:不阻断后续 build
|
||||
});
|
||||
|
||||
|
||||
@@ -25,12 +25,16 @@ function ensureUserConfigInitialized() {
|
||||
|
||||
if (fs.existsSync(CONFIG_DEFAULT_DIR)) {
|
||||
fs.cpSync(CONFIG_DEFAULT_DIR, CONFIG_USER_DIR, { recursive: true });
|
||||
console.log('[INFO] 检测到 config/user 不存在,已从 config/_default 初始化用户配置(完全替换策略需要完整配置)。');
|
||||
console.log(
|
||||
'[INFO] 检测到 config/user 不存在,已从 config/_default 初始化用户配置(完全替换策略需要完整配置)。'
|
||||
);
|
||||
return { initialized: true, source: '_default' };
|
||||
}
|
||||
|
||||
fs.mkdirSync(CONFIG_USER_DIR, { recursive: true });
|
||||
console.log('[WARN] 未找到默认配置目录 config/_default,已创建空的 config/user 目录;建议补齐 site.yml 与 pages/*.yml。');
|
||||
console.log(
|
||||
'[WARN] 未找到默认配置目录 config/_default,已创建空的 config/user 目录;建议补齐 site.yml 与 pages/*.yml。'
|
||||
);
|
||||
return { initialized: true, source: 'empty' };
|
||||
}
|
||||
|
||||
@@ -48,7 +52,9 @@ function ensureUserSiteYmlExists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[WARN] 未找到可用的 site.yml,无法自动更新导航;请手动在 config/user/site.yml 添加 navigation(含 id: bookmarks)。');
|
||||
console.log(
|
||||
'[WARN] 未找到可用的 site.yml,无法自动更新导航;请手动在 config/user/site.yml 添加 navigation(含 id: bookmarks)。'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,7 +69,7 @@ function upsertBookmarksNavInSiteYml(siteYmlPath) {
|
||||
|
||||
const navigation = loaded.navigation;
|
||||
|
||||
if (Array.isArray(navigation) && navigation.some(item => item && item.id === 'bookmarks')) {
|
||||
if (Array.isArray(navigation) && navigation.some((item) => item && item.id === 'bookmarks')) {
|
||||
return { updated: false, reason: 'already_present' };
|
||||
}
|
||||
|
||||
@@ -72,7 +78,7 @@ function upsertBookmarksNavInSiteYml(siteYmlPath) {
|
||||
}
|
||||
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const navLineIndex = lines.findIndex(line => /^navigation\s*:/.test(line));
|
||||
const navLineIndex = lines.findIndex((line) => /^navigation\s*:/.test(line));
|
||||
|
||||
const itemIndent = ' ';
|
||||
const propIndent = `${itemIndent} `;
|
||||
@@ -149,15 +155,15 @@ const ICON_MAPPING = {
|
||||
'netflix.com': 'fas fa-film',
|
||||
'trello.com': 'fab fa-trello',
|
||||
'wordpress.com': 'fab fa-wordpress',
|
||||
'jira': 'fab fa-jira',
|
||||
jira: 'fab fa-jira',
|
||||
'atlassian.com': 'fab fa-atlassian',
|
||||
'dropbox.com': 'fab fa-dropbox',
|
||||
'npm': 'fab fa-npm',
|
||||
npm: 'fab fa-npm',
|
||||
'docker.com': 'fab fa-docker',
|
||||
'python.org': 'fab fa-python',
|
||||
'javascript': 'fab fa-js',
|
||||
javascript: 'fab fa-js',
|
||||
'php.net': 'fab fa-php',
|
||||
'java': 'fab fa-java',
|
||||
java: 'fab fa-java',
|
||||
'codepen.io': 'fab fa-codepen',
|
||||
'behance.net': 'fab fa-behance',
|
||||
'dribbble.com': 'fab fa-dribbble',
|
||||
@@ -166,10 +172,10 @@ const ICON_MAPPING = {
|
||||
'flickr.com': 'fab fa-flickr',
|
||||
'github.io': 'fab fa-github',
|
||||
'airbnb.com': 'fab fa-airbnb',
|
||||
'bitcoin': 'fab fa-bitcoin',
|
||||
bitcoin: 'fab fa-bitcoin',
|
||||
'paypal.com': 'fab fa-paypal',
|
||||
'ethereum': 'fab fa-ethereum',
|
||||
'steam': 'fab fa-steam',
|
||||
ethereum: 'fab fa-ethereum',
|
||||
steam: 'fab fa-steam',
|
||||
};
|
||||
|
||||
// 获取最新的书签文件
|
||||
@@ -183,8 +189,9 @@ function getLatestBookmarkFile() {
|
||||
}
|
||||
|
||||
// 获取目录中的所有HTML文件
|
||||
const files = fs.readdirSync(BOOKMARKS_DIR)
|
||||
.filter(file => file.toLowerCase().endsWith('.html'));
|
||||
const files = fs
|
||||
.readdirSync(BOOKMARKS_DIR)
|
||||
.filter((file) => file.toLowerCase().endsWith('.html'));
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('[WARN] 未找到任何HTML书签文件');
|
||||
@@ -192,9 +199,9 @@ function getLatestBookmarkFile() {
|
||||
}
|
||||
|
||||
// 获取文件状态,按最后修改时间排序
|
||||
const fileStats = files.map(file => ({
|
||||
const fileStats = files.map((file) => ({
|
||||
file,
|
||||
mtime: fs.statSync(path.join(BOOKMARKS_DIR, file)).mtime
|
||||
mtime: fs.statSync(path.join(BOOKMARKS_DIR, file)).mtime,
|
||||
}));
|
||||
|
||||
// 找出最新的文件
|
||||
@@ -213,20 +220,21 @@ function getLatestBookmarkFile() {
|
||||
|
||||
// 解析书签HTML内容,支持2-4层级嵌套结构
|
||||
function parseBookmarks(htmlContent) {
|
||||
|
||||
// 正则表达式匹配文件夹和书签
|
||||
const folderRegex = /<DT><H3([^>]*)>(.*?)<\/H3>/g;
|
||||
const bookmarkRegex = /<DT><A HREF="([^"]+)"[^>]*>(.*?)<\/A>/g;
|
||||
|
||||
// 储存解析结果
|
||||
const bookmarks = {
|
||||
categories: []
|
||||
categories: [],
|
||||
};
|
||||
|
||||
// 提取根路径书签(书签栏容器内但不在任何子文件夹内的书签)
|
||||
function extractRootBookmarks(htmlContent) {
|
||||
// 找到书签栏文件夹标签
|
||||
const bookmarkBarMatch = htmlContent.match(/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i);
|
||||
const bookmarkBarMatch = htmlContent.match(
|
||||
/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i
|
||||
);
|
||||
if (!bookmarkBarMatch) {
|
||||
return [];
|
||||
}
|
||||
@@ -266,7 +274,10 @@ function parseBookmarks(htmlContent) {
|
||||
}
|
||||
}
|
||||
|
||||
const bookmarkBarContent = htmlContent.substring(bookmarkBarContentStart, bookmarkBarContentEnd);
|
||||
const bookmarkBarContent = htmlContent.substring(
|
||||
bookmarkBarContentStart,
|
||||
bookmarkBarContentEnd
|
||||
);
|
||||
|
||||
// 现在提取书签栏内所有子文件夹的范围
|
||||
const subfolderRanges = [];
|
||||
@@ -312,7 +323,7 @@ function parseBookmarks(htmlContent) {
|
||||
subfolderRanges.push({
|
||||
name: folderName,
|
||||
start: folderMatch.index,
|
||||
end: folderContentEnd
|
||||
end: folderContentEnd,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -337,7 +348,6 @@ function parseBookmarks(htmlContent) {
|
||||
}
|
||||
|
||||
if (!inFolder) {
|
||||
|
||||
// 基于URL选择适当的图标
|
||||
let icon = 'fas fa-link';
|
||||
for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) {
|
||||
@@ -351,7 +361,7 @@ function parseBookmarks(htmlContent) {
|
||||
name: name,
|
||||
url: url,
|
||||
icon: icon,
|
||||
description: ''
|
||||
description: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -401,7 +411,7 @@ function parseBookmarks(htmlContent) {
|
||||
name: folderName,
|
||||
start: folderStart,
|
||||
headerEnd: folderHeaderEnd,
|
||||
end: folderEnd
|
||||
end: folderEnd,
|
||||
});
|
||||
}
|
||||
pos += '</DL><p>'.length;
|
||||
@@ -451,7 +461,7 @@ function parseBookmarks(htmlContent) {
|
||||
const folder = {
|
||||
name: folderName,
|
||||
icon: 'fas fa-folder',
|
||||
path: [...parentPath, folderName]
|
||||
path: [...parentPath, folderName],
|
||||
};
|
||||
|
||||
// 检查是否包含子文件夹 - 创建新的正则实例避免干扰主循环
|
||||
@@ -484,10 +494,11 @@ function parseBookmarks(htmlContent) {
|
||||
}
|
||||
|
||||
// 只添加包含内容的文件夹
|
||||
const hasContent = folder.sites && folder.sites.length > 0 ||
|
||||
folder.subcategories && folder.subcategories.length > 0 ||
|
||||
folder.groups && folder.groups.length > 0 ||
|
||||
folder.subgroups && folder.subgroups.length > 0;
|
||||
const hasContent =
|
||||
(folder.sites && folder.sites.length > 0) ||
|
||||
(folder.subcategories && folder.subcategories.length > 0) ||
|
||||
(folder.groups && folder.groups.length > 0) ||
|
||||
(folder.subgroups && folder.subgroups.length > 0);
|
||||
|
||||
if (hasContent) {
|
||||
folders.push(folder);
|
||||
@@ -547,7 +558,7 @@ function parseBookmarks(htmlContent) {
|
||||
subfolderRanges.push({
|
||||
name: folderName,
|
||||
start: folderStart,
|
||||
end: folderContentEnd
|
||||
end: folderContentEnd,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -571,7 +582,6 @@ function parseBookmarks(htmlContent) {
|
||||
}
|
||||
|
||||
if (!inSubfolder) {
|
||||
|
||||
// 基于URL选择适当的图标
|
||||
let icon = 'fas fa-link'; // 默认图标
|
||||
for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) {
|
||||
@@ -585,7 +595,7 @@ function parseBookmarks(htmlContent) {
|
||||
name: name,
|
||||
url: url,
|
||||
icon: icon,
|
||||
description: ''
|
||||
description: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -597,7 +607,9 @@ function parseBookmarks(htmlContent) {
|
||||
const rootSites = extractRootBookmarks(htmlContent);
|
||||
|
||||
// 找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER)
|
||||
const bookmarkBarMatch = htmlContent.match(/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i);
|
||||
const bookmarkBarMatch = htmlContent.match(
|
||||
/<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i
|
||||
);
|
||||
if (!bookmarkBarMatch) {
|
||||
console.log('[WARN] 未找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER),使用备用方案');
|
||||
// 备用方案:使用第一个 <DL><p> 标签
|
||||
@@ -668,7 +680,10 @@ function parseBookmarks(htmlContent) {
|
||||
}
|
||||
}
|
||||
|
||||
const bookmarkBarContent = htmlContent.substring(bookmarkBarContentStart, bookmarkBarContentEnd);
|
||||
const bookmarkBarContent = htmlContent.substring(
|
||||
bookmarkBarContentStart,
|
||||
bookmarkBarContentEnd
|
||||
);
|
||||
|
||||
// 解析书签栏内的子文件夹作为顶层分类(跳过书签栏本身)
|
||||
bookmarks.categories = parseNestedFolder(bookmarkBarContent);
|
||||
@@ -684,7 +699,7 @@ function parseBookmarks(htmlContent) {
|
||||
name: '根目录书签',
|
||||
icon: 'fas fa-star',
|
||||
path: ['根目录书签'],
|
||||
sites: rootSites
|
||||
sites: rootSites,
|
||||
};
|
||||
|
||||
// 插入到数组首位
|
||||
@@ -702,14 +717,14 @@ function generateBookmarksYaml(bookmarks) {
|
||||
const bookmarksPage = {
|
||||
title: '我的书签',
|
||||
subtitle: '从浏览器导入的书签收藏',
|
||||
categories: bookmarks.categories
|
||||
categories: bookmarks.categories,
|
||||
};
|
||||
|
||||
// 转换为YAML
|
||||
const yamlString = yaml.dump(bookmarksPage, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
quotingType: '"'
|
||||
quotingType: '"',
|
||||
});
|
||||
|
||||
// 添加注释(可选确定性输出,方便版本管理)
|
||||
@@ -718,8 +733,7 @@ function generateBookmarksYaml(bookmarks) {
|
||||
? ''
|
||||
: `# 由bookmark-processor.js生成于 ${new Date().toISOString()}\n`;
|
||||
|
||||
const yamlWithComment =
|
||||
`# 自动生成的书签配置文件
|
||||
const yamlWithComment = `# 自动生成的书签配置文件
|
||||
${timestampLine}# 若要更新,请将新的书签HTML文件放入bookmarks/目录
|
||||
# 此文件使用模块化配置格式,位于config/user/pages/目录下
|
||||
|
||||
@@ -822,14 +836,15 @@ async function main() {
|
||||
} else if (navUpdateResult.reason === 'already_present') {
|
||||
console.log('[INFO] 导航配置已包含书签入口,无需更新\n');
|
||||
} else if (navUpdateResult.reason === 'no_navigation_config') {
|
||||
console.log('[WARN] 未找到可更新的导航配置文件;请确认 config/user/site.yml 存在且包含 navigation\n');
|
||||
console.log(
|
||||
'[WARN] 未找到可更新的导航配置文件;请确认 config/user/site.yml 存在且包含 navigation\n'
|
||||
);
|
||||
} else if (navUpdateResult.reason === 'error') {
|
||||
console.log('[WARN] 导航更新失败,请手动检查配置文件格式(详见错误信息)\n');
|
||||
console.error(navUpdateResult.error);
|
||||
} else {
|
||||
console.log('[INFO] 导航配置无需更新\n');
|
||||
}
|
||||
|
||||
} catch (writeError) {
|
||||
console.error(`[ERROR] 写入文件时出错:`, writeError);
|
||||
console.error('[ERROR] 错误堆栈:', writeError.stack);
|
||||
@@ -839,7 +854,6 @@ async function main() {
|
||||
console.log('========================================');
|
||||
console.log('[SUCCESS] 书签处理完成!');
|
||||
console.log('========================================');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FATAL] 处理书签文件时发生错误:', error);
|
||||
console.error('[ERROR] 错误堆栈:', error.stack);
|
||||
@@ -849,7 +863,7 @@ async function main() {
|
||||
|
||||
// 启动处理
|
||||
if (require.main === module) {
|
||||
main().catch(err => {
|
||||
main().catch((err) => {
|
||||
console.error('Unhandled error in bookmark processing:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
273
src/generator.js
273
src/generator.js
@@ -23,7 +23,7 @@ function loadHandlebarsTemplates() {
|
||||
// 加载布局模板
|
||||
const layoutsDir = path.join(templatesDir, 'layouts');
|
||||
if (fs.existsSync(layoutsDir)) {
|
||||
fs.readdirSync(layoutsDir).forEach(file => {
|
||||
fs.readdirSync(layoutsDir).forEach((file) => {
|
||||
if (file.endsWith('.hbs')) {
|
||||
const layoutName = path.basename(file, '.hbs');
|
||||
const layoutPath = path.join(layoutsDir, file);
|
||||
@@ -38,7 +38,7 @@ function loadHandlebarsTemplates() {
|
||||
// 加载组件模板
|
||||
const componentsDir = path.join(templatesDir, 'components');
|
||||
if (fs.existsSync(componentsDir)) {
|
||||
fs.readdirSync(componentsDir).forEach(file => {
|
||||
fs.readdirSync(componentsDir).forEach((file) => {
|
||||
if (file.endsWith('.hbs')) {
|
||||
const componentName = path.basename(file, '.hbs');
|
||||
const componentPath = path.join(componentsDir, file);
|
||||
@@ -76,7 +76,7 @@ function getDefaultLayoutTemplate() {
|
||||
|
||||
return {
|
||||
path: defaultLayoutPath,
|
||||
template: layoutTemplate
|
||||
template: layoutTemplate,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Error loading default layout template: ${error.message}`);
|
||||
@@ -100,7 +100,7 @@ function renderTemplate(templateName, data, useLayout = true) {
|
||||
// 添加 pageId 到数据中,以便通用模板使用(优先保留原 pageId,避免回退时语义错位)
|
||||
const enhancedData = {
|
||||
...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 = {
|
||||
...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}`);
|
||||
}
|
||||
} 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 = {
|
||||
...data,
|
||||
body: pageContent
|
||||
body: pageContent,
|
||||
};
|
||||
|
||||
// 渲染完整页面
|
||||
@@ -169,11 +171,11 @@ function escapeHtml(unsafe) {
|
||||
return '';
|
||||
}
|
||||
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) {
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -234,7 +238,7 @@ function loadModularConfig(dirPath) {
|
||||
fonts: {},
|
||||
profile: {},
|
||||
social: [],
|
||||
categories: []
|
||||
categories: [],
|
||||
};
|
||||
|
||||
// 加载基础配置
|
||||
@@ -261,10 +265,11 @@ function loadModularConfig(dirPath) {
|
||||
// 加载页面配置
|
||||
const pagesPath = path.join(dirPath, 'pages');
|
||||
if (fs.existsSync(pagesPath)) {
|
||||
const files = fs.readdirSync(pagesPath).filter(file =>
|
||||
file.endsWith('.yml') || file.endsWith('.yaml'));
|
||||
const files = fs
|
||||
.readdirSync(pagesPath)
|
||||
.filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
|
||||
|
||||
files.forEach(file => {
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(pagesPath, file);
|
||||
const fileConfig = safeLoadYamlConfig(filePath);
|
||||
|
||||
@@ -321,7 +326,7 @@ function ensureConfigDefaults(config) {
|
||||
result.site.theme = result.site.theme || {
|
||||
primary: '#4a89dc',
|
||||
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];
|
||||
// 检查是否是页面配置对象
|
||||
if (!pageConfig || typeof pageConfig !== 'object') return;
|
||||
@@ -408,7 +413,8 @@ function getSubmenuForNavItem(navItem, config) {
|
||||
}
|
||||
|
||||
// 通用处理:任意页面的子菜单生成(基于 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;
|
||||
}
|
||||
@@ -440,7 +446,7 @@ function makeUniqueSlug(base, usedSlugs) {
|
||||
function assignCategorySlugs(categories, usedSlugs) {
|
||||
if (!Array.isArray(categories)) return;
|
||||
|
||||
categories.forEach(category => {
|
||||
categories.forEach((category) => {
|
||||
if (!category || typeof category !== 'object') return;
|
||||
|
||||
const base = makeCategorySlugBase(category.name);
|
||||
@@ -504,11 +510,10 @@ function tryGetGitLastCommitIso(filePath) {
|
||||
|
||||
try {
|
||||
const relativePath = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['log', '-1', '--format=%cI', '--', relativePath],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
);
|
||||
const output = execFileSync('git', ['log', '-1', '--format=%cI', '--', relativePath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const raw = String(output || '').trim();
|
||||
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 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 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 items = articles
|
||||
.map(a => {
|
||||
.map((a) => {
|
||||
const title = a && a.title ? String(a.title) : '';
|
||||
const url = a && a.url ? String(a.url) : '';
|
||||
if (!title || !url) return null;
|
||||
@@ -607,7 +614,7 @@ function tryLoadArticlesFeedCache(pageId, config) {
|
||||
sourceUrl: a && a.sourceUrl ? String(a.sourceUrl) : '',
|
||||
|
||||
// 文章链接通常应在新标签页打开
|
||||
external: true
|
||||
external: true,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
@@ -617,8 +624,11 @@ function tryLoadArticlesFeedCache(pageId, config) {
|
||||
meta: {
|
||||
pageId: parsed.pageId || pageId,
|
||||
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) {
|
||||
console.warn(`[WARN] articles 缓存读取失败:${cachePath}(将回退 Phase 1)`);
|
||||
@@ -643,12 +653,15 @@ function normalizeUrlKey(input) {
|
||||
function collectSitesRecursively(node, output) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
if (Array.isArray(node.subcategories)) node.subcategories.forEach(child => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.groups)) node.groups.forEach(child => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.subgroups)) node.subgroups.forEach(child => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.subcategories))
|
||||
node.subcategories.forEach((child) => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.groups))
|
||||
node.groups.forEach((child) => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.subgroups))
|
||||
node.subgroups.forEach((child) => collectSitesRecursively(child, output));
|
||||
|
||||
if (Array.isArray(node.sites)) {
|
||||
node.sites.forEach(site => {
|
||||
node.sites.forEach((site) => {
|
||||
if (site && typeof site === 'object') output.push(site);
|
||||
});
|
||||
}
|
||||
@@ -672,18 +685,18 @@ function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
|
||||
{
|
||||
name: '最新文章',
|
||||
icon: 'fas fa-rss',
|
||||
items: safeItems
|
||||
}
|
||||
items: safeItems,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const categoryIndex = safeCategories.map(category => {
|
||||
const categoryIndex = safeCategories.map((category) => {
|
||||
const sites = [];
|
||||
collectSitesRecursively(category, sites);
|
||||
|
||||
const siteUrlKeys = new Set();
|
||||
const siteNameKeys = new Set();
|
||||
sites.forEach(site => {
|
||||
sites.forEach((site) => {
|
||||
const urlKey = normalizeUrlKey(site && site.url ? String(site.url) : '');
|
||||
if (urlKey) siteUrlKeys.add(urlKey);
|
||||
const nameKey = site && site.name ? String(site.name).trim().toLowerCase() : '';
|
||||
@@ -696,16 +709,16 @@ function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
|
||||
const buckets = categoryIndex.map(() => []);
|
||||
const uncategorized = [];
|
||||
|
||||
safeItems.forEach(item => {
|
||||
safeItems.forEach((item) => {
|
||||
const sourceUrlKey = normalizeUrlKey(item && item.sourceUrl ? String(item.sourceUrl) : '');
|
||||
const sourceNameKey = item && item.source ? String(item.source).trim().toLowerCase() : '';
|
||||
|
||||
let matchedIndex = -1;
|
||||
if (sourceUrlKey) {
|
||||
matchedIndex = categoryIndex.findIndex(idx => idx.siteUrlKeys.has(sourceUrlKey));
|
||||
matchedIndex = categoryIndex.findIndex((idx) => idx.siteUrlKeys.has(sourceUrlKey));
|
||||
}
|
||||
if (matchedIndex < 0 && sourceNameKey) {
|
||||
matchedIndex = categoryIndex.findIndex(idx => idx.siteNameKeys.has(sourceNameKey));
|
||||
matchedIndex = categoryIndex.findIndex((idx) => idx.siteNameKeys.has(sourceNameKey));
|
||||
}
|
||||
|
||||
if (matchedIndex < 0) {
|
||||
@@ -719,14 +732,14 @@ function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
|
||||
const displayCategories = categoryIndex.map((idx, i) => ({
|
||||
name: idx.category && idx.category.name ? String(idx.category.name) : '未命名分类',
|
||||
icon: idx.category && idx.category.icon ? String(idx.category.icon) : 'fas fa-rss',
|
||||
items: buckets[i]
|
||||
items: buckets[i],
|
||||
}));
|
||||
|
||||
if (uncategorized.length > 0) {
|
||||
displayCategories.push({
|
||||
name: '其他',
|
||||
icon: 'fas fa-ellipsis-h',
|
||||
items: uncategorized
|
||||
items: uncategorized,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -736,9 +749,13 @@ function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
|
||||
function tryLoadProjectsRepoCache(pageId, config) {
|
||||
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 =
|
||||
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 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 map = new Map();
|
||||
repos.forEach(r => {
|
||||
repos.forEach((r) => {
|
||||
const url = r && r.url ? String(r.url) : '';
|
||||
if (!url) return;
|
||||
map.set(url, {
|
||||
language: r && r.language ? String(r.language) : '',
|
||||
languageColor: r && r.languageColor ? String(r.languageColor) : '',
|
||||
stars: Number.isFinite(r && r.stars) ? r.stars : null,
|
||||
forks: Number.isFinite(r && r.forks) ? r.forks : null
|
||||
forks: Number.isFinite(r && r.forks) ? r.forks : null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -767,8 +784,8 @@ function tryLoadProjectsRepoCache(pageId, config) {
|
||||
map,
|
||||
meta: {
|
||||
pageId: parsed.pageId || pageId,
|
||||
generatedAt: parsed.generatedAt || ''
|
||||
}
|
||||
generatedAt: parsed.generatedAt || '',
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
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.sites)) {
|
||||
node.sites.forEach(site => {
|
||||
node.sites.forEach((site) => {
|
||||
if (!site || typeof site !== 'object' || !site.url) return;
|
||||
const canonical = normalizeGithubRepoUrl(site.url);
|
||||
if (!canonical) return;
|
||||
@@ -833,7 +850,7 @@ function prepareRenderData(config) {
|
||||
renderData._meta = {
|
||||
generated_at: new Date(),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
generator: 'MeNav'
|
||||
generator: 'MeNav',
|
||||
};
|
||||
|
||||
// 确保navigation是数组
|
||||
@@ -849,7 +866,7 @@ function prepareRenderData(config) {
|
||||
...item,
|
||||
isActive: index === 0, // 默认第一项为活动项
|
||||
id: item.id || `nav-${index}`,
|
||||
active: index === 0 // 保持旧模板兼容(由顺序决定,不读取配置的 active 字段)
|
||||
active: index === 0, // 保持旧模板兼容(由顺序决定,不读取配置的 active 字段)
|
||||
};
|
||||
|
||||
// 使用辅助函数获取子菜单
|
||||
@@ -863,11 +880,12 @@ function prepareRenderData(config) {
|
||||
}
|
||||
|
||||
// 首页(默认页)规则: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 冲突)
|
||||
if (Array.isArray(renderData.navigation)) {
|
||||
renderData.navigation.forEach(navItem => {
|
||||
renderData.navigation.forEach((navItem) => {
|
||||
const pageConfig = renderData[navItem.id];
|
||||
if (pageConfig && Array.isArray(pageConfig.categories)) {
|
||||
assignCategorySlugs(pageConfig.categories, new Map());
|
||||
@@ -880,7 +898,7 @@ function prepareRenderData(config) {
|
||||
JSON.stringify({
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: renderData // 使用经过处理的renderData而不是原始config
|
||||
data: renderData, // 使用经过处理的renderData而不是原始config
|
||||
})
|
||||
);
|
||||
|
||||
@@ -903,7 +921,7 @@ function loadConfig() {
|
||||
navigation: [],
|
||||
fonts: {},
|
||||
profile: {},
|
||||
social: []
|
||||
social: [],
|
||||
};
|
||||
|
||||
// 检查模块化配置来源是否存在
|
||||
@@ -915,13 +933,17 @@ function loadConfig() {
|
||||
// 配置采用“完全替换”策略:一旦存在 config/user/,将不会回退到 config/_default/
|
||||
if (!fs.existsSync('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/,再按需修改。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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/,再按需修改。');
|
||||
}
|
||||
|
||||
@@ -933,7 +955,9 @@ function loadConfig() {
|
||||
} else {
|
||||
console.error('[ERROR] 未找到可用配置:缺少 config/user/ 或 config/_default/。');
|
||||
console.error('[ERROR] 本版本已不再支持旧版单文件配置(config.yml / config.yaml)。');
|
||||
console.error('[ERROR] 解决方法:使用模块化配置目录(建议从 config/_default/ 复制到 config/user/ 再修改)。');
|
||||
console.error(
|
||||
'[ERROR] 解决方法:使用模块化配置目录(建议从 config/_default/ 复制到 config/user/ 再修改)。'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -952,7 +976,8 @@ function loadConfig() {
|
||||
|
||||
// 生成导航菜单
|
||||
function generateNavigation(navigation, config) {
|
||||
return navigation.map(nav => {
|
||||
return navigation
|
||||
.map((nav) => {
|
||||
// 根据页面ID获取对应的子菜单项(分类)
|
||||
let submenuItems = '';
|
||||
|
||||
@@ -963,12 +988,16 @@ function generateNavigation(navigation, config) {
|
||||
if (submenu && Array.isArray(submenu)) {
|
||||
submenuItems = `
|
||||
<div class="submenu">
|
||||
${submenu.map(category => `
|
||||
${submenu
|
||||
.map(
|
||||
(category) => `
|
||||
<a href="#${category.name}" class="submenu-item" data-page="${nav.id}" data-category="${category.name}">
|
||||
<i class="${escapeHtml(category.icon)}"></i>
|
||||
<span>${escapeHtml(category.name)}</span>
|
||||
</a>
|
||||
`).join('')}
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -983,7 +1012,8 @@ function generateNavigation(navigation, config) {
|
||||
</a>
|
||||
${submenuItems}
|
||||
</div>`;
|
||||
}).join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 生成网站卡片HTML
|
||||
@@ -992,12 +1022,16 @@ function generateSiteCards(sites) {
|
||||
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 || '')}">
|
||||
<i class="${escapeHtml(site.icon || 'fas fa-link')}"></i>
|
||||
<h3>${escapeHtml(site.name || '未命名站点')}</h3>
|
||||
<p>${escapeHtml(site.description || '')}</p>
|
||||
</a>`).join('\n');
|
||||
</a>`
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 生成分类板块
|
||||
@@ -1010,13 +1044,17 @@ function generateCategories(categories) {
|
||||
</section>`;
|
||||
}
|
||||
|
||||
return categories.map(category => `
|
||||
return categories
|
||||
.map(
|
||||
(category) => `
|
||||
<section class="category" id="${escapeHtml(category.name)}">
|
||||
<h2><i class="${escapeHtml(category.icon)}"></i> ${escapeHtml(category.name)}</h2>
|
||||
<div class="sites-grid">
|
||||
${generateSiteCards(category.sites)}
|
||||
</div>
|
||||
</section>`).join('\n');
|
||||
</section>`
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 生成社交链接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')}">
|
||||
<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>
|
||||
</a>`).join('\n');
|
||||
</a>`
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 生成页面内容(包括首页和其他页面)
|
||||
@@ -1093,12 +1135,13 @@ ${generateCategories(data.categories)}`;
|
||||
// 生成搜索结果页面
|
||||
function generateSearchResultsPage(config) {
|
||||
// 获取所有导航页面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获取对应的图标和名称
|
||||
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 name = navItem ? navItem.name : pageId;
|
||||
|
||||
@@ -1107,7 +1150,8 @@ function generateSearchResultsPage(config) {
|
||||
<h2><i class="${escapeHtml(icon)}"></i> ${escapeHtml(name)}匹配项</h2>
|
||||
<div class="sites-grid"></div>
|
||||
</section>`;
|
||||
}).join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `
|
||||
<!-- 搜索结果页 -->
|
||||
@@ -1173,9 +1217,9 @@ function normalizeFontFamilyForCss(input) {
|
||||
|
||||
return raw
|
||||
.split(',')
|
||||
.map(part => part.trim())
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map(part => {
|
||||
.map((part) => {
|
||||
const unquoted = part.replace(/^['"]|['"]$/g, '').trim();
|
||||
if (!unquoted) return '';
|
||||
if (generics.has(unquoted)) return unquoted;
|
||||
@@ -1190,14 +1234,15 @@ function normalizeFontFamilyForCss(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;
|
||||
return 'system';
|
||||
}
|
||||
|
||||
function getNormalizedFontsConfig(config) {
|
||||
const fonts =
|
||||
config && config.fonts && typeof config.fonts === 'object' ? config.fonts : {};
|
||||
const fonts = config && config.fonts && typeof config.fonts === 'object' ? config.fonts : {};
|
||||
|
||||
return {
|
||||
source: normalizeFontSource(fonts.source),
|
||||
@@ -1271,7 +1316,9 @@ function generateFontCss(config) {
|
||||
}
|
||||
|
||||
function normalizeGithubHeatmapColor(input) {
|
||||
const raw = String(input || '').trim().replace(/^#/, '');
|
||||
const raw = String(input || '')
|
||||
.trim()
|
||||
.replace(/^#/, '');
|
||||
const color = raw.toLowerCase();
|
||||
if (/^[0-9a-f]{6}$/.test(color)) return color;
|
||||
if (/^[0-9a-f]{3}$/.test(color)) return color;
|
||||
@@ -1279,7 +1326,8 @@ function normalizeGithubHeatmapColor(input) {
|
||||
}
|
||||
|
||||
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()
|
||||
: '';
|
||||
return username;
|
||||
@@ -1299,8 +1347,8 @@ function buildProjectsMeta(config) {
|
||||
heatmap: {
|
||||
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 = {
|
||||
...(config || {}),
|
||||
currentPage: pageId,
|
||||
pageId // 同时保留pageId字段,用于通用模板
|
||||
pageId, // 同时保留pageId字段,用于通用模板
|
||||
};
|
||||
|
||||
// 确保navigation是数组
|
||||
@@ -1324,11 +1372,11 @@ function renderPage(pageId, config) {
|
||||
data.navigation = [];
|
||||
} else {
|
||||
// 设置当前页面为活动页,其他页面为非活动
|
||||
data.navigation = config.navigation.map(nav => {
|
||||
data.navigation = config.navigation.map((nav) => {
|
||||
const navItem = {
|
||||
...nav,
|
||||
isActive: nav.id === pageId,
|
||||
active: nav.id === pageId // 兼容原有逻辑
|
||||
active: nav.id === pageId, // 兼容原有逻辑
|
||||
};
|
||||
|
||||
// 使用辅助函数获取子菜单
|
||||
@@ -1355,7 +1403,9 @@ function renderPage(pageId, config) {
|
||||
|
||||
// 页面配置缺失时也尽量给出可用的默认值,避免渲染空标题/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 (data.subtitle === undefined) data.subtitle = '';
|
||||
@@ -1366,7 +1416,12 @@ function renderPage(pageId, config) {
|
||||
let templateName = explicitTemplate || pageId;
|
||||
// 未显式指定模板时:若 pages/<pageId>.hbs 不存在,则默认使用通用 page 模板(避免依赖回退日志)
|
||||
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)) {
|
||||
templateName = 'page';
|
||||
}
|
||||
@@ -1388,17 +1443,19 @@ function renderPage(pageId, config) {
|
||||
// 注意:模板名可能被统一为 page(例如 friends/home 取消专属模板后),因此这里同时按 pageId 判断。
|
||||
const isFriendsPage = pageId === 'friends' || templateName === 'friends';
|
||||
const isArticlesPage = pageId === 'articles' || templateName === 'articles';
|
||||
if ((isFriendsPage || isArticlesPage)
|
||||
&& (!Array.isArray(data.categories) || data.categories.length === 0)
|
||||
&& Array.isArray(data.sites)
|
||||
&& data.sites.length > 0) {
|
||||
if (
|
||||
(isFriendsPage || isArticlesPage) &&
|
||||
(!Array.isArray(data.categories) || data.categories.length === 0) &&
|
||||
Array.isArray(data.sites) &&
|
||||
data.sites.length > 0
|
||||
) {
|
||||
const implicitName = isFriendsPage ? '全部友链' : '全部来源';
|
||||
data.categories = [
|
||||
{
|
||||
name: implicitName,
|
||||
icon: 'fas fa-link',
|
||||
sites: data.sites
|
||||
}
|
||||
sites: data.sites,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1422,9 +1479,10 @@ function renderPage(pageId, config) {
|
||||
}
|
||||
|
||||
// 首页标题规则:使用 site.yml 的 profile 覆盖首页(导航第一项)的 title/subtitle 显示
|
||||
const homePageId = config.homePageId
|
||||
|| (Array.isArray(config.navigation) && config.navigation[0] ? config.navigation[0].id : null)
|
||||
|| 'home';
|
||||
const homePageId =
|
||||
config.homePageId ||
|
||||
(Array.isArray(config.navigation) && config.navigation[0] ? config.navigation[0].id : null) ||
|
||||
'home';
|
||||
// 供模板判断“当前是否首页”
|
||||
data.homePageId = homePageId;
|
||||
if (pageId === homePageId && config.profile) {
|
||||
@@ -1456,7 +1514,7 @@ function generateAllPagesHTML(config) {
|
||||
|
||||
// 渲染配置中定义的所有页面
|
||||
if (Array.isArray(config.navigation)) {
|
||||
config.navigation.forEach(navItem => {
|
||||
config.navigation.forEach((navItem) => {
|
||||
const pageId = navItem.id;
|
||||
|
||||
// 渲染页面内容
|
||||
@@ -1485,7 +1543,7 @@ function generateHTML(config) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// 准备导航数据,添加submenu字段
|
||||
const navigationData = config.navigation.map(nav => {
|
||||
const navigationData = config.navigation.map((nav) => {
|
||||
const navItem = { ...nav };
|
||||
|
||||
// 使用辅助函数获取子菜单
|
||||
@@ -1517,7 +1575,7 @@ function generateHTML(config) {
|
||||
social: Array.isArray(config.social) ? config.social : [], // 兼容旧版
|
||||
|
||||
// 确保配置数据可用于浏览器扩展
|
||||
configJSON: config.configJSON // 从prepareRenderData函数中获取的配置数据
|
||||
configJSON: config.configJSON, // 从prepareRenderData函数中获取的配置数据
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1617,14 +1675,14 @@ function copyStaticFiles(config) {
|
||||
};
|
||||
|
||||
if (config && Array.isArray(config.navigation)) {
|
||||
config.navigation.forEach(navItem => {
|
||||
config.navigation.forEach((navItem) => {
|
||||
const pageId = navItem && navItem.id ? String(navItem.id) : '';
|
||||
if (!pageId) return;
|
||||
const pageConfig = config[pageId];
|
||||
if (!pageConfig || typeof pageConfig !== 'object') return;
|
||||
|
||||
if (Array.isArray(pageConfig.sites)) {
|
||||
pageConfig.sites.forEach(site => {
|
||||
pageConfig.sites.forEach((site) => {
|
||||
if (!site || typeof site !== 'object') return;
|
||||
copyLocalAsset(site.faviconUrl);
|
||||
});
|
||||
@@ -1632,8 +1690,8 @@ function copyStaticFiles(config) {
|
||||
|
||||
if (Array.isArray(pageConfig.categories)) {
|
||||
const sites = [];
|
||||
pageConfig.categories.forEach(category => collectSitesRecursively(category, sites));
|
||||
sites.forEach(site => {
|
||||
pageConfig.categories.forEach((category) => collectSitesRecursively(category, sites));
|
||||
sites.forEach((site) => {
|
||||
if (!site || typeof site !== 'object') return;
|
||||
copyLocalAsset(site.faviconUrl);
|
||||
});
|
||||
@@ -1648,7 +1706,10 @@ function copyStaticFiles(config) {
|
||||
if (config.site.favicon) {
|
||||
try {
|
||||
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)) {
|
||||
fs.copyFileSync(config.site.favicon, `dist/${path.basename(config.site.favicon)}`);
|
||||
} else {
|
||||
@@ -1700,5 +1761,5 @@ module.exports = {
|
||||
generateCategories,
|
||||
loadHandlebarsTemplates,
|
||||
renderTemplate,
|
||||
generateAllPagesHTML
|
||||
generateAllPagesHTML,
|
||||
};
|
||||
|
||||
@@ -39,25 +39,25 @@ function ifNotEquals(v1, v2, options) {
|
||||
function ifCond(v1, operator, v2, options) {
|
||||
switch (operator) {
|
||||
case '==':
|
||||
return (v1 == v2) ? options.fn(this) : options.inverse(this);
|
||||
return v1 == v2 ? options.fn(this) : options.inverse(this);
|
||||
case '===':
|
||||
return (v1 === v2) ? options.fn(this) : options.inverse(this);
|
||||
return v1 === v2 ? options.fn(this) : options.inverse(this);
|
||||
case '!=':
|
||||
return (v1 != v2) ? options.fn(this) : options.inverse(this);
|
||||
return v1 != v2 ? options.fn(this) : options.inverse(this);
|
||||
case '!==':
|
||||
return (v1 !== v2) ? options.fn(this) : options.inverse(this);
|
||||
return v1 !== v2 ? options.fn(this) : options.inverse(this);
|
||||
case '<':
|
||||
return (v1 < v2) ? options.fn(this) : options.inverse(this);
|
||||
return v1 < v2 ? options.fn(this) : options.inverse(this);
|
||||
case '<=':
|
||||
return (v1 <= v2) ? options.fn(this) : options.inverse(this);
|
||||
return v1 <= v2 ? options.fn(this) : options.inverse(this);
|
||||
case '>':
|
||||
return (v1 > v2) ? options.fn(this) : options.inverse(this);
|
||||
return v1 > v2 ? options.fn(this) : options.inverse(this);
|
||||
case '>=':
|
||||
return (v1 >= v2) ? options.fn(this) : options.inverse(this);
|
||||
return v1 >= v2 ? options.fn(this) : options.inverse(this);
|
||||
case '&&':
|
||||
return (v1 && v2) ? options.fn(this) : options.inverse(this);
|
||||
return v1 && v2 ? options.fn(this) : options.inverse(this);
|
||||
case '||':
|
||||
return (v1 || v2) ? options.fn(this) : options.inverse(this);
|
||||
return v1 || v2 ? options.fn(this) : options.inverse(this);
|
||||
default:
|
||||
return options.inverse(this);
|
||||
}
|
||||
@@ -100,7 +100,7 @@ function isEmpty(value, options) {
|
||||
function isNotEmpty(value, options) {
|
||||
return isEmpty(value, {
|
||||
fn: options.inverse,
|
||||
inverse: options.fn
|
||||
inverse: options.fn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ function isNotEmpty(value, options) {
|
||||
* @example {{#and isPremium isActive}}高级活跃用户{{else}}其他用户{{/and}}
|
||||
*/
|
||||
function and(a, b, options) {
|
||||
return (a && b) ? options.fn(this) : options.inverse(this);
|
||||
return a && b ? options.fn(this) : options.inverse(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +125,7 @@ function and(a, b, options) {
|
||||
* @example {{#or isPremium isAdmin}}有权限{{else}}无权限{{/or}}
|
||||
*/
|
||||
function or(a, b, options) {
|
||||
return (a || b) ? options.fn(this) : options.inverse(this);
|
||||
return a || b ? options.fn(this) : options.inverse(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,5 +184,5 @@ module.exports = {
|
||||
or,
|
||||
orHelper,
|
||||
not,
|
||||
ifHttpUrl
|
||||
ifHttpUrl,
|
||||
};
|
||||
|
||||
@@ -123,5 +123,5 @@ module.exports = {
|
||||
toLowerCase,
|
||||
toUpperCase,
|
||||
json,
|
||||
extractDomain
|
||||
extractDomain,
|
||||
};
|
||||
@@ -29,20 +29,20 @@ function registerAllHelpers(handlebars) {
|
||||
});
|
||||
|
||||
// 注册HTML转义函数(作为助手函数,方便在模板中调用)
|
||||
handlebars.registerHelper('escapeHtml', function(text) {
|
||||
handlebars.registerHelper('escapeHtml', function (text) {
|
||||
if (text === undefined || text === null) {
|
||||
return '';
|
||||
}
|
||||
return String(text)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
});
|
||||
|
||||
// 注册非转义助手函数(安全输出HTML)
|
||||
handlebars.registerHelper('safeHtml', function(text) {
|
||||
handlebars.registerHelper('safeHtml', function (text) {
|
||||
if (text === undefined || text === null) {
|
||||
return '';
|
||||
}
|
||||
@@ -55,5 +55,5 @@ module.exports = {
|
||||
formatters,
|
||||
conditions,
|
||||
utils,
|
||||
registerAllHelpers
|
||||
registerAllHelpers,
|
||||
};
|
||||
@@ -36,7 +36,7 @@ function concat() {
|
||||
const options = args.pop(); // 最后一个参数是Handlebars的options对象
|
||||
|
||||
// 过滤掉非数组参数
|
||||
const validArrays = args.filter(arg => Array.isArray(arg));
|
||||
const validArrays = args.filter((arg) => Array.isArray(arg));
|
||||
|
||||
if (validArrays.length === 0) {
|
||||
return [];
|
||||
@@ -143,7 +143,7 @@ function pick() {
|
||||
|
||||
const result = {};
|
||||
|
||||
keys.forEach(key => {
|
||||
keys.forEach((key) => {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
@@ -274,15 +274,23 @@ function safeUrl(url, options) {
|
||||
options.data.root.site.security &&
|
||||
options.data.root.site.security.allowedSchemes;
|
||||
|
||||
const allowedSchemes = Array.isArray(allowedFromConfig) && allowedFromConfig.length > 0
|
||||
const allowedSchemes =
|
||||
Array.isArray(allowedFromConfig) && allowedFromConfig.length > 0
|
||||
? allowedFromConfig
|
||||
.map(s => String(s || '').trim().toLowerCase().replace(/:$/, ''))
|
||||
.map((s) =>
|
||||
String(s || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/:$/, '')
|
||||
)
|
||||
.filter(Boolean)
|
||||
: ['http', 'https', 'mailto', 'tel'];
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const scheme = String(parsed.protocol || '').toLowerCase().replace(/:$/, '');
|
||||
const scheme = String(parsed.protocol || '')
|
||||
.toLowerCase()
|
||||
.replace(/:$/, '');
|
||||
if (allowedSchemes.includes(scheme)) return raw;
|
||||
console.warn(`[WARN] 已拦截不安全 URL scheme:${raw}`);
|
||||
return '#';
|
||||
@@ -306,5 +314,5 @@ module.exports = {
|
||||
add,
|
||||
faviconV2Url,
|
||||
faviconFallbackUrl,
|
||||
safeUrl
|
||||
safeUrl,
|
||||
};
|
||||
|
||||
317
src/script.js
317
src/script.js
@@ -25,10 +25,19 @@ function menavGetAllowedUrlSchemes() {
|
||||
? window.MeNav.getConfig()
|
||||
: null;
|
||||
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) {
|
||||
return fromConfig
|
||||
.map(s => String(s || '').trim().toLowerCase().replace(/:$/, ''))
|
||||
.map((s) =>
|
||||
String(s || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/:$/, '')
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -39,7 +48,13 @@ function menavGetAllowedUrlSchemes() {
|
||||
|
||||
function menavIsRelativeUrl(url) {
|
||||
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) {
|
||||
@@ -57,7 +72,9 @@ function menavSanitizeUrl(rawUrl, contextLabel) {
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const scheme = String(parsed.protocol || '').toLowerCase().replace(/:$/, '');
|
||||
const scheme = String(parsed.protocol || '')
|
||||
.toLowerCase()
|
||||
.replace(/:$/, '');
|
||||
const allowed = menavGetAllowedUrlSchemes();
|
||||
if (allowed.includes(scheme)) return url;
|
||||
console.warn(`[MeNav][安全] 已拦截不安全 URL scheme:${contextLabel || ''}`, url);
|
||||
@@ -76,9 +93,9 @@ function menavSanitizeClassList(rawClassList, contextLabel) {
|
||||
|
||||
const tokens = input
|
||||
.split(/\s+/g)
|
||||
.map(t => t.trim())
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map(t => t.replace(/[^\w-]/g, ''))
|
||||
.map((t) => t.replace(/[^\w-]/g, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
const sanitized = tokens.join(' ');
|
||||
@@ -132,7 +149,7 @@ window.MeNav = {
|
||||
version: menavDetectVersion(),
|
||||
|
||||
// 获取配置数据
|
||||
getConfig: function(options) {
|
||||
getConfig: function (options) {
|
||||
const configData = document.getElementById('menav-config-data');
|
||||
if (!configData) return null;
|
||||
|
||||
@@ -154,7 +171,7 @@ window.MeNav = {
|
||||
},
|
||||
|
||||
// 获取元素的唯一标识符
|
||||
_getElementId: function(element) {
|
||||
_getElementId: function (element) {
|
||||
const type = element.getAttribute('data-type');
|
||||
if (type === 'nav-item') {
|
||||
return element.getAttribute('data-id');
|
||||
@@ -167,7 +184,7 @@ window.MeNav = {
|
||||
},
|
||||
|
||||
// 根据类型和ID查找元素
|
||||
_findElement: function(type, id) {
|
||||
_findElement: function (type, id) {
|
||||
let selector;
|
||||
if (type === 'nav-item') {
|
||||
selector = `[data-type="${type}"][data-id="${id}"]`;
|
||||
@@ -191,7 +208,7 @@ window.MeNav = {
|
||||
},
|
||||
|
||||
// 更新DOM元素
|
||||
updateElement: function(type, id, newData) {
|
||||
updateElement: function (type, id, newData) {
|
||||
const element = this._findElement(type, id);
|
||||
if (!element) return false;
|
||||
|
||||
@@ -231,10 +248,13 @@ window.MeNav = {
|
||||
|
||||
if (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;
|
||||
|
||||
@@ -242,7 +262,7 @@ window.MeNav = {
|
||||
this.events.emit('elementUpdated', {
|
||||
id: id,
|
||||
type: 'site',
|
||||
data: newData
|
||||
data: newData,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -253,7 +273,10 @@ window.MeNav = {
|
||||
if (titleElement) {
|
||||
const iconElement = titleElement.querySelector('i');
|
||||
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 注入
|
||||
titleElement.textContent = '';
|
||||
@@ -265,14 +288,17 @@ window.MeNav = {
|
||||
element.setAttribute('data-name', newData.name);
|
||||
}
|
||||
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', {
|
||||
id: id,
|
||||
type: 'category',
|
||||
data: newData
|
||||
data: newData,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -288,16 +314,22 @@ window.MeNav = {
|
||||
if (newData.icon) {
|
||||
const iconElement = element.querySelector('i');
|
||||
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', {
|
||||
id: id,
|
||||
type: 'nav-item',
|
||||
data: newData
|
||||
data: newData,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -319,16 +351,22 @@ window.MeNav = {
|
||||
if (newData.icon) {
|
||||
const iconElement = element.querySelector('i');
|
||||
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', {
|
||||
id: id,
|
||||
type: 'social-link',
|
||||
data: newData
|
||||
data: newData,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -338,7 +376,7 @@ window.MeNav = {
|
||||
},
|
||||
|
||||
// 添加新元素
|
||||
addElement: function(type, parentId, data) {
|
||||
addElement: function (type, parentId, data) {
|
||||
let parent;
|
||||
|
||||
if (type === 'site') {
|
||||
@@ -381,7 +419,8 @@ window.MeNav = {
|
||||
const siteIcon = data.icon || 'fas fa-link';
|
||||
const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : '');
|
||||
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 =
|
||||
siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon'
|
||||
? siteForceIconModeRaw
|
||||
@@ -392,7 +431,10 @@ window.MeNav = {
|
||||
|
||||
newSite.setAttribute('href', safeSiteUrl);
|
||||
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)) {
|
||||
newSite.target = '_blank';
|
||||
newSite.rel = 'noopener';
|
||||
@@ -431,12 +473,7 @@ window.MeNav = {
|
||||
newSite.appendChild(repoHeader);
|
||||
newSite.appendChild(repoDesc);
|
||||
|
||||
const hasStats =
|
||||
data &&
|
||||
(data.language ||
|
||||
data.stars ||
|
||||
data.forks ||
|
||||
data.issues);
|
||||
const hasStats = data && (data.language || data.stars || data.forks || data.issues);
|
||||
|
||||
if (hasStats) {
|
||||
const repoStats = document.createElement('div');
|
||||
@@ -571,12 +608,18 @@ window.MeNav = {
|
||||
iconContainer.appendChild(favicon);
|
||||
iconContainer.appendChild(fallback);
|
||||
iconWrapper.appendChild(iconContainer);
|
||||
} else if (effectiveIconsMode === 'favicon' && safeSiteUrl && /^https?:\/\//i.test(safeSiteUrl)) {
|
||||
} else if (
|
||||
effectiveIconsMode === 'favicon' &&
|
||||
safeSiteUrl &&
|
||||
/^https?:\/\//i.test(safeSiteUrl)
|
||||
) {
|
||||
// 根据 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.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.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`;
|
||||
|
||||
@@ -655,8 +698,6 @@ window.MeNav = {
|
||||
// 添加到DOM
|
||||
sitesGrid.appendChild(newSite);
|
||||
|
||||
|
||||
|
||||
// 移除"暂无网站"提示(如果存在)
|
||||
const emptyMessage = sitesGrid.querySelector('.empty-sites');
|
||||
if (emptyMessage) {
|
||||
@@ -668,7 +709,7 @@ window.MeNav = {
|
||||
id: siteName,
|
||||
type: 'site',
|
||||
parentId: parentId,
|
||||
data: data
|
||||
data: data,
|
||||
});
|
||||
|
||||
return siteName;
|
||||
@@ -684,14 +725,20 @@ window.MeNav = {
|
||||
// 设置数据属性
|
||||
newCategory.setAttribute('data-type', 'category');
|
||||
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');
|
||||
|
||||
// 添加内容(用 DOM API 构建,避免 innerHTML 注入)
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.setAttribute('data-editable', 'category-name');
|
||||
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(document.createTextNode(' ' + String(data.name || '未命名分类')));
|
||||
|
||||
@@ -713,7 +760,7 @@ window.MeNav = {
|
||||
this.events.emit('elementAdded', {
|
||||
id: data.name,
|
||||
type: 'category',
|
||||
data: data
|
||||
data: data,
|
||||
});
|
||||
|
||||
return data.name;
|
||||
@@ -723,7 +770,7 @@ window.MeNav = {
|
||||
},
|
||||
|
||||
// 删除元素
|
||||
removeElement: function(type, id) {
|
||||
removeElement: function (type, id) {
|
||||
const element = this._findElement(type, id);
|
||||
if (!element) return false;
|
||||
|
||||
@@ -743,20 +790,20 @@ window.MeNav = {
|
||||
this.events.emit('elementRemoved', {
|
||||
id: id,
|
||||
type: type,
|
||||
parentId: parentId
|
||||
parentId: parentId,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
// 获取所有元素
|
||||
getAllElements: function(type) {
|
||||
return Array.from(document.querySelectorAll(`[data-type="${type}"]`)).map(el => {
|
||||
getAllElements: function (type) {
|
||||
return Array.from(document.querySelectorAll(`[data-type="${type}"]`)).map((el) => {
|
||||
const id = this._getElementId(el);
|
||||
return {
|
||||
id: id,
|
||||
type: type,
|
||||
element: el
|
||||
element: el,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -766,7 +813,7 @@ window.MeNav = {
|
||||
listeners: {},
|
||||
|
||||
// 添加事件监听器
|
||||
on: function(event, callback) {
|
||||
on: function (event, callback) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
@@ -775,25 +822,25 @@ window.MeNav = {
|
||||
},
|
||||
|
||||
// 触发事件
|
||||
emit: function(event, data) {
|
||||
emit: function (event, data) {
|
||||
if (this.listeners[event]) {
|
||||
this.listeners[event].forEach(callback => callback(data));
|
||||
this.listeners[event].forEach((callback) => callback(data));
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
// 移除事件监听器
|
||||
off: function(event, callback) {
|
||||
off: function (event, callback) {
|
||||
if (this.listeners[event]) {
|
||||
if (callback) {
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
|
||||
this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback);
|
||||
} else {
|
||||
delete this.listeners[event];
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 多层级嵌套书签功能
|
||||
@@ -802,7 +849,9 @@ function getCollapsibleNestedContainers(root) {
|
||||
const headers = root.querySelectorAll(
|
||||
'.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) {
|
||||
@@ -819,20 +868,20 @@ function isNestedContainerCollapsible(container) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.MeNav.expandAll = function() {
|
||||
window.MeNav.expandAll = function () {
|
||||
const activePage = document.querySelector('.page.active');
|
||||
if (activePage) {
|
||||
getCollapsibleNestedContainers(activePage).forEach(element => {
|
||||
getCollapsibleNestedContainers(activePage).forEach((element) => {
|
||||
element.classList.remove('collapsed');
|
||||
saveToggleState(element, 'expanded');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.MeNav.collapseAll = function() {
|
||||
window.MeNav.collapseAll = function () {
|
||||
const activePage = document.querySelector('.page.active');
|
||||
if (activePage) {
|
||||
getCollapsibleNestedContainers(activePage).forEach(element => {
|
||||
getCollapsibleNestedContainers(activePage).forEach((element) => {
|
||||
element.classList.add('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');
|
||||
if (!activePage) return;
|
||||
|
||||
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;
|
||||
|
||||
// 如果收起的数量 >= 总数的一半,执行展开;否则执行收起
|
||||
@@ -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}"]`;
|
||||
|
||||
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 = [];
|
||||
document.querySelectorAll('.category-level-1').forEach(cat => {
|
||||
document.querySelectorAll('.category-level-1').forEach((cat) => {
|
||||
categories.push(extractNestedData(cat));
|
||||
});
|
||||
return categories;
|
||||
@@ -916,8 +972,8 @@ function toggleNestedElement(container) {
|
||||
element: container,
|
||||
type: container.dataset.type,
|
||||
name: container.dataset.name,
|
||||
isCollapsed: !isCollapsed
|
||||
}
|
||||
isCollapsed: !isCollapsed,
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
@@ -947,8 +1003,8 @@ function restoreToggleState(element) {
|
||||
// 初始化嵌套分类
|
||||
function initializeNestedCategories() {
|
||||
// 为所有可折叠元素添加切换功能
|
||||
document.querySelectorAll('[data-toggle="category"], [data-toggle="group"]').forEach(header => {
|
||||
header.addEventListener('click', function(e) {
|
||||
document.querySelectorAll('[data-toggle="category"], [data-toggle="group"]').forEach((header) => {
|
||||
header.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
const container = this.parentElement;
|
||||
toggleNestedElement(container);
|
||||
@@ -965,32 +1021,40 @@ function extractNestedData(element) {
|
||||
name: element.dataset.name,
|
||||
type: element.dataset.type,
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
data.sites = Array.from(sites).map(site => ({
|
||||
data.sites = Array.from(sites).map((site) => ({
|
||||
name: site.dataset.name,
|
||||
url: site.dataset.url,
|
||||
icon: site.dataset.icon,
|
||||
description: site.dataset.description
|
||||
description: site.dataset.description,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1004,14 +1068,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const homePageId = (() => {
|
||||
// 1) 优先从生成端注入的配置数据读取(保持与实际导航顺序一致)
|
||||
try {
|
||||
const config = window.MeNav && typeof window.MeNav.getConfig === 'function'
|
||||
const config =
|
||||
window.MeNav && typeof window.MeNav.getConfig === 'function'
|
||||
? window.MeNav.getConfig()
|
||||
: null;
|
||||
const injectedHomePageId = config && config.data && config.data.homePageId
|
||||
const injectedHomePageId =
|
||||
config && config.data && config.data.homePageId
|
||||
? String(config.data.homePageId).trim()
|
||||
: '';
|
||||
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
|
||||
: null;
|
||||
const firstId = nav && nav[0] && nav[0].id ? String(nav[0].id).trim() : '';
|
||||
@@ -1046,7 +1113,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// 搜索索引,用于提高搜索效率
|
||||
let searchIndex = {
|
||||
initialized: false,
|
||||
items: []
|
||||
items: [],
|
||||
};
|
||||
|
||||
// 搜索引擎配置
|
||||
@@ -1054,25 +1121,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
local: {
|
||||
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>`,
|
||||
url: null // 本地搜索不需要URL
|
||||
url: null, // 本地搜索不需要URL
|
||||
},
|
||||
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>`,
|
||||
url: 'https://www.google.com/search?q='
|
||||
url: 'https://www.google.com/search?q=',
|
||||
},
|
||||
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>`,
|
||||
url: 'https://www.bing.com/search?q='
|
||||
url: 'https://www.bing.com/search?q=',
|
||||
},
|
||||
duckduckgo: {
|
||||
name: 'DuckDuckGo搜索',
|
||||
shortName: 'duckgo',
|
||||
// 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>`,
|
||||
url: 'https://duckduckgo.com/?q='
|
||||
}
|
||||
url: 'https://duckduckgo.com/?q=',
|
||||
},
|
||||
};
|
||||
|
||||
// 获取DOM元素 - 基本元素
|
||||
@@ -1217,19 +1284,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
pages = document.querySelectorAll('.page');
|
||||
}
|
||||
|
||||
pages.forEach(page => {
|
||||
pages.forEach((page) => {
|
||||
if (page.id === 'search-results') return;
|
||||
|
||||
const pageId = page.id;
|
||||
|
||||
page.querySelectorAll('.site-card').forEach(card => {
|
||||
page.querySelectorAll('.site-card').forEach((card) => {
|
||||
try {
|
||||
// 排除“扩展写回影子结构”等不应参与搜索的卡片
|
||||
if (card.closest('[data-search-exclude="true"]')) return;
|
||||
|
||||
// 兼容不同页面/卡片样式:优先取可见文本,其次回退到 data-*(确保 projects repo 卡片也能被搜索)
|
||||
const dataTitle = card.dataset?.name || card.getAttribute('data-name') || '';
|
||||
const dataDescription = card.dataset?.description || card.getAttribute('data-description') || '';
|
||||
const dataDescription =
|
||||
card.dataset?.description || card.getAttribute('data-description') || '';
|
||||
|
||||
const titleText =
|
||||
card.querySelector('h3')?.textContent ||
|
||||
@@ -1243,7 +1311,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const title = String(titleText || '').toLowerCase();
|
||||
const description = String(descriptionText || '').toLowerCase();
|
||||
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({
|
||||
@@ -1254,7 +1325,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
icon,
|
||||
element: card,
|
||||
// 预先计算搜索文本,提高搜索效率
|
||||
searchText: (title + ' ' + description).toLowerCase()
|
||||
searchText: (title + ' ' + description).toLowerCase(),
|
||||
});
|
||||
} catch (cardError) {
|
||||
console.error('Error processing card:', cardError);
|
||||
@@ -1308,7 +1379,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
overlay.addEventListener('click', closeAllPanels);
|
||||
|
||||
// 全局快捷键:Ctrl/Cmd + K 聚焦搜索
|
||||
document.addEventListener('keydown', e => {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if (key !== 'k') return;
|
||||
if ((!e.ctrlKey && !e.metaKey) || e.altKey) return;
|
||||
@@ -1378,7 +1449,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
pages = document.querySelectorAll('.page');
|
||||
}
|
||||
|
||||
pages.forEach(page => {
|
||||
pages.forEach((page) => {
|
||||
const shouldBeActive = page.id === pageId;
|
||||
if (shouldBeActive !== page.classList.contains('active')) {
|
||||
page.classList.toggle('active', shouldBeActive);
|
||||
@@ -1428,12 +1499,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let hasResults = false;
|
||||
|
||||
// 使用更高效的搜索算法
|
||||
const matchedItems = searchIndex.items.filter(item => {
|
||||
return item.searchText.includes(searchTerm) || PinyinMatch.match(item.searchText, searchTerm);;
|
||||
const matchedItems = searchIndex.items.filter((item) => {
|
||||
return (
|
||||
item.searchText.includes(searchTerm) || PinyinMatch.match(item.searchText, searchTerm)
|
||||
);
|
||||
});
|
||||
|
||||
// 按页面分组结果
|
||||
matchedItems.forEach(item => {
|
||||
matchedItems.forEach((item) => {
|
||||
if (!searchResults.has(item.pageId)) {
|
||||
searchResults.set(item.pageId, []);
|
||||
}
|
||||
@@ -1446,7 +1519,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
// 清空并隐藏所有搜索区域
|
||||
searchSections.forEach(section => {
|
||||
searchSections.forEach((section) => {
|
||||
try {
|
||||
const grid = section.querySelector('.sites-grid');
|
||||
if (grid) {
|
||||
@@ -1467,7 +1540,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (grid) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
matches.forEach(card => {
|
||||
matches.forEach((card) => {
|
||||
// 高亮匹配文本
|
||||
highlightSearchTerm(card, searchTerm);
|
||||
fragment.appendChild(card);
|
||||
@@ -1493,7 +1566,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// 显示搜索结果页面
|
||||
if (currentPageId !== 'search-results') {
|
||||
currentPageId = 'search-results';
|
||||
pages.forEach(page => {
|
||||
pages.forEach((page) => {
|
||||
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 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 text = element.textContent || '';
|
||||
@@ -1542,7 +1618,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
element.appendChild(fragment);
|
||||
};
|
||||
|
||||
const highlightInElement = element => {
|
||||
const highlightInElement = (element) => {
|
||||
if (!element) return;
|
||||
|
||||
const rawText = element.textContent || '';
|
||||
@@ -1557,7 +1633,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
while ((match = regex.exec(rawText)) !== null) {
|
||||
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');
|
||||
@@ -1613,7 +1691,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
// 清空搜索结果
|
||||
searchSections.forEach(section => {
|
||||
searchSections.forEach((section) => {
|
||||
try {
|
||||
const grid = section.querySelector('.sites-grid');
|
||||
if (grid) {
|
||||
@@ -1637,14 +1715,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (targetPageId && currentPageId !== targetPageId) {
|
||||
currentPageId = targetPageId;
|
||||
pages.forEach(page => {
|
||||
pages.forEach((page) => {
|
||||
page.classList.toggle('active', page.id === targetPageId);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 如果没有激活的导航项,默认显示首页
|
||||
currentPageId = homePageId;
|
||||
pages.forEach(page => {
|
||||
pages.forEach((page) => {
|
||||
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) {
|
||||
option.classList.add('active');
|
||||
@@ -1773,7 +1851,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// 更新搜索引擎UI显示
|
||||
function updateSearchEngineUI() {
|
||||
// 移除所有选项的激活状态
|
||||
searchEngineOptions.forEach(option => {
|
||||
searchEngineOptions.forEach((option) => {
|
||||
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');
|
||||
if (!activeWrapper) return;
|
||||
|
||||
@@ -1896,7 +1974,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
|
||||
// 导航项点击效果
|
||||
navItems.forEach(item => {
|
||||
navItems.forEach((item) => {
|
||||
item.addEventListener('click', (e) => {
|
||||
if (item.getAttribute('target') === '_blank') return;
|
||||
|
||||
@@ -1913,7 +1991,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
wrapper.classList.toggle('expanded');
|
||||
} else {
|
||||
// 关闭所有已展开的子菜单
|
||||
navItemWrappers.forEach(navWrapper => {
|
||||
navItemWrappers.forEach((navWrapper) => {
|
||||
if (navWrapper !== wrapper) {
|
||||
navWrapper.classList.remove('expanded');
|
||||
}
|
||||
@@ -1925,7 +2003,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
// 激活导航项
|
||||
navItems.forEach(nav => {
|
||||
navItems.forEach((nav) => {
|
||||
nav.classList.toggle('active', nav === item);
|
||||
});
|
||||
|
||||
@@ -1942,7 +2020,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
|
||||
// 子菜单项点击效果
|
||||
submenuItems.forEach(item => {
|
||||
submenuItems.forEach((item) => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1951,7 +2029,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const categoryName = item.getAttribute('data-category');
|
||||
const categoryId = item.getAttribute('data-category-id');
|
||||
|
||||
const escapeSelector = value => {
|
||||
const escapeSelector = (value) => {
|
||||
if (value === null || value === undefined) return '';
|
||||
const text = String(value);
|
||||
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, '\\$&');
|
||||
};
|
||||
|
||||
const escapeAttrValue = value => {
|
||||
const escapeAttrValue = (value) => {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
};
|
||||
|
||||
if (pageId) {
|
||||
// 清除所有子菜单项的激活状态
|
||||
submenuItems.forEach(subItem => {
|
||||
submenuItems.forEach((subItem) => {
|
||||
subItem.classList.remove('active');
|
||||
});
|
||||
|
||||
@@ -1974,7 +2052,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
item.classList.add('active');
|
||||
|
||||
// 激活相应的导航项
|
||||
navItems.forEach(nav => {
|
||||
navItems.forEach((nav) => {
|
||||
nav.classList.toggle('active', nav.getAttribute('data-page') === pageId);
|
||||
});
|
||||
|
||||
@@ -2001,7 +2079,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// 回退:旧逻辑按文本包含匹配(兼容旧页面/旧数据)
|
||||
if (!targetCategory && categoryName) {
|
||||
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 scrollPosition = contentElement.scrollTop + rect.top - containerRect.top - desiredPosition;
|
||||
const scrollPosition =
|
||||
contentElement.scrollTop + rect.top - containerRect.top - desiredPosition;
|
||||
|
||||
// 执行滚动
|
||||
contentElement.scrollTo({
|
||||
top: scrollPosition,
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} else {
|
||||
// 回退到基本滚动方式
|
||||
@@ -2064,7 +2143,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// 初始化分类切换按钮
|
||||
const categoryToggleBtn = document.getElementById('category-toggle');
|
||||
if (categoryToggleBtn) {
|
||||
categoryToggleBtn.addEventListener('click', function() {
|
||||
categoryToggleBtn.addEventListener('click', function () {
|
||||
window.MeNav.toggleCategories();
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -15,7 +15,7 @@ const {
|
||||
function stripYamlComments(yamlText) {
|
||||
return yamlText
|
||||
.split(/\r?\n/)
|
||||
.filter(line => !/^\s*#/.test(line))
|
||||
.filter((line) => !/^\s*#/.test(line))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
@@ -49,7 +49,7 @@ test('parseBookmarks:解析书签栏、根目录书签与图标映射', () =>
|
||||
assert.equal(bookmarks.categories[0].sites[0].name, 'GitHub');
|
||||
assert.equal(bookmarks.categories[0].sites[0].icon, 'fab fa-github');
|
||||
|
||||
const tools = bookmarks.categories.find(c => c.name === '工具');
|
||||
const tools = bookmarks.categories.find((c) => c.name === '工具');
|
||||
assert.ok(tools, '应解析出“工具”分类');
|
||||
assert.ok(Array.isArray(tools.sites));
|
||||
assert.equal(tools.sites[0].name, 'Google');
|
||||
@@ -62,11 +62,26 @@ test('templates:subgroups(第4层)应可渲染到页面', () => {
|
||||
const hbs = Handlebars.create();
|
||||
registerAllHelpers(hbs);
|
||||
|
||||
const category = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'category.hbs'), 'utf8');
|
||||
const group = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'group.hbs'), 'utf8');
|
||||
const pageHeader = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'page-header.hbs'), 'utf8');
|
||||
const siteCard = fs.readFileSync(path.join(__dirname, '..', 'templates', 'components', 'site-card.hbs'), 'utf8');
|
||||
const page = fs.readFileSync(path.join(__dirname, '..', 'templates', 'pages', 'bookmarks.hbs'), 'utf8');
|
||||
const category = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'templates', 'components', 'category.hbs'),
|
||||
'utf8'
|
||||
);
|
||||
const group = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'templates', 'components', 'group.hbs'),
|
||||
'utf8'
|
||||
);
|
||||
const pageHeader = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'templates', 'components', 'page-header.hbs'),
|
||||
'utf8'
|
||||
);
|
||||
const siteCard = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'templates', 'components', 'site-card.hbs'),
|
||||
'utf8'
|
||||
);
|
||||
const page = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'templates', 'pages', 'bookmarks.hbs'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
hbs.registerPartial('category', category);
|
||||
hbs.registerPartial('group', group);
|
||||
@@ -96,7 +111,12 @@ test('templates:subgroups(第4层)应可渲染到页面', () => {
|
||||
name: 'React生态',
|
||||
icon: 'fab fa-react',
|
||||
sites: [
|
||||
{ name: 'React', url: 'https://reactjs.org/', icon: 'fab fa-react', description: 'React官方' },
|
||||
{
|
||||
name: 'React',
|
||||
url: 'https://reactjs.org/',
|
||||
icon: 'fab fa-react',
|
||||
description: 'React官方',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -119,7 +139,9 @@ test('generateBookmarksYaml:生成 YAML 且可被解析', () => {
|
||||
{
|
||||
name: '示例分类',
|
||||
icon: 'fas fa-folder',
|
||||
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: '' }],
|
||||
sites: [
|
||||
{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: '' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -140,11 +162,7 @@ test('upsertBookmarksNavInSiteYml:无 navigation 时追加并幂等', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-test-'));
|
||||
const filePath = path.join(tmp, 'site.yml');
|
||||
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
`title: Test Site\n`,
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(filePath, `title: Test Site\n`, 'utf8');
|
||||
|
||||
const r1 = upsertBookmarksNavInSiteYml(filePath);
|
||||
assert.equal(r1.updated, true);
|
||||
|
||||
@@ -30,9 +30,9 @@ function renderBookmarksWithSite(site) {
|
||||
{
|
||||
name: '分类',
|
||||
icon: 'fas fa-folder',
|
||||
sites: [site]
|
||||
}
|
||||
]
|
||||
sites: [site],
|
||||
},
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
@@ -45,7 +45,7 @@ test('站点配置包含 faviconUrl(本地 assets 路径)时,渲染 bookma
|
||||
url: 'https://intranet.example/',
|
||||
faviconUrl: 'assets/menav.svg',
|
||||
icon: 'fas fa-link',
|
||||
external: true
|
||||
external: true,
|
||||
});
|
||||
|
||||
assert.match(html, /data-favicon-url="assets\/menav\.svg"/);
|
||||
@@ -60,7 +60,7 @@ test('站点配置包含 faviconUrl(在线 ico)时,渲染 bookmarks 不应
|
||||
url: 'https://example.com/',
|
||||
faviconUrl: 'https://content.webcull.com/images/websites/icons/470/695/b788b0.ico',
|
||||
icon: 'fas fa-link',
|
||||
external: true
|
||||
external: true,
|
||||
});
|
||||
|
||||
assert.match(
|
||||
@@ -73,4 +73,3 @@ test('站点配置包含 faviconUrl(在线 ico)时,渲染 bookmarks 不应
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,9 +20,24 @@ test('首页(navigation 第一项)应使用 profile 覆盖 title/subtitle
|
||||
{ id: 'home', name: '首页', icon: 'fas fa-home' },
|
||||
{ id: 'projects', name: '项目', icon: 'fas fa-project-diagram' },
|
||||
],
|
||||
bookmarks: { title: '书签页标题', subtitle: '书签页副标题', template: 'bookmarks', categories: [] },
|
||||
home: { title: 'HOME_PAGE_TITLE', subtitle: 'HOME_PAGE_SUBTITLE', template: 'page', categories: [] },
|
||||
projects: { title: '项目页标题', subtitle: '项目页副标题', template: 'projects', categories: [] },
|
||||
bookmarks: {
|
||||
title: '书签页标题',
|
||||
subtitle: '书签页副标题',
|
||||
template: 'bookmarks',
|
||||
categories: [],
|
||||
},
|
||||
home: {
|
||||
title: 'HOME_PAGE_TITLE',
|
||||
subtitle: 'HOME_PAGE_SUBTITLE',
|
||||
template: 'page',
|
||||
categories: [],
|
||||
},
|
||||
projects: {
|
||||
title: '项目页标题',
|
||||
subtitle: '项目页副标题',
|
||||
template: 'projects',
|
||||
categories: [],
|
||||
},
|
||||
};
|
||||
|
||||
const pages = generateAllPagesHTML(config);
|
||||
|
||||
@@ -46,4 +46,3 @@ test('未配置 icons.mode 时应回退为默认 favicon', () => {
|
||||
assert.equal(config.icons.mode, 'favicon');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ test('多级结构下 sites.external 未配置时应默认 true,且 external:f
|
||||
' - name: DeepExternalFalse',
|
||||
' url: https://example.com/deep-false',
|
||||
' external: false',
|
||||
''
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
@@ -67,4 +67,3 @@ test('多级结构下 sites.external 未配置时应默认 true,且 external:f
|
||||
assert.equal(deepSites[1].external, false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,14 @@ test('friends/articles:应恢复分类展示(扩展仍以 data-* 结构为
|
||||
{
|
||||
name: '技术博主',
|
||||
icon: 'fas fa-user-friends',
|
||||
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
||||
sites: [
|
||||
{
|
||||
name: 'Example',
|
||||
url: 'https://example.com',
|
||||
icon: 'fas fa-link',
|
||||
description: 'desc',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -50,7 +57,14 @@ test('friends/articles:应恢复分类展示(扩展仍以 data-* 结构为
|
||||
{
|
||||
name: '最新文章',
|
||||
icon: 'fas fa-pen',
|
||||
sites: [{ name: 'Article A', url: 'https://example.com/a', icon: 'fas fa-link', description: 'summary' }],
|
||||
sites: [
|
||||
{
|
||||
name: 'Article A',
|
||||
url: 'https://example.com/a',
|
||||
icon: 'fas fa-link',
|
||||
description: 'summary',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -61,15 +75,27 @@ test('friends/articles:应恢复分类展示(扩展仍以 data-* 结构为
|
||||
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
|
||||
assert.ok(pages.friends.includes('page-template-friends'));
|
||||
assert.ok(pages.friends.includes('sites-grid'));
|
||||
assert.ok(pages.friends.includes('class="site-card'), 'friends 应使用普通 site-card 样式(图标在左,标题在右)');
|
||||
assert.ok(!pages.friends.includes('site-card-friend'), 'friends 不应使用 site-card-friend 变体样式');
|
||||
assert.ok(
|
||||
pages.friends.includes('class="site-card'),
|
||||
'friends 应使用普通 site-card 样式(图标在左,标题在右)'
|
||||
);
|
||||
assert.ok(
|
||||
!pages.friends.includes('site-card-friend'),
|
||||
'friends 不应使用 site-card-friend 变体样式'
|
||||
);
|
||||
assert.ok(pages.friends.includes('category-header'), 'friends 应输出分类标题结构');
|
||||
|
||||
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
|
||||
assert.ok(pages.articles.includes('page-template-articles'));
|
||||
assert.ok(pages.articles.includes('sites-grid'));
|
||||
assert.ok(pages.articles.includes('class="site-card'), 'articles 应使用普通 site-card 样式(图标在左,标题在右)');
|
||||
assert.ok(!pages.articles.includes('site-card-article'), 'articles 不应使用 site-card-article 变体样式');
|
||||
assert.ok(
|
||||
pages.articles.includes('class="site-card'),
|
||||
'articles 应使用普通 site-card 样式(图标在左,标题在右)'
|
||||
);
|
||||
assert.ok(
|
||||
!pages.articles.includes('site-card-article'),
|
||||
'articles 不应使用 site-card-article 变体样式'
|
||||
);
|
||||
assert.ok(pages.articles.includes('category-header'), 'articles 应输出分类标题结构');
|
||||
});
|
||||
});
|
||||
@@ -92,13 +118,22 @@ test('friends/articles:页面配置使用顶层 sites 时应自动映射为分
|
||||
title: '友情链接',
|
||||
subtitle: '朋友们',
|
||||
template: 'page',
|
||||
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
||||
sites: [
|
||||
{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' },
|
||||
],
|
||||
},
|
||||
articles: {
|
||||
title: '文章',
|
||||
subtitle: '文章入口',
|
||||
template: 'articles',
|
||||
sites: [{ name: 'Article A', url: 'https://example.com/a', icon: 'fas fa-link', description: 'summary' }],
|
||||
sites: [
|
||||
{
|
||||
name: 'Article A',
|
||||
url: 'https://example.com/a',
|
||||
icon: 'fas fa-link',
|
||||
description: 'summary',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -107,15 +142,27 @@ test('friends/articles:页面配置使用顶层 sites 时应自动映射为分
|
||||
assert.ok(typeof pages.friends === 'string' && pages.friends.length > 0);
|
||||
assert.ok(pages.friends.includes('page-template-friends'));
|
||||
assert.ok(pages.friends.includes('sites-grid'));
|
||||
assert.ok(pages.friends.includes('class="site-card'), 'friends 应使用普通 site-card 样式(图标在左,标题在右)');
|
||||
assert.ok(!pages.friends.includes('site-card-friend'), 'friends 不应使用 site-card-friend 变体样式');
|
||||
assert.ok(
|
||||
pages.friends.includes('class="site-card'),
|
||||
'friends 应使用普通 site-card 样式(图标在左,标题在右)'
|
||||
);
|
||||
assert.ok(
|
||||
!pages.friends.includes('site-card-friend'),
|
||||
'friends 不应使用 site-card-friend 变体样式'
|
||||
);
|
||||
assert.ok(pages.friends.includes('category-header'), 'friends 应输出分类标题结构');
|
||||
|
||||
assert.ok(typeof pages.articles === 'string' && pages.articles.length > 0);
|
||||
assert.ok(pages.articles.includes('page-template-articles'));
|
||||
assert.ok(pages.articles.includes('sites-grid'));
|
||||
assert.ok(pages.articles.includes('class="site-card'), 'articles 应使用普通 site-card 样式(图标在左,标题在右)');
|
||||
assert.ok(!pages.articles.includes('site-card-article'), 'articles 不应使用 site-card-article 变体样式');
|
||||
assert.ok(
|
||||
pages.articles.includes('class="site-card'),
|
||||
'articles 应使用普通 site-card 样式(图标在左,标题在右)'
|
||||
);
|
||||
assert.ok(
|
||||
!pages.articles.includes('site-card-article'),
|
||||
'articles 不应使用 site-card-article 变体样式'
|
||||
);
|
||||
assert.ok(pages.articles.includes('category-header'), 'articles 应输出分类标题结构');
|
||||
});
|
||||
});
|
||||
@@ -191,7 +238,14 @@ test('projects:应输出代码仓库风格卡片(site-card-repo)', () => {
|
||||
{
|
||||
name: '项目',
|
||||
icon: 'fas fa-code',
|
||||
sites: [{ name: 'Proj', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
||||
sites: [
|
||||
{
|
||||
name: 'Proj',
|
||||
url: 'https://example.com',
|
||||
icon: 'fas fa-link',
|
||||
description: 'desc',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -231,10 +285,10 @@ test('articles Phase 2:存在 RSS 缓存时渲染文章条目,并隐藏扩
|
||||
publishedAt: '2025-12-25T12:00:00.000Z',
|
||||
source: 'Example Blog',
|
||||
sourceUrl: 'https://example.com',
|
||||
icon: 'fas fa-pen'
|
||||
}
|
||||
icon: 'fas fa-pen',
|
||||
},
|
||||
],
|
||||
stats: { totalArticles: 1 }
|
||||
stats: { totalArticles: 1 },
|
||||
},
|
||||
null,
|
||||
2
|
||||
@@ -259,7 +313,14 @@ test('articles Phase 2:存在 RSS 缓存时渲染文章条目,并隐藏扩
|
||||
{
|
||||
name: '来源',
|
||||
icon: 'fas fa-pen',
|
||||
sites: [{ name: 'Source A', url: 'https://example.com', icon: 'fas fa-link', description: 'desc' }],
|
||||
sites: [
|
||||
{
|
||||
name: 'Source A',
|
||||
url: 'https://example.com',
|
||||
icon: 'fas fa-link',
|
||||
description: 'desc',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -269,7 +330,10 @@ test('articles Phase 2:存在 RSS 缓存时渲染文章条目,并隐藏扩
|
||||
const html = pages.articles;
|
||||
|
||||
assert.ok(typeof html === 'string' && html.length > 0);
|
||||
assert.ok(html.includes('data-type="article"'), '文章条目卡片应为 data-type="article"(只读)');
|
||||
assert.ok(
|
||||
html.includes('data-type="article"'),
|
||||
'文章条目卡片应为 data-type="article"(只读)'
|
||||
);
|
||||
assert.ok(html.includes('site-card-meta'), '文章条目应展示日期/来源元信息');
|
||||
assert.ok(html.includes('Example Blog'));
|
||||
assert.ok(html.includes('2025-12-25'));
|
||||
|
||||
Reference in New Issue
Block a user