| |
| const fs = require('fs'); |
| const path = require('path'); |
| const { execFileSync } = require('child_process'); |
|
|
| const { |
| loadHandlebarsTemplates, |
| getDefaultLayoutTemplate, |
| renderTemplate, |
| } = require('./template/engine'); |
| const { MENAV_EXTENSION_CONFIG_FILE, loadConfig, getSubmenuForNavItem } = require('./config'); |
|
|
| const { |
| generateNavigation, |
| generateCategories, |
| generateSocialLinks, |
| } = require('./html/components'); |
| const { generate404Html } = require('./html/404'); |
| const { generateFontLinks, generateFontCss } = require('./html/fonts'); |
| const { preparePageData } = require('./html/page-data'); |
| const { collectSitesRecursively } = require('./utils/sites'); |
| const { |
| BuildError, |
| ConfigError, |
| FileError, |
| TemplateError, |
| wrapAsyncError, |
| } = require('./utils/errors'); |
| const { createLogger, isVerbose, startTimer } = require('./utils/logger'); |
|
|
| |
| |
| |
| |
| |
| |
| function renderPage(pageId, config) { |
| const { data, templateName } = preparePageData(pageId, config); |
| return renderTemplate(templateName, data, false); |
| } |
|
|
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| function generateHTML(config) { |
| |
| const pages = generateAllPagesHTML(config); |
|
|
| |
| const currentYear = new Date().getFullYear(); |
|
|
| |
| const navigationData = config.navigation.map((nav) => { |
| const navItem = { ...nav }; |
|
|
| |
| const submenu = getSubmenuForNavItem(navItem, config); |
| if (submenu) { |
| navItem.submenu = submenu; |
| } |
|
|
| return navItem; |
| }); |
|
|
| |
| 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, |
| }; |
|
|
| try { |
| |
| const { template: layoutTemplate } = getDefaultLayoutTemplate(); |
|
|
| |
| return layoutTemplate(layoutData); |
| } catch (error) { |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function tryBundleCss(srcPath, destPath) { |
| let esbuild; |
| try { |
| esbuild = require('esbuild'); |
| } catch { |
| return false; |
| } |
|
|
| try { |
| |
| const absoluteSrc = path.resolve(srcPath); |
| const absoluteDest = path.resolve(destPath); |
|
|
| esbuild.buildSync({ |
| entryPoints: [absoluteSrc], |
| outfile: absoluteDest, |
| bundle: true, |
| minify: true, |
| logLevel: 'silent', |
| }); |
| return true; |
| } catch (error) { |
| const log = createLogger('assets'); |
| log.warn('CSS bundle 失败,降级为复制', { |
| message: error && error.message ? error.message : String(error), |
| }); |
| if (isVerbose() && error && error.stack) console.error(error.stack); |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| function copyDirRecursive(src, dest) { |
| if (!fs.existsSync(src)) return; |
| fs.mkdirSync(dest, { recursive: true }); |
|
|
| for (const entry of fs.readdirSync(src, { withFileTypes: true })) { |
| const srcPath = path.join(src, entry.name); |
| const destPath = path.join(dest, entry.name); |
|
|
| if (entry.isDirectory()) { |
| copyDirRecursive(srcPath, destPath); |
| } else { |
| fs.copyFileSync(srcPath, destPath); |
| } |
| } |
| } |
|
|
| function tryMinifyStaticAsset(srcPath, destPath, loader) { |
| let esbuild; |
| try { |
| esbuild = require('esbuild'); |
| } catch { |
| return false; |
| } |
|
|
| try { |
| const source = fs.readFileSync(srcPath, 'utf8'); |
| const result = esbuild.transformSync(source, { |
| loader, |
| minify: true, |
| charset: 'utf8', |
| }); |
| fs.writeFileSync(destPath, result.code); |
| return true; |
| } catch (error) { |
| const log = createLogger('assets'); |
| log.warn('压缩静态资源失败,已降级为原文件', { |
| path: srcPath, |
| message: error && error.message ? error.message : String(error), |
| }); |
| if (isVerbose() && error && error.stack) console.error(error.stack); |
| return false; |
| } |
| } |
|
|
| |
| function copyStaticFiles(config) { |
| const log = createLogger('assets'); |
|
|
| |
| if (!fs.existsSync('dist')) { |
| fs.mkdirSync('dist', { recursive: true }); |
| } |
|
|
| |
| try { |
| if (!tryBundleCss('assets/style.css', 'dist/style.css')) { |
| |
| fs.copyFileSync('assets/style.css', 'dist/style.css'); |
| copyDirRecursive('assets/styles', 'dist/styles'); |
| log.warn('CSS 未合并,浏览器将发起多个请求'); |
| } |
| } catch (e) { |
| log.error('复制 style.css 失败', { message: e && e.message ? e.message : String(e) }); |
| if (isVerbose() && e && e.stack) console.error(e.stack); |
| } |
|
|
| try { |
| if (!tryMinifyStaticAsset('assets/pinyin-match.js', 'dist/pinyin-match.js', 'js')) { |
| fs.copyFileSync('assets/pinyin-match.js', 'dist/pinyin-match.js'); |
| } |
| } catch (e) { |
| log.error('复制 pinyin-match.js 失败', { message: e && e.message ? e.message : String(e) }); |
| if (isVerbose() && e && e.stack) console.error(e.stack); |
| } |
|
|
| |
|
|
| |
| try { |
| const copied = new Set(); |
|
|
| const copyLocalAsset = (rawUrl) => { |
| const raw = String(rawUrl || '').trim(); |
| if (!raw) return; |
| if (/^https?:\/\//i.test(raw)) return; |
|
|
| const rel = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, ''); |
| if (!rel.startsWith('assets/')) return; |
|
|
| const normalized = path.posix.normalize(rel); |
| if (!normalized.startsWith('assets/')) return; |
| if (copied.has(normalized)) return; |
| copied.add(normalized); |
|
|
| const srcPath = path.join(process.cwd(), normalized); |
| const destPath = path.join(process.cwd(), 'dist', normalized); |
| if (!fs.existsSync(srcPath)) { |
| log.warn('faviconUrl 本地文件不存在', { path: normalized }); |
| return; |
| } |
|
|
| fs.mkdirSync(path.dirname(destPath), { recursive: true }); |
| fs.copyFileSync(srcPath, destPath); |
| }; |
|
|
| if (config && Array.isArray(config.navigation)) { |
| config.navigation.forEach((navItem) => { |
| const pageId = navItem && navItem.id ? String(navItem.id) : ''; |
| if (!pageId) return; |
| const pageConfig = config[pageId]; |
| if (!pageConfig || typeof pageConfig !== 'object') return; |
|
|
| if (Array.isArray(pageConfig.sites)) { |
| pageConfig.sites.forEach((site) => { |
| if (!site || typeof site !== 'object') return; |
| copyLocalAsset(site.faviconUrl); |
| }); |
| } |
|
|
| if (Array.isArray(pageConfig.categories)) { |
| const sites = []; |
| pageConfig.categories.forEach((category) => collectSitesRecursively(category, sites)); |
| sites.forEach((site) => { |
| if (!site || typeof site !== 'object') return; |
| copyLocalAsset(site.faviconUrl); |
| }); |
| } |
| }); |
| } |
| } catch (e) { |
| log.error('复制 faviconUrl 本地资源失败', { message: e && e.message ? e.message : String(e) }); |
| if (isVerbose() && e && e.stack) console.error(e.stack); |
| } |
|
|
| |
| 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 { |
| log.warn('favicon 文件不存在', { path: config.site.favicon }); |
| } |
| } catch (e) { |
| log.error('复制 favicon 失败', { message: e && e.message ? e.message : String(e) }); |
| if (isVerbose() && e && e.stack) console.error(e.stack); |
| } |
| } |
| } |
|
|
| |
| function main() { |
| const cmdLog = createLogger('generate'); |
| const configLog = createLogger('config'); |
| const renderLog = createLogger('render'); |
| const elapsedMs = startTimer(); |
|
|
| cmdLog.info('开始', { version: process.env.npm_package_version }); |
|
|
| let source = 'unknown'; |
| if (fs.existsSync('config/user')) source = 'config/user'; |
| else if (fs.existsSync('config/_default')) source = 'config/_default'; |
| configLog.info('加载模块化配置', { source }); |
|
|
| const config = loadConfig(); |
|
|
| try { |
| |
| if (!fs.existsSync('dist')) { |
| fs.mkdirSync('dist', { recursive: true }); |
| } |
|
|
| renderLog.info('生成页面', { |
| pages: Array.isArray(config.navigation) ? config.navigation.length : 0, |
| }); |
|
|
| |
| loadHandlebarsTemplates(); |
|
|
| |
| const htmlContent = generateHTML(config); |
|
|
| |
| fs.writeFileSync('dist/index.html', htmlContent); |
|
|
| |
| try { |
| const extensionConfig = |
| config && config.extensionConfig ? JSON.stringify(config.extensionConfig, null, 2) : ''; |
| if (extensionConfig) { |
| fs.writeFileSync(path.join('dist', MENAV_EXTENSION_CONFIG_FILE), extensionConfig); |
| } |
| } catch (error) { |
| cmdLog.warn('写入扩展配置文件失败(不影响页面渲染)', { |
| message: error && error.message ? error.message : String(error), |
| }); |
| if (isVerbose() && error && error.stack) console.error(error.stack); |
| } |
|
|
| |
| fs.writeFileSync('dist/404.html', generate404Html(config)); |
|
|
| |
| try { |
| execFileSync(process.execPath, [path.join(process.cwd(), 'scripts', 'build-runtime.js')], { |
| stdio: 'inherit', |
| }); |
| } catch (error) { |
| throw new BuildError('构建运行时脚本失败', { |
| 脚本路径: 'scripts/build-runtime.js', |
| 错误信息: error.message, |
| }); |
| } |
|
|
| |
| copyStaticFiles(config); |
|
|
| cmdLog.ok('完成', { ms: elapsedMs(), dist: 'dist/' }); |
| } catch (e) { |
| |
| if ( |
| e instanceof ConfigError || |
| e instanceof TemplateError || |
| e instanceof BuildError || |
| e instanceof FileError |
| ) { |
| throw e; |
| } |
|
|
| |
| throw new BuildError('构建过程中发生错误', { |
| 错误类型: e.name || 'Error', |
| 错误信息: e.message || '未知错误', |
| }); |
| } |
| } |
|
|
| if (require.main === module) { |
| |
| wrapAsyncError(main)(); |
| } |
|
|
| |
| module.exports = { |
| main, |
| loadConfig, |
| generateHTML, |
| generate404Html, |
| copyStaticFiles, |
| generateNavigation, |
| generateCategories, |
| loadHandlebarsTemplates, |
| renderTemplate, |
| generateAllPagesHTML, |
| }; |
|
|