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, "'"); } /** * 统一处理配置文件加载错误 * @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/.yml 的 categories) if (config[navItem.id] && Array.isArray(config[navItem.id].categories)) return config[navItem.id].categories; return null; } /** * 将 JSON 字符串安全嵌入到 ` 结束标签导致脚本块被提前终止。 * 说明:返回值仍是合法 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/.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, 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} categories 页面配置 categories(可包含更深层级) * @param {Array} articlesItems Phase 2 文章条目(来自缓存) * @returns {Array<{name: string, icon: string, items: Array}>} */ 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 = ` `; } return ` `; }).join('\n'); } // 生成网站卡片HTML function generateSiteCards(sites) { if (!sites || !Array.isArray(sites) || sites.length === 0) { return `

暂无网站

`; } return sites.map(site => `

${escapeHtml(site.name || '未命名站点')}

${escapeHtml(site.description || '')}

`).join('\n'); } // 生成分类板块 function generateCategories(categories) { if (!categories || !Array.isArray(categories) || categories.length === 0) { return `

暂无分类

请在配置文件中添加分类

`; } return categories.map(category => `

${escapeHtml(category.name)}

${generateSiteCards(category.sites)}
`).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 => ` `).join('\n'); } // 生成页面内容(包括首页和其他页面) function generatePageContent(pageId, data) { // 确保数据对象存在 if (!data) { console.error(`Missing data for page: ${pageId}`); return `

页面未配置

请配置 ${pageId} 页面

`; } // 首页使用profile数据,其他页面使用自身数据 if (pageId === 'home') { const profile = data.profile || {}; return `

${escapeHtml(profile.title || '欢迎使用')}

${escapeHtml(profile.subtitle || '个人导航站')}

${generateCategories(data.categories)}`; } else { // 其他页面使用通用结构 const title = data.title || `${pageId} 页面`; const subtitle = data.subtitle || ''; const categories = data.categories || []; return `

${escapeHtml(title)}

${escapeHtml(subtitle)}

${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 ` `; }).join('\n'); return `

搜索结果

在所有页面中找到的匹配项

${searchSections}
`; } /** * 将 CSS 文本安全嵌入到 ` 结束标签导致样式块被提前终止。 * @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(), }; } // 生成字体相关 (包含固定的首页特殊样式字体) function generateFontLinks(config) { const fonts = getNormalizedFontsConfig(config); const links = []; // 首页特殊样式字体:固定为 Quicksand(不通过配置控制) links.push(''); links.push(''); links.push( '' ); // 全站基础字体:按配置加载 if (fonts.source === 'css' && fonts.cssUrl) { links.push( `` ); } 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( `` ); } 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/.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 };