feat: 页面模板差异化改进 + 配置优化 + 兼容清理 (#29)
- 首页判定:navigation 第一项 - 模板:page/projects/articles/bookmarks/search-results - bookmarks:update: YYYY-MM-DD | from: git|mtime - articles:RSS 聚合只读条目 + 分类聚合 + 影子写回结构 - projects:repo 卡片 + 可选热力图 + 自动抓取元信息 - 工作流:构建前 sync + schedule 定时刷新 - 移除兼容:config.yml/config.yaml、navigation.yml、home 特例 - 迁移说明:config/update-instructions.md
This commit is contained in:
@@ -17,8 +17,6 @@ const MODULAR_DEFAULT_BOOKMARKS_FILE = 'config/_default/pages/bookmarks.yml';
|
||||
|
||||
const USER_SITE_YML = path.join(CONFIG_USER_DIR, 'site.yml');
|
||||
const DEFAULT_SITE_YML = path.join(CONFIG_DEFAULT_DIR, 'site.yml');
|
||||
const LEGACY_USER_NAV_YML = path.join(CONFIG_USER_DIR, 'navigation.yml');
|
||||
const LEGACY_DEFAULT_NAV_YML = path.join(CONFIG_DEFAULT_DIR, 'navigation.yml');
|
||||
|
||||
function ensureUserConfigInitialized() {
|
||||
if (fs.existsSync(CONFIG_USER_DIR)) {
|
||||
@@ -748,71 +746,9 @@ function updateNavigationWithBookmarks() {
|
||||
if (result.reason === 'error') {
|
||||
return { updated: false, target: 'site.yml', reason: 'error', error: result.error };
|
||||
}
|
||||
// 如果 site.yml 无法更新(如 navigation 格式异常),继续尝试旧版 navigation.yml
|
||||
}
|
||||
|
||||
// 2) 兼容旧版:独立 navigation.yml
|
||||
if (fs.existsSync(LEGACY_USER_NAV_YML)) {
|
||||
const updated = updateNavigationFile(LEGACY_USER_NAV_YML);
|
||||
return { updated, target: 'navigation.yml', reason: updated ? 'updated' : 'already_present' };
|
||||
}
|
||||
|
||||
if (fs.existsSync(LEGACY_DEFAULT_NAV_YML)) {
|
||||
try {
|
||||
const defaultNavContent = fs.readFileSync(LEGACY_DEFAULT_NAV_YML, 'utf8');
|
||||
if (!fs.existsSync(CONFIG_USER_DIR)) {
|
||||
fs.mkdirSync(CONFIG_USER_DIR, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(LEGACY_USER_NAV_YML, defaultNavContent, 'utf8');
|
||||
const updated = updateNavigationFile(LEGACY_USER_NAV_YML);
|
||||
return { updated, target: 'navigation.yml', reason: updated ? 'updated' : 'already_present' };
|
||||
} catch (error) {
|
||||
return { updated: false, target: 'navigation.yml', reason: 'error', error };
|
||||
}
|
||||
}
|
||||
|
||||
return { updated: false, target: null, reason: 'no_navigation_config' };
|
||||
}
|
||||
|
||||
// 更新单个导航配置文件
|
||||
function updateNavigationFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const navConfig = yaml.load(content);
|
||||
|
||||
// 检查是否已有书签页面
|
||||
const hasBookmarksNav = Array.isArray(navConfig) &&
|
||||
navConfig.some(nav => nav.id === 'bookmarks');
|
||||
|
||||
if (!hasBookmarksNav) {
|
||||
// 添加书签导航项
|
||||
if (!Array.isArray(navConfig)) {
|
||||
console.log(`Warning: Navigation config in ${filePath} is not an array, cannot update`);
|
||||
return false;
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: '书签',
|
||||
icon: 'fas fa-bookmark',
|
||||
id: 'bookmarks'
|
||||
});
|
||||
|
||||
// 更新文件
|
||||
const updatedYaml = yaml.dump(navConfig, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
quotingType: '"'
|
||||
});
|
||||
|
||||
fs.writeFileSync(filePath, updatedYaml, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // 无需更新
|
||||
} catch (error) {
|
||||
console.error(`Error updating navigation file ${filePath}:`, error);
|
||||
return false;
|
||||
return { updated: false, target: 'site.yml', reason: result.reason };
|
||||
}
|
||||
return { updated: false, target: null, reason: 'no_site_yml' };
|
||||
}
|
||||
|
||||
// 主函数
|
||||
|
||||
575
src/generator.js
575
src/generator.js
@@ -1,6 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const Handlebars = require('handlebars');
|
||||
|
||||
// 导入Handlebars助手函数
|
||||
@@ -96,11 +97,11 @@ function renderTemplate(templateName, data, useLayout = true) {
|
||||
const genericTemplateContent = fs.readFileSync(genericTemplatePath, 'utf8');
|
||||
const genericTemplate = handlebars.compile(genericTemplateContent);
|
||||
|
||||
// 添加 pageId 到数据中,以便通用模板使用
|
||||
const enhancedData = {
|
||||
...data,
|
||||
pageId: templateName // 确保pageId在模板中可用
|
||||
};
|
||||
// 添加 pageId 到数据中,以便通用模板使用(优先保留原 pageId,避免回退时语义错位)
|
||||
const enhancedData = {
|
||||
...data,
|
||||
pageId: data && data.pageId ? data.pageId : templateName
|
||||
};
|
||||
|
||||
// 渲染页面内容
|
||||
const pageContent = genericTemplate(enhancedData);
|
||||
@@ -255,17 +256,6 @@ function loadModularConfig(dirPath) {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果site.yml中没有navigation配置,则回退到独立的navigation.yml
|
||||
if (!config.navigation || config.navigation.length === 0) {
|
||||
const navConfigPath = path.join(dirPath, 'navigation.yml');
|
||||
const navConfig = safeLoadYamlConfig(navConfigPath);
|
||||
if (navConfig) {
|
||||
config.navigation = navConfig;
|
||||
console.log('site.yml 中未找到导航配置,使用独立的 navigation.yml 文件');
|
||||
console.log('提示:建议将导航配置迁移到 site.yml 中,以便统一管理');
|
||||
}
|
||||
}
|
||||
|
||||
// 加载页面配置
|
||||
const pagesPath = path.join(dirPath, 'pages');
|
||||
if (fs.existsSync(pagesPath)) {
|
||||
@@ -280,11 +270,6 @@ function loadModularConfig(dirPath) {
|
||||
// 提取文件名(不含扩展名)作为配置键
|
||||
const configKey = path.basename(file, path.extname(file));
|
||||
|
||||
// 特殊处理home.yml中的categories字段
|
||||
if (configKey === 'home' && fileConfig.categories) {
|
||||
config.categories = fileConfig.categories;
|
||||
}
|
||||
|
||||
// 将页面配置添加到主配置对象
|
||||
config[configKey] = fileConfig;
|
||||
}
|
||||
@@ -326,7 +311,6 @@ function ensureConfigDefaults(config) {
|
||||
|
||||
result.profile = result.profile || {};
|
||||
result.social = result.social || [];
|
||||
result.categories = result.categories || [];
|
||||
// 图标配置默认值
|
||||
result.icons = result.icons || {};
|
||||
// icons.mode: manual | favicon, 默认 favicon
|
||||
@@ -367,17 +351,21 @@ function ensureConfigDefaults(config) {
|
||||
category.sites.forEach(processSiteDefaults);
|
||||
}
|
||||
|
||||
// 为首页的每个类别和站点设置默认值
|
||||
result.categories = result.categories || [];
|
||||
result.categories.forEach(processCategoryDefaults);
|
||||
|
||||
// 为所有页面配置中的类别和站点设置默认值
|
||||
Object.keys(result).forEach(key => {
|
||||
const pageConfig = result[key];
|
||||
// 检查是否是页面配置对象且包含categories数组
|
||||
if (pageConfig && typeof pageConfig === 'object' && Array.isArray(pageConfig.categories)) {
|
||||
// 检查是否是页面配置对象
|
||||
if (!pageConfig || typeof pageConfig !== 'object') return;
|
||||
|
||||
// 传统结构:categories -> sites
|
||||
if (Array.isArray(pageConfig.categories)) {
|
||||
pageConfig.categories.forEach(processCategoryDefaults);
|
||||
}
|
||||
|
||||
// 扁平结构:sites(用于 friends/articles 等“无层级并列卡片”页面)
|
||||
if (Array.isArray(pageConfig.sites)) {
|
||||
pageConfig.sites.forEach(processSiteDefaults);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -412,30 +400,8 @@ function getSubmenuForNavItem(navItem, config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 首页页面添加子菜单(分类)
|
||||
if (navItem.id === 'home' && Array.isArray(config.categories)) {
|
||||
return config.categories;
|
||||
}
|
||||
// 书签页面添加子菜单(分类)
|
||||
else if (navItem.id === 'bookmarks' && config.bookmarks && Array.isArray(config.bookmarks.categories)) {
|
||||
return config.bookmarks.categories;
|
||||
}
|
||||
// 项目页面添加子菜单
|
||||
else if (navItem.id === 'projects' && config.projects && Array.isArray(config.projects.categories)) {
|
||||
return config.projects.categories;
|
||||
}
|
||||
// 文章页面添加子菜单
|
||||
else if (navItem.id === 'articles' && config.articles && Array.isArray(config.articles.categories)) {
|
||||
return config.articles.categories;
|
||||
}
|
||||
// 友链页面添加子菜单
|
||||
else if (navItem.id === 'friends' && config.friends && Array.isArray(config.friends.categories)) {
|
||||
return config.friends.categories;
|
||||
}
|
||||
// 通用处理:任意自定义页面的子菜单生成
|
||||
else if (config[navItem.id] && config[navItem.id].categories && Array.isArray(config[navItem.id].categories)) {
|
||||
return config[navItem.id].categories;
|
||||
}
|
||||
// 通用处理:任意页面的子菜单生成(基于 pages/<id>.yml 的 categories)
|
||||
if (config[navItem.id] && Array.isArray(config[navItem.id].categories)) return config[navItem.id].categories;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -454,6 +420,359 @@ function makeJsonSafeForHtmlScript(jsonString) {
|
||||
return jsonString.replace(/<\/script/gi, '<\\/script');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析页面配置文件路径(优先 user,回退 _default)
|
||||
* 注意:仅用于构建期读取文件元信息,不会把路径注入到页面/扩展配置中。
|
||||
* @param {string} pageId 页面ID(与 pages/<id>.yml 文件名对应)
|
||||
* @returns {string|null} 文件路径或 null
|
||||
*/
|
||||
function resolvePageConfigFilePath(pageId) {
|
||||
if (!pageId) return null;
|
||||
|
||||
const candidates = [
|
||||
path.join(process.cwd(), 'config', 'user', 'pages', `${pageId}.yml`),
|
||||
path.join(process.cwd(), 'config', 'user', 'pages', `${pageId}.yaml`),
|
||||
path.join(process.cwd(), 'config', '_default', 'pages', `${pageId}.yml`),
|
||||
path.join(process.cwd(), 'config', '_default', 'pages', `${pageId}.yaml`),
|
||||
];
|
||||
|
||||
for (const filePath of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) return filePath;
|
||||
} catch (e) {
|
||||
// 忽略 IO 异常,继续尝试下一个候选
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试获取文件最后一次 git 提交时间(ISO 字符串)
|
||||
* @param {string} filePath 文件路径
|
||||
* @returns {string|null} ISO 字符串(UTC),失败返回 null
|
||||
*/
|
||||
function tryGetGitLastCommitIso(filePath) {
|
||||
if (!filePath) return null;
|
||||
|
||||
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 raw = String(output || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
|
||||
return date.toISOString();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件 mtime(ISO 字符串)
|
||||
* @param {string} filePath 文件路径
|
||||
* @returns {string|null} ISO 字符串(UTC),失败返回 null
|
||||
*/
|
||||
function tryGetFileMtimeIso(filePath) {
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
const mtime = stats && stats.mtime ? stats.mtime : null;
|
||||
if (!(mtime instanceof Date) || Number.isNaN(mtime.getTime())) return null;
|
||||
return mtime.toISOString();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算页面配置文件“内容更新时间”(优先 git,回退 mtime)
|
||||
* @param {string} pageId 页面ID
|
||||
* @returns {{updatedAt: string, updatedAtSource: 'git'|'mtime'}|null}
|
||||
*/
|
||||
function getPageConfigUpdatedAtMeta(pageId) {
|
||||
const filePath = resolvePageConfigFilePath(pageId);
|
||||
if (!filePath) return null;
|
||||
|
||||
const gitIso = tryGetGitLastCommitIso(filePath);
|
||||
if (gitIso) {
|
||||
return { updatedAt: gitIso, updatedAtSource: 'git' };
|
||||
}
|
||||
|
||||
const mtimeIso = tryGetFileMtimeIso(filePath);
|
||||
if (mtimeIso) {
|
||||
return { updatedAt: mtimeIso, updatedAtSource: 'mtime' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 articles 页面 RSS 缓存(Phase 2)
|
||||
* - 缓存默认放在 dev/(仓库默认 gitignore)
|
||||
* - 构建端只读缓存:缓存缺失/损坏时回退到 Phase 1(渲染来源站点分类)
|
||||
* @param {string} pageId 页面ID(用于支持多个 articles 页面的独立缓存)
|
||||
* @param {Object} config 全站配置(用于读取 site.rss.cacheDir)
|
||||
* @returns {{items: Array<Object>, meta: Object}|null}
|
||||
*/
|
||||
function tryLoadArticlesFeedCache(pageId, config) {
|
||||
if (!pageId) return null;
|
||||
|
||||
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) : '';
|
||||
const cacheDir = cacheDirFromEnv || cacheDirFromConfig || 'dev';
|
||||
|
||||
const cacheBaseDir = path.isAbsolute(cacheDir) ? cacheDir : path.join(process.cwd(), cacheDir);
|
||||
const cachePath = path.join(cacheBaseDir, `${pageId}.feed-cache.json`);
|
||||
if (!fs.existsSync(cachePath)) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
|
||||
const articles = Array.isArray(parsed.articles) ? parsed.articles : [];
|
||||
const items = articles
|
||||
.map(a => {
|
||||
const title = a && a.title ? String(a.title) : '';
|
||||
const url = a && a.url ? String(a.url) : '';
|
||||
if (!title || !url) return null;
|
||||
|
||||
return {
|
||||
// 兼容 site-card partial 字段
|
||||
name: title,
|
||||
url,
|
||||
icon: a && a.icon ? String(a.icon) : 'fas fa-pen',
|
||||
description: a && a.summary ? String(a.summary) : '',
|
||||
|
||||
// Phase 2 文章元信息(只读展示)
|
||||
publishedAt: a && a.publishedAt ? String(a.publishedAt) : '',
|
||||
source: a && a.source ? String(a.source) : '',
|
||||
// 文章来源站点首页 URL(用于按分类聚合展示;旧缓存可能缺失)
|
||||
sourceUrl: a && a.sourceUrl ? String(a.sourceUrl) : '',
|
||||
|
||||
// 文章链接通常应在新标签页打开
|
||||
external: true
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
pageId: parsed.pageId || pageId,
|
||||
generatedAt: parsed.generatedAt || '',
|
||||
total: parsed.stats && Number.isFinite(parsed.stats.totalArticles) ? parsed.stats.totalArticles : items.length
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`[WARN] articles 缓存读取失败:${cachePath}(将回退 Phase 1)`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrlKey(input) {
|
||||
if (!input) return '';
|
||||
try {
|
||||
const u = new URL(String(input));
|
||||
const origin = u.origin;
|
||||
let pathname = u.pathname || '/';
|
||||
// 统一去掉末尾斜杠(根路径除外),避免 https://a.com 与 https://a.com/ 不匹配
|
||||
if (pathname !== '/' && pathname.endsWith('/')) pathname = pathname.slice(0, -1);
|
||||
return `${origin}${pathname}`;
|
||||
} catch {
|
||||
return String(input).trim();
|
||||
}
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (site && typeof site === 'object') output.push(site);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* articles Phase 2:按页面配置的“分类”聚合文章展示
|
||||
* - 规则:某篇文章的 sourceUrl/source 归属到其来源站点(pages/articles.yml 中配置的站点)所在的分类
|
||||
* - 兼容:旧缓存缺少 sourceUrl 时回退使用 source(站点名称)匹配
|
||||
* @param {Array<Object>} categories 页面配置 categories(可包含更深层级)
|
||||
* @param {Array<Object>} articlesItems Phase 2 文章条目(来自缓存)
|
||||
* @returns {Array<{name: string, icon: string, items: Array<Object>}>}
|
||||
*/
|
||||
function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
|
||||
const safeItems = Array.isArray(articlesItems) ? articlesItems : [];
|
||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||
|
||||
// 若页面未配置分类,则回退为单一分类容器
|
||||
if (safeCategories.length === 0) {
|
||||
return [
|
||||
{
|
||||
name: '最新文章',
|
||||
icon: 'fas fa-rss',
|
||||
items: safeItems
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const categoryIndex = safeCategories.map(category => {
|
||||
const sites = [];
|
||||
collectSitesRecursively(category, sites);
|
||||
|
||||
const siteUrlKeys = new Set();
|
||||
const siteNameKeys = new Set();
|
||||
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() : '';
|
||||
if (nameKey) siteNameKeys.add(nameKey);
|
||||
});
|
||||
|
||||
return { category, siteUrlKeys, siteNameKeys };
|
||||
});
|
||||
|
||||
const buckets = categoryIndex.map(() => []);
|
||||
const uncategorized = [];
|
||||
|
||||
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));
|
||||
}
|
||||
if (matchedIndex < 0 && sourceNameKey) {
|
||||
matchedIndex = categoryIndex.findIndex(idx => idx.siteNameKeys.has(sourceNameKey));
|
||||
}
|
||||
|
||||
if (matchedIndex < 0) {
|
||||
uncategorized.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
buckets[matchedIndex].push(item);
|
||||
});
|
||||
|
||||
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]
|
||||
}));
|
||||
|
||||
if (uncategorized.length > 0) {
|
||||
displayCategories.push({
|
||||
name: '其他',
|
||||
icon: 'fas fa-ellipsis-h',
|
||||
items: uncategorized
|
||||
});
|
||||
}
|
||||
|
||||
return displayCategories;
|
||||
}
|
||||
|
||||
function tryLoadProjectsRepoCache(pageId, config) {
|
||||
if (!pageId) return null;
|
||||
|
||||
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) : '';
|
||||
const cacheDir = cacheDirFromEnv || cacheDirFromConfig || 'dev';
|
||||
|
||||
const cacheBaseDir = path.isAbsolute(cacheDir) ? cacheDir : path.join(process.cwd(), cacheDir);
|
||||
const cachePath = path.join(cacheBaseDir, `${pageId}.repo-cache.json`);
|
||||
if (!fs.existsSync(cachePath)) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
|
||||
const repos = Array.isArray(parsed.repos) ? parsed.repos : [];
|
||||
const map = new Map();
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
map,
|
||||
meta: {
|
||||
pageId: parsed.pageId || pageId,
|
||||
generatedAt: parsed.generatedAt || ''
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`[WARN] projects 缓存读取失败:${cachePath}(将仅展示标题与描述)`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGithubRepoUrl(url) {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const u = new URL(String(url));
|
||||
if (u.hostname.toLowerCase() !== 'github.com') return '';
|
||||
const parts = u.pathname.split('/').filter(Boolean);
|
||||
if (parts.length < 2) return '';
|
||||
const owner = parts[0];
|
||||
const repo = parts[1].replace(/\.git$/i, '');
|
||||
if (!owner || !repo) return '';
|
||||
return `https://github.com/${owner}/${repo}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function applyRepoMetaToCategories(categories, repoMetaMap) {
|
||||
if (!Array.isArray(categories) || !(repoMetaMap instanceof Map)) return;
|
||||
|
||||
const walk = (node) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node.subcategories)) node.subcategories.forEach(walk);
|
||||
if (Array.isArray(node.groups)) node.groups.forEach(walk);
|
||||
if (Array.isArray(node.subgroups)) node.subgroups.forEach(walk);
|
||||
|
||||
if (Array.isArray(node.sites)) {
|
||||
node.sites.forEach(site => {
|
||||
if (!site || typeof site !== 'object' || !site.url) return;
|
||||
const canonical = normalizeGithubRepoUrl(site.url);
|
||||
if (!canonical) return;
|
||||
const meta = repoMetaMap.get(canonical);
|
||||
if (!meta) return;
|
||||
|
||||
site.language = meta.language || '';
|
||||
site.languageColor = meta.languageColor || '';
|
||||
site.stars = meta.stars;
|
||||
site.forks = meta.forks;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
categories.forEach(walk);
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备渲染数据,添加模板所需的特殊属性
|
||||
* @param {Object} config 配置对象
|
||||
@@ -527,8 +846,7 @@ function loadConfig() {
|
||||
navigation: [],
|
||||
fonts: {},
|
||||
profile: {},
|
||||
social: [],
|
||||
categories: []
|
||||
social: []
|
||||
};
|
||||
|
||||
// 检查模块化配置来源是否存在
|
||||
@@ -556,20 +874,10 @@ function loadConfig() {
|
||||
// 2. 次高优先级: config/_default/ 目录
|
||||
config = loadModularConfig('config/_default');
|
||||
} else {
|
||||
// 3. 最低优先级: 旧版单文件配置 (config.yml or config.yaml)
|
||||
const legacyConfigPath = fs.existsSync('config.yml') ? 'config.yml' : 'config.yaml';
|
||||
|
||||
if (fs.existsSync(legacyConfigPath)) {
|
||||
try {
|
||||
const fileContent = fs.readFileSync(legacyConfigPath, 'utf8');
|
||||
config = yaml.load(fileContent);
|
||||
} catch (e) {
|
||||
console.error(`Error loading configuration from ${legacyConfigPath}:`, e);
|
||||
}
|
||||
} else {
|
||||
console.error('No configuration found. Please create a configuration file.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.error('[ERROR] 未找到可用配置:缺少 config/user/ 或 config/_default/。');
|
||||
console.error('[ERROR] 本版本已不再支持旧版单文件配置(config.yml / config.yaml)。');
|
||||
console.error('[ERROR] 解决方法:使用模块化配置目录(建议从 config/_default/ 复制到 config/user/ 再修改)。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 确保配置有默认值并通过验证
|
||||
@@ -689,8 +997,10 @@ function generatePageContent(pageId, data) {
|
||||
console.error(`Missing data for page: ${pageId}`);
|
||||
return `
|
||||
<div class="welcome-section">
|
||||
<h2>页面未配置</h2>
|
||||
<p class="subtitle">请配置 ${pageId} 页面</p>
|
||||
<div class="welcome-section-main">
|
||||
<h2>页面未配置</h2>
|
||||
<p class="subtitle">请配置 ${pageId} 页面</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -700,8 +1010,10 @@ function generatePageContent(pageId, data) {
|
||||
|
||||
return `
|
||||
<div class="welcome-section">
|
||||
<h2>${escapeHtml(profile.title || '欢迎使用')}</h2>
|
||||
<h3>${escapeHtml(profile.subtitle || '个人导航站')}</h3>
|
||||
<div class="welcome-section-main">
|
||||
<h2>${escapeHtml(profile.title || '欢迎使用')}</h2>
|
||||
<h3>${escapeHtml(profile.subtitle || '个人导航站')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
${generateCategories(data.categories)}`;
|
||||
} else {
|
||||
@@ -712,8 +1024,10 @@ ${generateCategories(data.categories)}`;
|
||||
|
||||
return `
|
||||
<div class="welcome-section">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<p class="subtitle">${escapeHtml(subtitle)}</p>
|
||||
<div class="welcome-section-main">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<p class="subtitle">${escapeHtml(subtitle)}</p>
|
||||
</div>
|
||||
</div>
|
||||
${generateCategories(categories)}`;
|
||||
}
|
||||
@@ -742,8 +1056,10 @@ function generateSearchResultsPage(config) {
|
||||
<!-- 搜索结果页 -->
|
||||
<div class="page" id="search-results">
|
||||
<div class="welcome-section">
|
||||
<h2>搜索结果</h2>
|
||||
<p class="subtitle">在所有页面中找到的匹配项</p>
|
||||
<div class="welcome-section-main">
|
||||
<h2>搜索结果</h2>
|
||||
<p class="subtitle">在所有页面中找到的匹配项</p>
|
||||
</div>
|
||||
</div>
|
||||
${searchSections}
|
||||
</div>`;
|
||||
@@ -784,6 +1100,40 @@ function generateFontVariables(config) {
|
||||
return css;
|
||||
}
|
||||
|
||||
function normalizeGithubHeatmapColor(input) {
|
||||
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;
|
||||
return '339af0';
|
||||
}
|
||||
|
||||
function getGithubUsernameFromConfig(config) {
|
||||
const username = config && config.site && config.site.github && config.site.github.username
|
||||
? String(config.site.github.username).trim()
|
||||
: '';
|
||||
return username;
|
||||
}
|
||||
|
||||
function buildProjectsMeta(config) {
|
||||
const username = getGithubUsernameFromConfig(config);
|
||||
if (!username) return null;
|
||||
|
||||
const color = normalizeGithubHeatmapColor(
|
||||
config && config.site && config.site.github && config.site.github.heatmapColor
|
||||
? config.site.github.heatmapColor
|
||||
: '339af0'
|
||||
);
|
||||
|
||||
return {
|
||||
heatmap: {
|
||||
username,
|
||||
profileUrl: `https://github.com/${username}`,
|
||||
imageUrl: `https://ghchart.rshah.org/${color}/${username}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单个页面
|
||||
* @param {string} pageId 页面ID
|
||||
@@ -793,7 +1143,7 @@ function generateFontVariables(config) {
|
||||
function renderPage(pageId, config) {
|
||||
// 准备页面数据
|
||||
const data = {
|
||||
...config,
|
||||
...(config || {}),
|
||||
currentPage: pageId,
|
||||
pageId // 同时保留pageId字段,用于通用模板
|
||||
};
|
||||
@@ -833,6 +1183,74 @@ function renderPage(pageId, config) {
|
||||
Object.assign(data, config[pageId]);
|
||||
}
|
||||
|
||||
// 页面配置缺失时也尽量给出可用的默认值,避免渲染空标题/undefined
|
||||
if (data.title === undefined) {
|
||||
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 = '';
|
||||
if (!Array.isArray(data.categories)) data.categories = [];
|
||||
|
||||
// 检查页面配置中是否指定了模板(用于派生字段与渲染)
|
||||
const explicitTemplate = typeof data.template === 'string' ? data.template.trim() : '';
|
||||
let templateName = explicitTemplate || pageId;
|
||||
// 未显式指定模板时:若 pages/<pageId>.hbs 不存在,则默认使用通用 page 模板(避免依赖回退日志)
|
||||
if (!explicitTemplate) {
|
||||
const inferredTemplatePath = path.join(process.cwd(), 'templates', 'pages', `${templateName}.hbs`);
|
||||
if (!fs.existsSync(inferredTemplatePath)) {
|
||||
templateName = 'page';
|
||||
}
|
||||
}
|
||||
|
||||
// 页面级卡片风格开关(用于差异化)
|
||||
if (templateName === 'projects') {
|
||||
data.siteCardStyle = 'repo';
|
||||
data.projectsMeta = buildProjectsMeta(config);
|
||||
if (Array.isArray(data.categories)) {
|
||||
const repoCache = tryLoadProjectsRepoCache(pageId, config);
|
||||
if (repoCache && repoCache.map) {
|
||||
applyRepoMetaToCategories(data.categories, repoCache.map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// friends/articles:允许顶层 sites(历史/兼容),自动转换为一个分类容器以保持页面结构一致
|
||||
// 注意:模板名可能被统一为 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) {
|
||||
const implicitName = isFriendsPage ? '全部友链' : '全部来源';
|
||||
data.categories = [
|
||||
{
|
||||
name: implicitName,
|
||||
icon: 'fas fa-link',
|
||||
sites: data.sites
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// articles 模板页面:Phase 2 若存在 RSS 缓存,则注入 articlesItems(缓存缺失/损坏则回退 Phase 1)
|
||||
if (templateName === 'articles') {
|
||||
const cache = tryLoadArticlesFeedCache(pageId, config);
|
||||
data.articlesItems = cache && Array.isArray(cache.items) ? cache.items : [];
|
||||
data.articlesMeta = cache ? cache.meta : null;
|
||||
// Phase 2:按页面配置分类聚合展示(用于模板渲染只读文章列表)
|
||||
data.articlesCategories = data.articlesItems.length
|
||||
? buildArticlesCategoriesByPageCategories(data.categories, data.articlesItems)
|
||||
: [];
|
||||
}
|
||||
|
||||
// bookmarks 模板页面:注入配置文件“内容更新时间”(优先 git,回退 mtime)
|
||||
if (templateName === 'bookmarks') {
|
||||
const updatedAtMeta = getPageConfigUpdatedAtMeta(pageId);
|
||||
if (updatedAtMeta) {
|
||||
data.pageMeta = { ...updatedAtMeta };
|
||||
}
|
||||
}
|
||||
|
||||
// 首页标题规则:使用 site.yml 的 profile 覆盖首页(导航第一项)的 title/subtitle 显示
|
||||
const homePageId = config.homePageId
|
||||
|| (Array.isArray(config.navigation) && config.navigation[0] ? config.navigation[0].id : null)
|
||||
@@ -844,10 +1262,7 @@ function renderPage(pageId, config) {
|
||||
if (config.profile.subtitle !== undefined) data.subtitle = config.profile.subtitle;
|
||||
}
|
||||
|
||||
// 检查页面配置中是否指定了模板
|
||||
let templateName = pageId;
|
||||
if (config[pageId] && config[pageId].template) {
|
||||
templateName = config[pageId].template;
|
||||
console.log(`页面 ${pageId} 使用指定模板: ${templateName}`);
|
||||
}
|
||||
|
||||
|
||||
430
src/script.js
430
src/script.js
@@ -235,9 +235,31 @@ window.MeNav = {
|
||||
const sitesGrid = parent.querySelector('[data-container="sites"]');
|
||||
if (!sitesGrid) return null;
|
||||
|
||||
// 站点卡片样式:根据“页面模板”决定(friends/articles/projects 等)
|
||||
let siteCardStyle = '';
|
||||
try {
|
||||
const pageEl = parent.closest('.page');
|
||||
const pageId = pageEl && pageEl.id ? String(pageEl.id) : '';
|
||||
let templateName = pageId;
|
||||
|
||||
const cfg =
|
||||
window.MeNav && typeof window.MeNav.getConfig === 'function'
|
||||
? window.MeNav.getConfig()
|
||||
: null;
|
||||
const pageConfig = cfg && cfg.data && pageId ? cfg.data[pageId] : null;
|
||||
if (pageConfig && pageConfig.template) {
|
||||
templateName = String(pageConfig.template);
|
||||
}
|
||||
|
||||
// projects 模板使用代码仓库风格卡片(与生成端 templates/components/site-card.hbs 保持一致)
|
||||
if (templateName === 'projects') siteCardStyle = 'repo';
|
||||
} catch (e) {
|
||||
siteCardStyle = '';
|
||||
}
|
||||
|
||||
// 创建新的站点卡片
|
||||
const newSite = document.createElement('a');
|
||||
newSite.className = 'site-card';
|
||||
newSite.className = siteCardStyle ? `site-card site-card-${siteCardStyle}` : 'site-card';
|
||||
|
||||
const siteName = data.name || '未命名站点';
|
||||
const siteUrl = data.url || '#';
|
||||
@@ -246,6 +268,10 @@ window.MeNav = {
|
||||
|
||||
newSite.href = siteUrl;
|
||||
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
|
||||
if (/^https?:\/\//i.test(siteUrl)) {
|
||||
newSite.target = '_blank';
|
||||
newSite.rel = 'noopener';
|
||||
}
|
||||
|
||||
// 设置数据属性
|
||||
newSite.setAttribute('data-type', 'site');
|
||||
@@ -254,76 +280,163 @@ window.MeNav = {
|
||||
newSite.setAttribute('data-icon', siteIcon);
|
||||
newSite.setAttribute('data-description', siteDescription);
|
||||
|
||||
// 添加内容(根据图标模式渲染,避免 innerHTML 注入)
|
||||
const iconWrapper = document.createElement('div');
|
||||
iconWrapper.className = 'site-card-icon';
|
||||
iconWrapper.setAttribute('aria-hidden', 'true');
|
||||
// projects repo 风格:与模板中的 repo 结构保持一致(不走 favicon 逻辑)
|
||||
if (siteCardStyle === 'repo') {
|
||||
const repoHeader = document.createElement('div');
|
||||
repoHeader.className = 'repo-header';
|
||||
|
||||
const contentWrapper = document.createElement('div');
|
||||
contentWrapper.className = 'site-card-content';
|
||||
const repoIcon = document.createElement('i');
|
||||
repoIcon.className = `${siteIcon || 'fas fa-code'} repo-icon`;
|
||||
repoIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const titleEl = document.createElement('h3');
|
||||
titleEl.textContent = siteName;
|
||||
const repoTitle = document.createElement('div');
|
||||
repoTitle.className = 'repo-title';
|
||||
repoTitle.textContent = siteName;
|
||||
|
||||
const descEl = document.createElement('p');
|
||||
descEl.textContent = siteDescription;
|
||||
repoHeader.appendChild(repoIcon);
|
||||
repoHeader.appendChild(repoTitle);
|
||||
|
||||
contentWrapper.appendChild(titleEl);
|
||||
contentWrapper.appendChild(descEl);
|
||||
const repoDesc = document.createElement('div');
|
||||
repoDesc.className = 'repo-desc';
|
||||
repoDesc.textContent = siteDescription;
|
||||
|
||||
let iconsMode = 'favicon';
|
||||
try {
|
||||
const cfg =
|
||||
window.MeNav && typeof window.MeNav.getConfig === 'function'
|
||||
? window.MeNav.getConfig()
|
||||
: null;
|
||||
iconsMode = (cfg && (cfg.data?.icons?.mode || cfg.icons?.mode)) || 'favicon';
|
||||
} catch (e) {
|
||||
iconsMode = 'favicon';
|
||||
}
|
||||
newSite.appendChild(repoHeader);
|
||||
newSite.appendChild(repoDesc);
|
||||
|
||||
if (iconsMode === 'favicon' && data.url && /^https?:\/\//i.test(data.url)) {
|
||||
const faviconUrl = `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(data.url)}&size=32`;
|
||||
const hasStats =
|
||||
data &&
|
||||
(data.language ||
|
||||
data.stars ||
|
||||
data.forks ||
|
||||
data.issues);
|
||||
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.className = 'icon-container';
|
||||
if (hasStats) {
|
||||
const repoStats = document.createElement('div');
|
||||
repoStats.className = 'repo-stats';
|
||||
|
||||
const placeholder = document.createElement('i');
|
||||
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder';
|
||||
placeholder.setAttribute('aria-hidden', 'true');
|
||||
if (data.language) {
|
||||
const languageItem = document.createElement('div');
|
||||
languageItem.className = 'stat-item';
|
||||
|
||||
const fallback = document.createElement('i');
|
||||
fallback.className = `${siteIcon} icon-fallback`;
|
||||
fallback.setAttribute('aria-hidden', 'true');
|
||||
const langDot = document.createElement('span');
|
||||
langDot.className = 'lang-dot';
|
||||
langDot.style.backgroundColor = data.languageColor || '#909296';
|
||||
|
||||
const favicon = document.createElement('img');
|
||||
favicon.className = 'favicon-icon';
|
||||
favicon.src = faviconUrl;
|
||||
favicon.alt = `${siteName} favicon`;
|
||||
favicon.loading = 'lazy';
|
||||
favicon.addEventListener('load', () => {
|
||||
favicon.classList.add('loaded');
|
||||
placeholder.classList.add('hidden');
|
||||
});
|
||||
favicon.addEventListener('error', () => {
|
||||
favicon.classList.add('error');
|
||||
placeholder.classList.add('hidden');
|
||||
fallback.classList.add('visible');
|
||||
});
|
||||
languageItem.appendChild(langDot);
|
||||
languageItem.appendChild(document.createTextNode(String(data.language)));
|
||||
repoStats.appendChild(languageItem);
|
||||
}
|
||||
|
||||
iconContainer.appendChild(placeholder);
|
||||
iconContainer.appendChild(favicon);
|
||||
iconContainer.appendChild(fallback);
|
||||
iconWrapper.appendChild(iconContainer);
|
||||
if (data.stars) {
|
||||
const starsItem = document.createElement('div');
|
||||
starsItem.className = 'stat-item';
|
||||
|
||||
const starIcon = document.createElement('i');
|
||||
starIcon.className = 'far fa-star';
|
||||
starIcon.setAttribute('aria-hidden', 'true');
|
||||
starsItem.appendChild(starIcon);
|
||||
starsItem.appendChild(document.createTextNode(` ${data.stars}`));
|
||||
repoStats.appendChild(starsItem);
|
||||
}
|
||||
|
||||
if (data.forks) {
|
||||
const forksItem = document.createElement('div');
|
||||
forksItem.className = 'stat-item';
|
||||
|
||||
const forkIcon = document.createElement('i');
|
||||
forkIcon.className = 'fas fa-code-branch';
|
||||
forkIcon.setAttribute('aria-hidden', 'true');
|
||||
forksItem.appendChild(forkIcon);
|
||||
forksItem.appendChild(document.createTextNode(` ${data.forks}`));
|
||||
repoStats.appendChild(forksItem);
|
||||
}
|
||||
|
||||
if (data.issues) {
|
||||
const issuesItem = document.createElement('div');
|
||||
issuesItem.className = 'stat-item';
|
||||
|
||||
const issueIcon = document.createElement('i');
|
||||
issueIcon.className = 'fas fa-exclamation-circle';
|
||||
issueIcon.setAttribute('aria-hidden', 'true');
|
||||
issuesItem.appendChild(issueIcon);
|
||||
issuesItem.appendChild(document.createTextNode(` ${data.issues}`));
|
||||
repoStats.appendChild(issuesItem);
|
||||
}
|
||||
|
||||
newSite.appendChild(repoStats);
|
||||
}
|
||||
} else {
|
||||
const iconEl = document.createElement('i');
|
||||
iconEl.className = `${siteIcon} site-icon`;
|
||||
iconEl.setAttribute('aria-hidden', 'true');
|
||||
iconWrapper.appendChild(iconEl);
|
||||
}
|
||||
// 添加内容(根据图标模式渲染,避免 innerHTML 注入)
|
||||
const iconWrapper = document.createElement('div');
|
||||
iconWrapper.className = 'site-card-icon';
|
||||
iconWrapper.setAttribute('aria-hidden', 'true');
|
||||
|
||||
newSite.appendChild(iconWrapper);
|
||||
newSite.appendChild(contentWrapper);
|
||||
const contentWrapper = document.createElement('div');
|
||||
contentWrapper.className = 'site-card-content';
|
||||
|
||||
const titleEl = document.createElement('h3');
|
||||
titleEl.textContent = siteName;
|
||||
|
||||
const descEl = document.createElement('p');
|
||||
descEl.textContent = siteDescription;
|
||||
|
||||
contentWrapper.appendChild(titleEl);
|
||||
contentWrapper.appendChild(descEl);
|
||||
|
||||
let iconsMode = 'favicon';
|
||||
try {
|
||||
const cfg =
|
||||
window.MeNav && typeof window.MeNav.getConfig === 'function'
|
||||
? window.MeNav.getConfig()
|
||||
: null;
|
||||
iconsMode = (cfg && (cfg.data?.icons?.mode || cfg.icons?.mode)) || 'favicon';
|
||||
} catch (e) {
|
||||
iconsMode = 'favicon';
|
||||
}
|
||||
|
||||
if (iconsMode === 'favicon' && data.url && /^https?:\/\//i.test(data.url)) {
|
||||
const faviconUrl = `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(data.url)}&size=32`;
|
||||
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.className = 'icon-container';
|
||||
|
||||
const placeholder = document.createElement('i');
|
||||
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder';
|
||||
placeholder.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const fallback = document.createElement('i');
|
||||
fallback.className = `${siteIcon} icon-fallback`;
|
||||
fallback.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const favicon = document.createElement('img');
|
||||
favicon.className = 'favicon-icon';
|
||||
favicon.src = faviconUrl;
|
||||
favicon.alt = `${siteName} favicon`;
|
||||
favicon.loading = 'lazy';
|
||||
favicon.addEventListener('load', () => {
|
||||
favicon.classList.add('loaded');
|
||||
placeholder.classList.add('hidden');
|
||||
});
|
||||
favicon.addEventListener('error', () => {
|
||||
favicon.classList.add('error');
|
||||
placeholder.classList.add('hidden');
|
||||
fallback.classList.add('visible');
|
||||
});
|
||||
|
||||
iconContainer.appendChild(placeholder);
|
||||
iconContainer.appendChild(favicon);
|
||||
iconContainer.appendChild(fallback);
|
||||
iconWrapper.appendChild(iconContainer);
|
||||
} else {
|
||||
const iconEl = document.createElement('i');
|
||||
iconEl.className = `${siteIcon} site-icon`;
|
||||
iconEl.setAttribute('aria-hidden', 'true');
|
||||
iconWrapper.appendChild(iconEl);
|
||||
}
|
||||
|
||||
newSite.appendChild(iconWrapper);
|
||||
newSite.appendChild(contentWrapper);
|
||||
}
|
||||
|
||||
// 添加到DOM
|
||||
sitesGrid.appendChild(newSite);
|
||||
@@ -703,7 +816,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let isLightTheme = false; // 主题状态
|
||||
let isSidebarCollapsed = false; // 侧边栏折叠状态
|
||||
let pages; // 页面元素的全局引用
|
||||
let currentSearchEngine = 'local'; // 当前选择的搜索引擎
|
||||
let currentSearchEngine = 'local'; // 当前选择的搜索引擎
|
||||
|
||||
// 搜索索引,用于提高搜索效率
|
||||
let searchIndex = {
|
||||
@@ -711,8 +824,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
items: []
|
||||
};
|
||||
|
||||
// 搜索引擎配置
|
||||
const searchEngines = {
|
||||
// 搜索引擎配置
|
||||
const searchEngines = {
|
||||
local: {
|
||||
name: '本地搜索',
|
||||
icon: 'fas fa-search',
|
||||
@@ -733,7 +846,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
icon: 'fas fa-paw',
|
||||
url: 'https://www.baidu.com/s?wd='
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// 获取DOM元素 - 基本元素
|
||||
const searchInput = document.getElementById('search');
|
||||
@@ -879,8 +993,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
page.querySelectorAll('.site-card').forEach(card => {
|
||||
try {
|
||||
const title = card.querySelector('h3')?.textContent?.toLowerCase() || '';
|
||||
const description = card.querySelector('p')?.textContent?.toLowerCase() || '';
|
||||
// 排除“扩展写回影子结构”等不应参与搜索的卡片
|
||||
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 titleText =
|
||||
card.querySelector('h3')?.textContent ||
|
||||
card.querySelector('.repo-title')?.textContent ||
|
||||
dataTitle;
|
||||
const descriptionText =
|
||||
card.querySelector('p')?.textContent ||
|
||||
card.querySelector('.repo-desc')?.textContent ||
|
||||
dataDescription;
|
||||
|
||||
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 || '';
|
||||
|
||||
@@ -1154,114 +1284,84 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!card || !searchTerm) return;
|
||||
|
||||
try {
|
||||
const title = card.querySelector('h3');
|
||||
const description = card.querySelector('p');
|
||||
// 兼容 projects repo 卡片:title/desc 不一定是 h3/p
|
||||
const titleElement = card.querySelector('h3') || card.querySelector('.repo-title');
|
||||
const descriptionElement = card.querySelector('p') || card.querySelector('.repo-desc');
|
||||
|
||||
if (!title || !description) return;
|
||||
const hasPinyinMatch = typeof PinyinMatch !== 'undefined' && PinyinMatch && typeof PinyinMatch.match === 'function';
|
||||
|
||||
// 安全地高亮标题中的匹配文本
|
||||
if (title.textContent.toLowerCase().includes(searchTerm)) {
|
||||
const titleText = title.textContent;
|
||||
const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
|
||||
const applyRangeHighlight = (element, start, end) => {
|
||||
const text = element.textContent || '';
|
||||
const safeStart = Math.max(0, Math.min(text.length, start));
|
||||
const safeEnd = Math.max(safeStart, Math.min(text.length - 1, end));
|
||||
|
||||
// 创建安全的DOM结构而不是直接使用innerHTML
|
||||
const titleFragment = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.appendChild(document.createTextNode(text.slice(0, safeStart)));
|
||||
|
||||
// 使用正则表达式查找所有匹配项
|
||||
const titleRegex = new RegExp(regex);
|
||||
while ((match = titleRegex.exec(titleText)) !== null) {
|
||||
// 添加匹配前的文本
|
||||
if (match.index > lastIndex) {
|
||||
titleFragment.appendChild(document.createTextNode(
|
||||
titleText.substring(lastIndex, match.index)
|
||||
));
|
||||
const span = document.createElement('span');
|
||||
span.className = 'highlight';
|
||||
span.textContent = text.slice(safeStart, safeEnd + 1);
|
||||
fragment.appendChild(span);
|
||||
|
||||
fragment.appendChild(document.createTextNode(text.slice(safeEnd + 1)));
|
||||
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
element.appendChild(fragment);
|
||||
};
|
||||
|
||||
const highlightInElement = element => {
|
||||
if (!element) return;
|
||||
|
||||
const rawText = element.textContent || '';
|
||||
const lowerText = rawText.toLowerCase();
|
||||
if (!rawText) return;
|
||||
|
||||
if (lowerText.includes(searchTerm)) {
|
||||
const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(rawText)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
fragment.appendChild(document.createTextNode(rawText.substring(lastIndex, match.index)));
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = 'highlight';
|
||||
span.textContent = match[0];
|
||||
fragment.appendChild(span);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
|
||||
// 防止无限循环
|
||||
if (regex.lastIndex === 0) break;
|
||||
}
|
||||
|
||||
// 添加高亮的匹配文本
|
||||
const span = document.createElement('span');
|
||||
span.className = 'highlight';
|
||||
span.textContent = match[0];
|
||||
titleFragment.appendChild(span);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
|
||||
// 防止无限循环
|
||||
if (titleRegex.lastIndex === 0) break;
|
||||
}
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastIndex < titleText.length) {
|
||||
titleFragment.appendChild(document.createTextNode(
|
||||
titleText.substring(lastIndex)
|
||||
));
|
||||
}
|
||||
|
||||
// 清空原标题并添加新内容
|
||||
while (title.firstChild) {
|
||||
title.removeChild(title.firstChild);
|
||||
}
|
||||
title.appendChild(titleFragment);
|
||||
} else if (PinyinMatch.match(title.textContent, searchTerm)) {
|
||||
const arr = PinyinMatch.match(title.textContent, searchTerm);
|
||||
const [start, end] = arr;
|
||||
title.innerHTML = title.textContent.slice(0, start) +
|
||||
`<span class="highlight">${title.textContent.slice(start, end + 1)}</span>` +
|
||||
title.textContent.slice(end + 1);
|
||||
}
|
||||
|
||||
// 安全地高亮描述中的匹配文本
|
||||
if (description.textContent.toLowerCase().includes(searchTerm)) {
|
||||
const descText = description.textContent;
|
||||
const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
|
||||
|
||||
// 创建安全的DOM结构而不是直接使用innerHTML
|
||||
const descFragment = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
// 使用正则表达式查找所有匹配项
|
||||
const descRegex = new RegExp(regex);
|
||||
while ((match = descRegex.exec(descText)) !== null) {
|
||||
// 添加匹配前的文本
|
||||
if (match.index > lastIndex) {
|
||||
descFragment.appendChild(document.createTextNode(
|
||||
descText.substring(lastIndex, match.index)
|
||||
));
|
||||
if (lastIndex < rawText.length) {
|
||||
fragment.appendChild(document.createTextNode(rawText.substring(lastIndex)));
|
||||
}
|
||||
|
||||
// 添加高亮的匹配文本
|
||||
const span = document.createElement('span');
|
||||
span.className = 'highlight';
|
||||
span.textContent = match[0];
|
||||
descFragment.appendChild(span);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
|
||||
// 防止无限循环
|
||||
if (descRegex.lastIndex === 0) break;
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
element.appendChild(fragment);
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastIndex < descText.length) {
|
||||
descFragment.appendChild(document.createTextNode(
|
||||
descText.substring(lastIndex)
|
||||
));
|
||||
if (hasPinyinMatch) {
|
||||
const arr = PinyinMatch.match(rawText, searchTerm);
|
||||
if (Array.isArray(arr) && arr.length >= 2) {
|
||||
const [start, end] = arr;
|
||||
applyRangeHighlight(element, start, end);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 清空原描述并添加新内容
|
||||
while (description.firstChild) {
|
||||
description.removeChild(description.firstChild);
|
||||
}
|
||||
description.appendChild(descFragment);
|
||||
} else if (PinyinMatch.match(description.textContent, searchTerm)) {
|
||||
const arr = PinyinMatch.match(description.textContent, searchTerm);
|
||||
const [start, end] = arr;
|
||||
description.innerHTML = description.textContent.slice(0, start) +
|
||||
`<span class="highlight">${description.textContent.slice(start, end + 1)}</span>` +
|
||||
description.textContent.slice(end + 1);
|
||||
}
|
||||
highlightInElement(titleElement);
|
||||
highlightInElement(descriptionElement);
|
||||
} catch (error) {
|
||||
console.error('Error highlighting search term');
|
||||
}
|
||||
@@ -1688,11 +1788,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 初始化嵌套分类功能
|
||||
initializeNestedCategories();
|
||||
|
||||
// 初始化分类切换按钮
|
||||
const categoryToggleBtn = document.getElementById('category-toggle');
|
||||
// 初始化嵌套分类功能
|
||||
initializeNestedCategories();
|
||||
|
||||
// 初始化分类切换按钮
|
||||
const categoryToggleBtn = document.getElementById('category-toggle');
|
||||
if (categoryToggleBtn) {
|
||||
categoryToggleBtn.addEventListener('click', function() {
|
||||
window.MeNav.toggleCategories();
|
||||
|
||||
Reference in New Issue
Block a user