chore: 使用 Prettier 统一代码风格

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

View File

@@ -2,7 +2,7 @@ name: Build and Deploy Site
on:
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 推送

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
@@ -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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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