1526 lines
51 KiB
JavaScript
1526 lines
51 KiB
JavaScript
const fs = require('fs');
|
||
const yaml = require('js-yaml');
|
||
const path = require('path');
|
||
const { execFileSync } = require('child_process');
|
||
const Handlebars = require('handlebars');
|
||
|
||
// 导入Handlebars助手函数
|
||
const { registerAllHelpers } = require('./helpers');
|
||
|
||
// 注册Handlebars实例和辅助函数
|
||
const handlebars = Handlebars.create();
|
||
registerAllHelpers(handlebars);
|
||
|
||
// 加载和注册Handlebars模板的函数
|
||
function loadHandlebarsTemplates() {
|
||
const templatesDir = path.join(process.cwd(), 'templates');
|
||
|
||
// 检查基本模板目录是否存在
|
||
if (!fs.existsSync(templatesDir)) {
|
||
throw new Error('Templates directory not found. Cannot proceed without templates.');
|
||
}
|
||
|
||
// 加载布局模板
|
||
const layoutsDir = path.join(templatesDir, 'layouts');
|
||
if (fs.existsSync(layoutsDir)) {
|
||
fs.readdirSync(layoutsDir).forEach(file => {
|
||
if (file.endsWith('.hbs')) {
|
||
const layoutName = path.basename(file, '.hbs');
|
||
const layoutPath = path.join(layoutsDir, file);
|
||
const layoutContent = fs.readFileSync(layoutPath, 'utf8');
|
||
handlebars.registerPartial(layoutName, layoutContent);
|
||
}
|
||
});
|
||
} else {
|
||
throw new Error('Layouts directory not found. Cannot proceed without layout templates.');
|
||
}
|
||
|
||
// 加载组件模板
|
||
const componentsDir = path.join(templatesDir, 'components');
|
||
if (fs.existsSync(componentsDir)) {
|
||
fs.readdirSync(componentsDir).forEach(file => {
|
||
if (file.endsWith('.hbs')) {
|
||
const componentName = path.basename(file, '.hbs');
|
||
const componentPath = path.join(componentsDir, file);
|
||
const componentContent = fs.readFileSync(componentPath, 'utf8');
|
||
handlebars.registerPartial(componentName, componentContent);
|
||
}
|
||
});
|
||
} else {
|
||
throw new Error('Components directory not found. Cannot proceed without component templates.');
|
||
}
|
||
|
||
// 识别并检查默认布局模板是否存在
|
||
const defaultLayoutPath = path.join(layoutsDir, 'default.hbs');
|
||
if (!fs.existsSync(defaultLayoutPath)) {
|
||
throw new Error('Default layout template not found. Cannot proceed without default layout.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取默认布局模板
|
||
* @returns {Object} 包含模板路径和编译的模板函数
|
||
*/
|
||
function getDefaultLayoutTemplate() {
|
||
const defaultLayoutPath = path.join(process.cwd(), 'templates', 'layouts', 'default.hbs');
|
||
|
||
// 检查默认布局模板是否存在
|
||
if (!fs.existsSync(defaultLayoutPath)) {
|
||
throw new Error('Default layout template not found. Cannot proceed without default layout.');
|
||
}
|
||
|
||
try {
|
||
// 读取布局内容并编译模板
|
||
const layoutContent = fs.readFileSync(defaultLayoutPath, 'utf8');
|
||
const layoutTemplate = handlebars.compile(layoutContent);
|
||
|
||
return {
|
||
path: defaultLayoutPath,
|
||
template: layoutTemplate
|
||
};
|
||
} catch (error) {
|
||
throw new Error(`Error loading default layout template: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// 渲染Handlebars模板函数
|
||
function renderTemplate(templateName, data, useLayout = true) {
|
||
const templatePath = path.join(process.cwd(), 'templates', 'pages', `${templateName}.hbs`);
|
||
|
||
// 检查模板是否存在
|
||
if (!fs.existsSync(templatePath)) {
|
||
// 尝试使用通用模板 page.hbs
|
||
const genericTemplatePath = path.join(process.cwd(), 'templates', 'pages', 'page.hbs');
|
||
|
||
if (fs.existsSync(genericTemplatePath)) {
|
||
console.log(`模板 ${templateName}.hbs 不存在,使用通用模板 page.hbs 代替`);
|
||
const genericTemplateContent = fs.readFileSync(genericTemplatePath, 'utf8');
|
||
const genericTemplate = handlebars.compile(genericTemplateContent);
|
||
|
||
// 添加 pageId 到数据中,以便通用模板使用(优先保留原 pageId,避免回退时语义错位)
|
||
const enhancedData = {
|
||
...data,
|
||
pageId: data && data.pageId ? data.pageId : templateName
|
||
};
|
||
|
||
// 渲染页面内容
|
||
const pageContent = genericTemplate(enhancedData);
|
||
|
||
// 如果不使用布局,直接返回页面内容
|
||
if (!useLayout) {
|
||
return pageContent;
|
||
}
|
||
|
||
try {
|
||
// 使用辅助函数获取默认布局模板
|
||
const { template: layoutTemplate } = getDefaultLayoutTemplate();
|
||
|
||
// 准备布局数据,包含页面内容
|
||
const layoutData = {
|
||
...enhancedData,
|
||
body: pageContent
|
||
};
|
||
|
||
// 渲染完整页面
|
||
return layoutTemplate(layoutData);
|
||
} catch (layoutError) {
|
||
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.`);
|
||
}
|
||
}
|
||
|
||
try {
|
||
const templateContent = fs.readFileSync(templatePath, 'utf8');
|
||
const template = handlebars.compile(templateContent);
|
||
|
||
// 渲染页面内容
|
||
const pageContent = template(data);
|
||
|
||
// 如果不使用布局,直接返回页面内容
|
||
if (!useLayout) {
|
||
return pageContent;
|
||
}
|
||
|
||
try {
|
||
// 使用辅助函数获取默认布局模板
|
||
const { template: layoutTemplate } = getDefaultLayoutTemplate();
|
||
|
||
// 准备布局数据,包含页面内容
|
||
const layoutData = {
|
||
...data,
|
||
body: pageContent
|
||
};
|
||
|
||
// 渲染完整页面
|
||
return layoutTemplate(layoutData);
|
||
} catch (layoutError) {
|
||
throw new Error(`Error rendering layout for ${templateName}: ${layoutError.message}`);
|
||
}
|
||
} catch (error) {
|
||
throw new Error(`Error rendering template ${templateName}: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// HTML转义函数,防止XSS攻击
|
||
function escapeHtml(unsafe) {
|
||
if (unsafe === undefined || unsafe === null) {
|
||
return '';
|
||
}
|
||
return String(unsafe)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
/**
|
||
* 统一处理配置文件加载错误
|
||
* @param {string} filePath 配置文件路径
|
||
* @param {Error} error 错误对象
|
||
*/
|
||
function handleConfigLoadError(filePath, error) {
|
||
console.error(`Error loading configuration from ${filePath}:`, error);
|
||
}
|
||
|
||
/**
|
||
* 安全地加载YAML配置文件
|
||
* @param {string} filePath 配置文件路径
|
||
* @returns {Object|null} 配置对象,如果文件不存在或加载失败则返回null
|
||
*/
|
||
function safeLoadYamlConfig(filePath) {
|
||
if (!fs.existsSync(filePath)) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||
// 使用 loadAll 而不是 load 来支持多文档 YAML 文件
|
||
const docs = yaml.loadAll(fileContent);
|
||
|
||
// 如果只有一个文档,直接返回
|
||
if (docs.length === 1) {
|
||
return docs[0];
|
||
}
|
||
|
||
// 如果有多个文档,返回第一个文档(忽略后面的文档)
|
||
if (docs.length > 1) {
|
||
console.warn(`Warning: Multiple documents found in ${filePath}. Using the first document only.`);
|
||
return docs[0];
|
||
}
|
||
|
||
return null;
|
||
} catch (error) {
|
||
handleConfigLoadError(filePath, error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载模块化配置目录
|
||
* @param {string} dirPath 配置目录路径
|
||
* @returns {Object|null} 配置对象,如果目录不存在或加载失败则返回null
|
||
*/
|
||
function loadModularConfig(dirPath) {
|
||
if (!fs.existsSync(dirPath)) {
|
||
return null;
|
||
}
|
||
|
||
const config = {
|
||
site: {},
|
||
navigation: [],
|
||
fonts: {},
|
||
profile: {},
|
||
social: [],
|
||
categories: []
|
||
};
|
||
|
||
// 加载基础配置
|
||
const siteConfigPath = path.join(dirPath, 'site.yml');
|
||
const siteConfig = safeLoadYamlConfig(siteConfigPath);
|
||
if (siteConfig) {
|
||
// 将site.yml中的内容分配到正确的配置字段
|
||
config.site = siteConfig;
|
||
|
||
// 提取特殊字段到顶层配置
|
||
if (siteConfig.fonts) config.fonts = siteConfig.fonts;
|
||
if (siteConfig.profile) config.profile = siteConfig.profile;
|
||
if (siteConfig.social) config.social = siteConfig.social;
|
||
|
||
// 优先使用site.yml中的navigation配置
|
||
if (siteConfig.navigation) {
|
||
config.navigation = siteConfig.navigation;
|
||
console.log('使用 site.yml 中的导航配置');
|
||
}
|
||
}
|
||
|
||
// 加载页面配置
|
||
const pagesPath = path.join(dirPath, 'pages');
|
||
if (fs.existsSync(pagesPath)) {
|
||
const files = fs.readdirSync(pagesPath).filter(file =>
|
||
file.endsWith('.yml') || file.endsWith('.yaml'));
|
||
|
||
files.forEach(file => {
|
||
const filePath = path.join(pagesPath, file);
|
||
const fileConfig = safeLoadYamlConfig(filePath);
|
||
|
||
if (fileConfig) {
|
||
// 提取文件名(不含扩展名)作为配置键
|
||
const configKey = path.basename(file, path.extname(file));
|
||
|
||
// 将页面配置添加到主配置对象
|
||
config[configKey] = fileConfig;
|
||
}
|
||
});
|
||
}
|
||
|
||
return config;
|
||
}
|
||
|
||
/**
|
||
* 确保配置对象具有必要的默认值
|
||
* @param {Object} config 配置对象
|
||
* @returns {Object} 处理后的配置对象
|
||
*/
|
||
function ensureConfigDefaults(config) {
|
||
// 创建一个新对象,避免修改原始配置
|
||
const result = { ...config };
|
||
|
||
// 确保基本结构存在
|
||
result.site = result.site || {};
|
||
result.navigation = result.navigation || [];
|
||
|
||
// 字体默认值(单一字体配置)
|
||
result.fonts = result.fonts && typeof result.fonts === 'object' ? result.fonts : {};
|
||
result.fonts.source = result.fonts.source || 'css';
|
||
result.fonts.family = result.fonts.family || 'LXGW WenKai';
|
||
result.fonts.weight = result.fonts.weight || 'normal';
|
||
result.fonts.cssUrl = result.fonts.cssUrl || 'https://fontsapi.zeoseven.com/292/main/result.css';
|
||
|
||
result.profile = result.profile || {};
|
||
result.social = result.social || [];
|
||
// 图标配置默认值
|
||
result.icons = result.icons || {};
|
||
// icons.mode: manual | favicon, 默认 favicon
|
||
result.icons.mode = result.icons.mode || 'favicon';
|
||
|
||
// 站点基本信息默认值
|
||
result.site.title = result.site.title || 'MeNav导航';
|
||
result.site.description = result.site.description || '个人网络导航站';
|
||
result.site.author = result.site.author || 'MeNav User';
|
||
result.site.logo_text = result.site.logo_text || '导航站';
|
||
result.site.favicon = result.site.favicon || 'menav.svg';
|
||
result.site.logo = result.site.logo || null;
|
||
result.site.footer = result.site.footer || '';
|
||
result.site.theme = result.site.theme || {
|
||
primary: '#4a89dc',
|
||
background: '#f5f7fa',
|
||
modeToggle: true
|
||
};
|
||
|
||
// 用户资料默认值
|
||
result.profile = result.profile || {};
|
||
result.profile.title = result.profile.title || '欢迎使用';
|
||
result.profile.subtitle = result.profile.subtitle || 'MeNav个人导航系统';
|
||
|
||
// 处理站点默认值的辅助函数
|
||
function processSiteDefaults(site) {
|
||
site.name = site.name || '未命名站点';
|
||
site.url = site.url || '#';
|
||
site.description = site.description || '';
|
||
site.icon = site.icon || 'fas fa-link';
|
||
site.external = typeof site.external === 'boolean' ? site.external : true;
|
||
}
|
||
|
||
// 处理分类默认值的辅助函数
|
||
function processCategoryDefaults(category) {
|
||
category.name = category.name || '未命名分类';
|
||
category.sites = category.sites || [];
|
||
category.sites.forEach(processSiteDefaults);
|
||
}
|
||
|
||
// 为所有页面配置中的类别和站点设置默认值
|
||
Object.keys(result).forEach(key => {
|
||
const pageConfig = result[key];
|
||
// 检查是否是页面配置对象
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 验证配置是否有效
|
||
* @param {Object} config 配置对象
|
||
* @returns {boolean} 配置是否有效
|
||
*/
|
||
function validateConfig(config) {
|
||
// 基本结构检查
|
||
if (!config || typeof config !== 'object') {
|
||
console.error('配置无效: 配置必须是一个对象');
|
||
return false;
|
||
}
|
||
|
||
// 所有其他验证被移除,因为它们只是检查但没有实际操作
|
||
// 配置默认值和数据修复已经在ensureConfigDefaults函数中处理
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 获取导航项的子菜单数据
|
||
* @param {Object} navItem 导航项对象
|
||
* @param {Object} config 配置对象
|
||
* @returns {Array|null} 子菜单数据数组或null
|
||
*/
|
||
function getSubmenuForNavItem(navItem, config) {
|
||
if (!navItem || !navItem.id || !config) {
|
||
return null;
|
||
}
|
||
|
||
// 通用处理:任意页面的子菜单生成(基于 pages/<id>.yml 的 categories)
|
||
if (config[navItem.id] && Array.isArray(config[navItem.id].categories)) return config[navItem.id].categories;
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 将 JSON 字符串安全嵌入到 <script> 中,避免出现 `</script>` 结束标签导致脚本块被提前终止。
|
||
* 说明:返回值仍是合法 JSON,JSON.parse 后数据不变。
|
||
* @param {string} jsonString JSON 字符串
|
||
* @returns {string} 安全的 JSON 字符串
|
||
*/
|
||
function makeJsonSafeForHtmlScript(jsonString) {
|
||
if (typeof jsonString !== 'string') {
|
||
return '';
|
||
}
|
||
|
||
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 配置对象
|
||
* @returns {Object} 增强的渲染数据
|
||
*/
|
||
function prepareRenderData(config) {
|
||
// 创建渲染数据对象,包含原始配置
|
||
const renderData = { ...config };
|
||
|
||
// 添加额外渲染数据
|
||
renderData._meta = {
|
||
generated_at: new Date(),
|
||
version: process.env.npm_package_version || '1.0.0',
|
||
generator: 'MeNav'
|
||
};
|
||
|
||
// 确保navigation是数组
|
||
if (!Array.isArray(renderData.navigation)) {
|
||
renderData.navigation = [];
|
||
// 移除警告日志,数据处理逻辑保留
|
||
}
|
||
|
||
// 添加导航项的活动状态标记和子菜单
|
||
if (Array.isArray(renderData.navigation)) {
|
||
renderData.navigation = renderData.navigation.map((item, index) => {
|
||
const navItem = {
|
||
...item,
|
||
isActive: index === 0, // 默认第一项为活动项
|
||
id: item.id || `nav-${index}`,
|
||
active: index === 0 // 保持旧模板兼容(由顺序决定,不读取配置的 active 字段)
|
||
};
|
||
|
||
// 使用辅助函数获取子菜单
|
||
const submenu = getSubmenuForNavItem(navItem, renderData);
|
||
if (submenu) {
|
||
navItem.submenu = submenu;
|
||
}
|
||
|
||
return navItem;
|
||
});
|
||
}
|
||
|
||
// 首页(默认页)规则:navigation 顺序第一项即首页
|
||
renderData.homePageId = renderData.navigation && renderData.navigation[0] ? renderData.navigation[0].id : null;
|
||
|
||
// 添加序列化的配置数据,用于浏览器扩展(确保包含 homePageId 等处理结果)
|
||
renderData.configJSON = makeJsonSafeForHtmlScript(
|
||
JSON.stringify({
|
||
version: process.env.npm_package_version || '1.0.0',
|
||
timestamp: new Date().toISOString(),
|
||
data: renderData // 使用经过处理的renderData而不是原始config
|
||
})
|
||
);
|
||
|
||
// 为Handlebars模板特别准备navigationData数组
|
||
renderData.navigationData = renderData.navigation;
|
||
|
||
// 确保social数据格式正确
|
||
if (Array.isArray(renderData.social)) {
|
||
renderData.socialLinks = renderData.social; // 兼容模板中的不同引用名
|
||
}
|
||
|
||
return renderData;
|
||
}
|
||
|
||
// 读取配置文件
|
||
function loadConfig() {
|
||
// 初始化空配置对象
|
||
let config = {
|
||
site: {},
|
||
navigation: [],
|
||
fonts: {},
|
||
profile: {},
|
||
social: []
|
||
};
|
||
|
||
// 检查模块化配置来源是否存在
|
||
const hasUserModularConfig = fs.existsSync('config/user');
|
||
const hasDefaultModularConfig = fs.existsSync('config/_default');
|
||
|
||
// 根据优先级顺序选择最高优先级的配置
|
||
if (hasUserModularConfig) {
|
||
// 配置采用“完全替换”策略:一旦存在 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/ 到 config/user/,再按需修改。');
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!fs.existsSync('config/user/pages')) {
|
||
console.warn('[WARN] 检测到 config/user/ 目录,但缺少 config/user/pages/。部分页面内容可能为空。');
|
||
console.warn('[WARN] 建议:复制 config/_default/pages/ 到 config/user/pages/,再按需修改。');
|
||
}
|
||
|
||
// 1. 最高优先级: config/user/ 目录
|
||
config = loadModularConfig('config/user');
|
||
} else if (hasDefaultModularConfig) {
|
||
// 2. 次高优先级: config/_default/ 目录
|
||
config = loadModularConfig('config/_default');
|
||
} else {
|
||
console.error('[ERROR] 未找到可用配置:缺少 config/user/ 或 config/_default/。');
|
||
console.error('[ERROR] 本版本已不再支持旧版单文件配置(config.yml / config.yaml)。');
|
||
console.error('[ERROR] 解决方法:使用模块化配置目录(建议从 config/_default/ 复制到 config/user/ 再修改)。');
|
||
process.exit(1);
|
||
}
|
||
|
||
// 确保配置有默认值并通过验证
|
||
config = ensureConfigDefaults(config);
|
||
|
||
if (!validateConfig(config)) {
|
||
// 移除警告日志,保留函数调用
|
||
}
|
||
|
||
// 准备渲染数据
|
||
const renderData = prepareRenderData(config);
|
||
|
||
return renderData;
|
||
}
|
||
|
||
// 生成导航菜单
|
||
function generateNavigation(navigation, config) {
|
||
return navigation.map(nav => {
|
||
// 根据页面ID获取对应的子菜单项(分类)
|
||
let submenuItems = '';
|
||
|
||
// 使用辅助函数获取子菜单数据
|
||
const submenu = getSubmenuForNavItem(nav, config);
|
||
|
||
// 如果存在子菜单,生成HTML
|
||
if (submenu && Array.isArray(submenu)) {
|
||
submenuItems = `
|
||
<div class="submenu">
|
||
${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('')}
|
||
</div>`;
|
||
}
|
||
|
||
return `
|
||
<div class="nav-item-wrapper">
|
||
<a href="#" class="nav-item${nav.active ? ' active' : ''}" data-page="${escapeHtml(nav.id)}">
|
||
<div class="icon-container">
|
||
<i class="${escapeHtml(nav.icon)}"></i>
|
||
</div>
|
||
<span class="nav-text">${escapeHtml(nav.name)}</span>
|
||
${submenuItems ? '<i class="fas fa-chevron-down submenu-toggle"></i>' : ''}
|
||
</a>
|
||
${submenuItems}
|
||
</div>`;
|
||
}).join('\n');
|
||
}
|
||
|
||
// 生成网站卡片HTML
|
||
function generateSiteCards(sites) {
|
||
if (!sites || !Array.isArray(sites) || sites.length === 0) {
|
||
return `<p class="empty-sites">暂无网站</p>`;
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
// 生成分类板块
|
||
function generateCategories(categories) {
|
||
if (!categories || !Array.isArray(categories) || categories.length === 0) {
|
||
return `
|
||
<section class="category">
|
||
<h2><i class="fas fa-info-circle"></i> 暂无分类</h2>
|
||
<p>请在配置文件中添加分类</p>
|
||
</section>`;
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
// 生成社交链接HTML
|
||
function generateSocialLinks(social) {
|
||
if (!social || !Array.isArray(social) || social.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
// 尝试使用Handlebars模板
|
||
try {
|
||
const socialLinksPath = path.join(process.cwd(), 'templates', 'components', 'social-links.hbs');
|
||
if (fs.existsSync(socialLinksPath)) {
|
||
const templateContent = fs.readFileSync(socialLinksPath, 'utf8');
|
||
const template = handlebars.compile(templateContent);
|
||
// 确保数据格式正确
|
||
return template(social); // 社交链接模板直接接收数组
|
||
}
|
||
} catch (error) {
|
||
console.error('Error rendering social-links template:', error);
|
||
// 出错时回退到原始生成方法
|
||
}
|
||
|
||
// 回退到原始生成方法
|
||
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');
|
||
}
|
||
|
||
// 生成页面内容(包括首页和其他页面)
|
||
function generatePageContent(pageId, data) {
|
||
// 确保数据对象存在
|
||
if (!data) {
|
||
console.error(`Missing data for page: ${pageId}`);
|
||
return `
|
||
<div class="welcome-section">
|
||
<div class="welcome-section-main">
|
||
<h2>页面未配置</h2>
|
||
<p class="subtitle">请配置 ${pageId} 页面</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// 首页使用profile数据,其他页面使用自身数据
|
||
if (pageId === 'home') {
|
||
const profile = data.profile || {};
|
||
|
||
return `
|
||
<div class="welcome-section">
|
||
<div class="welcome-section-main">
|
||
<h2>${escapeHtml(profile.title || '欢迎使用')}</h2>
|
||
<h3>${escapeHtml(profile.subtitle || '个人导航站')}</h3>
|
||
</div>
|
||
</div>
|
||
${generateCategories(data.categories)}`;
|
||
} else {
|
||
// 其他页面使用通用结构
|
||
const title = data.title || `${pageId} 页面`;
|
||
const subtitle = data.subtitle || '';
|
||
const categories = data.categories || [];
|
||
|
||
return `
|
||
<div class="welcome-section">
|
||
<div class="welcome-section-main">
|
||
<h2>${escapeHtml(title)}</h2>
|
||
<p class="subtitle">${escapeHtml(subtitle)}</p>
|
||
</div>
|
||
</div>
|
||
${generateCategories(categories)}`;
|
||
}
|
||
}
|
||
|
||
// 生成搜索结果页面
|
||
function generateSearchResultsPage(config) {
|
||
// 获取所有导航页面ID
|
||
const pageIds = config.navigation.map(nav => nav.id);
|
||
|
||
// 生成所有页面的搜索结果区域
|
||
const searchSections = pageIds.map(pageId => {
|
||
// 根据页面ID获取对应的图标和名称
|
||
const navItem = config.navigation.find(nav => nav.id === pageId);
|
||
const icon = navItem ? navItem.icon : 'fas fa-file';
|
||
const name = navItem ? navItem.name : pageId;
|
||
|
||
return `
|
||
<section class="category search-section" data-section="${escapeHtml(pageId)}" style="display: none;">
|
||
<h2><i class="${escapeHtml(icon)}"></i> ${escapeHtml(name)}匹配项</h2>
|
||
<div class="sites-grid"></div>
|
||
</section>`;
|
||
}).join('\n');
|
||
|
||
return `
|
||
<!-- 搜索结果页 -->
|
||
<div class="page" id="search-results">
|
||
<div class="welcome-section">
|
||
<div class="welcome-section-main">
|
||
<h2>搜索结果</h2>
|
||
<p class="subtitle">在所有页面中找到的匹配项</p>
|
||
</div>
|
||
</div>
|
||
${searchSections}
|
||
</div>`;
|
||
}
|
||
|
||
/**
|
||
* 将 CSS 文本安全嵌入到 <style> 中,避免出现 `</style>` 结束标签导致样式块被提前终止。
|
||
* @param {string} cssText CSS 文本
|
||
* @returns {string} 安全的 CSS 文本
|
||
*/
|
||
function makeCssSafeForHtmlStyleTag(cssText) {
|
||
if (typeof cssText !== 'string') {
|
||
return '';
|
||
}
|
||
|
||
return cssText.replace(/<\/style/gi, '<\\/style');
|
||
}
|
||
|
||
function normalizeFontWeight(input) {
|
||
if (input === undefined || input === null) return 'normal';
|
||
|
||
if (typeof input === 'number' && Number.isFinite(input)) {
|
||
return String(input);
|
||
}
|
||
|
||
const raw = String(input).trim();
|
||
if (!raw) return 'normal';
|
||
|
||
if (/^(normal|bold|bolder|lighter)$/i.test(raw)) return raw.toLowerCase();
|
||
if (/^[1-9]00$/.test(raw)) return raw;
|
||
|
||
return raw;
|
||
}
|
||
|
||
function normalizeFontFamilyForCss(input) {
|
||
const raw = String(input || '').trim();
|
||
if (!raw) return '';
|
||
|
||
const generics = new Set([
|
||
'serif',
|
||
'sans-serif',
|
||
'monospace',
|
||
'cursive',
|
||
'fantasy',
|
||
'system-ui',
|
||
'ui-serif',
|
||
'ui-sans-serif',
|
||
'ui-monospace',
|
||
'ui-rounded',
|
||
'emoji',
|
||
'math',
|
||
'fangsong',
|
||
]);
|
||
|
||
return raw
|
||
.split(',')
|
||
.map(part => part.trim())
|
||
.filter(Boolean)
|
||
.map(part => {
|
||
const unquoted = part.replace(/^['"]|['"]$/g, '').trim();
|
||
if (!unquoted) return '';
|
||
if (generics.has(unquoted)) return unquoted;
|
||
|
||
const needsQuotes = /\s/.test(unquoted);
|
||
if (!needsQuotes) return unquoted;
|
||
|
||
return `"${unquoted.replace(/"/g, '\\"')}"`;
|
||
})
|
||
.filter(Boolean)
|
||
.join(', ');
|
||
}
|
||
|
||
function normalizeFontSource(input) {
|
||
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 : {};
|
||
|
||
return {
|
||
source: normalizeFontSource(fonts.source),
|
||
family: normalizeFontFamilyForCss(fonts.family),
|
||
weight: normalizeFontWeight(fonts.weight),
|
||
cssUrl: String(fonts.cssUrl || fonts.href || '').trim(),
|
||
};
|
||
}
|
||
|
||
// 生成字体相关 <link>(包含固定的首页特殊样式字体)
|
||
function generateFontLinks(config) {
|
||
const fonts = getNormalizedFontsConfig(config);
|
||
const links = [];
|
||
|
||
// 首页特殊样式字体:固定为 Quicksand(不通过配置控制)
|
||
links.push('<link rel="preconnect" href="https://fonts.googleapis.com">');
|
||
links.push('<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>');
|
||
links.push(
|
||
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@500&display=swap">'
|
||
);
|
||
|
||
// 全站基础字体:按配置加载
|
||
if (fonts.source === 'css' && fonts.cssUrl) {
|
||
links.push(
|
||
`<link rel="stylesheet" href="${escapeHtml(fonts.cssUrl)}">`
|
||
);
|
||
}
|
||
|
||
if (fonts.source === 'google' && fonts.family) {
|
||
const familyNoQuotes = fonts.family.replace(/["']/g, '').split(',')[0].trim();
|
||
const weight = /^[1-9]00$/.test(fonts.weight) ? fonts.weight : '400';
|
||
const familyParam = encodeURIComponent(familyNoQuotes).replace(/%20/g, '+');
|
||
links.push(
|
||
`<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${familyParam}:wght@${weight}&display=swap">`
|
||
);
|
||
}
|
||
|
||
return links.join('\n');
|
||
}
|
||
|
||
// 生成字体 CSS 变量(单一字体配置)
|
||
function generateFontCss(config) {
|
||
const fonts = getNormalizedFontsConfig(config);
|
||
const family = fonts.family || 'system-ui, sans-serif';
|
||
const weight = fonts.weight || 'normal';
|
||
|
||
const css = `:root {\n --font-body: ${family};\n --font-weight-body: ${weight};\n}\n`;
|
||
return makeCssSafeForHtmlStyleTag(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
|
||
* @param {Object} config 配置数据
|
||
* @returns {string} 渲染后的HTML
|
||
*/
|
||
function renderPage(pageId, config) {
|
||
// 准备页面数据
|
||
const data = {
|
||
...(config || {}),
|
||
currentPage: pageId,
|
||
pageId // 同时保留pageId字段,用于通用模板
|
||
};
|
||
|
||
// 确保navigation是数组
|
||
if (!Array.isArray(config.navigation)) {
|
||
console.warn('Warning: config.navigation is not an array in renderPage. Using empty array.');
|
||
data.navigation = [];
|
||
} else {
|
||
// 设置当前页面为活动页,其他页面为非活动
|
||
data.navigation = config.navigation.map(nav => {
|
||
const navItem = {
|
||
...nav,
|
||
isActive: nav.id === pageId,
|
||
active: nav.id === pageId // 兼容原有逻辑
|
||
};
|
||
|
||
// 使用辅助函数获取子菜单
|
||
const submenu = getSubmenuForNavItem(navItem, config);
|
||
if (submenu) {
|
||
navItem.submenu = submenu;
|
||
}
|
||
|
||
return navItem;
|
||
});
|
||
}
|
||
|
||
// 确保socialLinks字段存在
|
||
data.socialLinks = Array.isArray(config.social) ? config.social : [];
|
||
|
||
// 确保navigationData可用(针对模板使用)
|
||
data.navigationData = data.navigation;
|
||
|
||
// 页面特定的额外数据
|
||
if (config[pageId]) {
|
||
// 使用已经经过ensureConfigDefaults处理的配置数据
|
||
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)
|
||
|| 'home';
|
||
// 供模板判断“当前是否首页”
|
||
data.homePageId = homePageId;
|
||
if (pageId === homePageId && config.profile) {
|
||
if (config.profile.title !== undefined) data.title = config.profile.title;
|
||
if (config.profile.subtitle !== undefined) data.subtitle = config.profile.subtitle;
|
||
}
|
||
|
||
if (config[pageId] && config[pageId].template) {
|
||
console.log(`页面 ${pageId} 使用指定模板: ${templateName}`);
|
||
}
|
||
|
||
// 直接渲染页面内容,不使用layout布局(因为layout会在generateHTML中统一应用)
|
||
return renderTemplate(templateName, data, false);
|
||
}
|
||
|
||
/**
|
||
* 生成所有页面的HTML内容
|
||
* @param {Object} config 配置对象
|
||
* @returns {Object} 包含所有页面HTML的对象
|
||
*/
|
||
function generateAllPagesHTML(config) {
|
||
// 页面内容集合
|
||
const pages = {};
|
||
|
||
// 渲染配置中定义的所有页面
|
||
if (Array.isArray(config.navigation)) {
|
||
config.navigation.forEach(navItem => {
|
||
const pageId = navItem.id;
|
||
|
||
// 渲染页面内容
|
||
pages[pageId] = renderPage(pageId, config);
|
||
});
|
||
}
|
||
|
||
// 确保搜索结果页存在
|
||
if (!pages['search-results']) {
|
||
pages['search-results'] = renderPage('search-results', config);
|
||
}
|
||
|
||
return pages;
|
||
}
|
||
|
||
/**
|
||
* 生成完整的HTML
|
||
* @param {Object} config 配置对象
|
||
* @returns {string} 完整HTML
|
||
*/
|
||
function generateHTML(config) {
|
||
// 获取所有页面内容
|
||
const pages = generateAllPagesHTML(config);
|
||
|
||
// 获取当前年份
|
||
const currentYear = new Date().getFullYear();
|
||
|
||
// 准备导航数据,添加submenu字段
|
||
const navigationData = config.navigation.map(nav => {
|
||
const navItem = { ...nav };
|
||
|
||
// 使用辅助函数获取子菜单
|
||
const submenu = getSubmenuForNavItem(navItem, config);
|
||
if (submenu) {
|
||
navItem.submenu = submenu;
|
||
}
|
||
|
||
return navItem;
|
||
});
|
||
|
||
// 准备字体链接与 CSS 变量
|
||
const fontLinks = generateFontLinks(config);
|
||
const fontCss = generateFontCss(config);
|
||
|
||
// 准备社交链接
|
||
const socialLinks = generateSocialLinks(config.social);
|
||
|
||
// 使用主布局模板
|
||
const layoutData = {
|
||
...config,
|
||
pages,
|
||
fontLinks,
|
||
fontCss,
|
||
navigationData,
|
||
currentYear,
|
||
socialLinks,
|
||
navigation: generateNavigation(config.navigation, config), // 兼容旧版
|
||
social: Array.isArray(config.social) ? config.social : [], // 兼容旧版
|
||
|
||
// 确保配置数据可用于浏览器扩展
|
||
configJSON: config.configJSON // 从prepareRenderData函数中获取的配置数据
|
||
};
|
||
|
||
try {
|
||
// 使用辅助函数获取默认布局模板
|
||
const { template: layoutTemplate } = getDefaultLayoutTemplate();
|
||
|
||
// 渲染模板
|
||
return layoutTemplate(layoutData);
|
||
} catch (error) {
|
||
console.error('Error rendering main HTML template:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 复制静态文件
|
||
function copyStaticFiles(config) {
|
||
// 确保dist目录存在
|
||
if (!fs.existsSync('dist')) {
|
||
fs.mkdirSync('dist', { recursive: true });
|
||
}
|
||
|
||
// 复制CSS文件
|
||
try {
|
||
fs.copyFileSync('assets/style.css', 'dist/style.css');
|
||
} catch (e) {
|
||
console.error('Error copying style.css:', e);
|
||
}
|
||
|
||
try {
|
||
fs.copyFileSync('assets/pinyin-match.js', 'dist/pinyin-match.js');
|
||
} catch (e) {
|
||
console.error('Error copying pinyin-match.js:', e);
|
||
}
|
||
|
||
// 复制JavaScript文件
|
||
try {
|
||
fs.copyFileSync('src/script.js', 'dist/script.js');
|
||
} catch (e) {
|
||
console.error('Error copying script.js:', e);
|
||
}
|
||
|
||
// 如果配置了favicon,确保文件存在并复制
|
||
if (config.site.favicon) {
|
||
try {
|
||
if (fs.existsSync(`assets/${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 {
|
||
console.warn(`Warning: Favicon file not found: ${config.site.favicon}`);
|
||
}
|
||
} catch (e) {
|
||
console.error('Error copying favicon:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 主函数
|
||
function main() {
|
||
const config = loadConfig();
|
||
|
||
try {
|
||
// 确保dist目录存在
|
||
if (!fs.existsSync('dist')) {
|
||
fs.mkdirSync('dist', { recursive: true });
|
||
}
|
||
|
||
// 初始化Handlebars模板系统
|
||
loadHandlebarsTemplates();
|
||
|
||
// 使用generateHTML函数生成完整的HTML
|
||
const htmlContent = generateHTML(config);
|
||
|
||
// 生成HTML
|
||
fs.writeFileSync('dist/index.html', htmlContent);
|
||
|
||
// 复制静态文件
|
||
copyStaticFiles(config);
|
||
} catch (e) {
|
||
console.error('Error in main function:', e);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
if (require.main === module) {
|
||
main();
|
||
}
|
||
|
||
// 导出供测试使用的函数
|
||
module.exports = {
|
||
loadConfig,
|
||
generateHTML,
|
||
copyStaticFiles,
|
||
generateNavigation,
|
||
generateCategories,
|
||
loadHandlebarsTemplates,
|
||
renderTemplate,
|
||
generateAllPagesHTML
|
||
};
|