me / src /generator /main.js
cheymin's picture
Upload 136 files
e1ae2c6 verified
// 生成端主实现(由 src/generator.js 薄入口加载并 re-export)
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');
/**
* 渲染单个页面
* @param {string} pageId 页面ID
* @param {Object} config 配置数据
* @returns {string} 渲染后的HTML
*/
function renderPage(pageId, config) {
const { data, templateName } = preparePageData(pageId, config);
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) {
throw error;
}
}
/**
* 使用 esbuild bundle 模式合并 CSS @import
* 注意:transformSync 不支持 bundle,必须用 buildSync
* @param {string} srcPath - 源 CSS 文件路径
* @param {string} destPath - 目标文件路径
* @returns {boolean} 是否成功
*/
function tryBundleCss(srcPath, destPath) {
let esbuild;
try {
esbuild = require('esbuild');
} catch {
return false;
}
try {
// buildSync 需要绝对路径
const absoluteSrc = path.resolve(srcPath);
const absoluteDest = path.resolve(destPath);
esbuild.buildSync({
entryPoints: [absoluteSrc],
outfile: absoluteDest,
bundle: true, // 关键:合并 @import
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;
}
}
/**
* 递归复制目录(降级方案:当 esbuild 不可用时复制 styles 目录)
* @param {string} src - 源目录
* @param {string} dest - 目标目录
*/
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');
// 确保 dist 目录存在
if (!fs.existsSync('dist')) {
fs.mkdirSync('dist', { recursive: true });
}
// 复制 CSS 文件(支持模块化 @import)
try {
if (!tryBundleCss('assets/style.css', 'dist/style.css')) {
// 降级:复制入口 + styles 目录
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);
}
// dist/script.js 由构建阶段 runtime bundle 产出(scripts/build-runtime.js),这里不再复制/覆盖
// faviconUrl(站点级自定义图标):若使用本地路径(建议以 assets/ 开头),则复制到 dist 下同路径
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);
}
// 如果配置了 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 {
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 {
// 确保 dist 目录存在
if (!fs.existsSync('dist')) {
fs.mkdirSync('dist', { recursive: true });
}
renderLog.info('生成页面', {
pages: Array.isArray(config.navigation) ? config.navigation.length : 0,
});
// 初始化 Handlebars 模板系统
loadHandlebarsTemplates();
// 使用 generateHTML 函数生成完整的 HTML
const htmlContent = generateHTML(config);
// 生成 HTML
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);
}
// GitHub Pages 静态路由回退:用于支持 /<id> 形式的路径深链接
fs.writeFileSync('dist/404.html', generate404Html(config));
// 构建运行时脚本(bundle → dist/script.js)
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;
}
// 否则包装为 BuildError(避免直接暴露底层异常)
throw new BuildError('构建过程中发生错误', {
错误类型: e.name || 'Error',
错误信息: e.message || '未知错误',
});
}
}
if (require.main === module) {
// 使用 wrapAsyncError 包装主函数,自动处理错误
wrapAsyncError(main)();
}
// 导出供测试使用的函数
module.exports = {
main,
loadConfig,
generateHTML,
generate404Html,
copyStaticFiles,
generateNavigation,
generateCategories,
loadHandlebarsTemplates,
renderTemplate,
generateAllPagesHTML,
};