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:
rbetree
2025-12-28 00:22:54 +08:00
committed by GitHub
parent 1475a8a0d3
commit 387cd2492e
35 changed files with 2927 additions and 851 deletions

View File

@@ -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' };
}
// 主函数

View File

@@ -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;
}
}
/**
* 获取文件 mtimeISO 字符串)
* @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}`);
}

View File

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