diff --git a/package.json b/package.json index c293535..1ffe1f1 100644 --- a/package.json +++ b/package.json @@ -6,19 +6,19 @@ "homepage": "https://rbetree.github.io/menav", "scripts": { "generate": "node src/generator.js", - "dev": "npm run sync-projects && npm run sync-articles && node src/generator.js && serve dist -l 5173", - "dev:offline": "node src/generator.js && serve dist -l 5173", + "dev": "node ./scripts/dev.js", + "dev:offline": "node ./scripts/dev-offline.js", "clean": "node ./scripts/clean.js", - "build": "npm run clean && npm run generate", + "build": "node ./scripts/build.js", "sync-articles": "node ./scripts/sync-articles.js", "sync-projects": "node ./scripts/sync-projects.js", "import-bookmarks": "node src/bookmark-processor.js", - "test": "node --test test/*.js", + "test": "node ./scripts/test.js", "lint": "node ./scripts/lint.js", - "format": "prettier --write \"src/**/*.js\" \"scripts/**/*.js\" \"test/**/*.js\" \".github/**/*.yml\" \"*.{md,json}\" \"config/**/*.md\" \"config/**/*.yml\"", - "format:check": "prettier --check \"src/**/*.js\" \"scripts/**/*.js\" \"test/**/*.js\" \".github/**/*.yml\" \"*.{md,json}\" \"config/**/*.md\" \"config/**/*.yml\"", + "format": "node ./scripts/format.js --write", + "format:check": "node ./scripts/format.js --check", "format:check:changed": "node ./scripts/format-check-changed.js", - "check": "npm run lint && npm test && npm run build", + "check": "node ./scripts/check.js", "prepare": "husky" }, "keywords": [ diff --git a/scripts/build-runtime.js b/scripts/build-runtime.js index 9ba2dea..30b1a89 100644 --- a/scripts/build-runtime.js +++ b/scripts/build-runtime.js @@ -1,6 +1,10 @@ const path = require('node:path'); const fs = require('node:fs'); +const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); + +const log = createLogger('bundle'); + function ensureDir(dirPath) { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); @@ -12,7 +16,7 @@ async function main() { try { esbuild = require('esbuild'); } catch (error) { - console.error('未找到 esbuild,请先执行 npm install。'); + log.error('未找到 esbuild,请先执行 npm install。'); process.exitCode = 1; return; } @@ -22,7 +26,7 @@ async function main() { const outFile = path.join(projectRoot, 'dist', 'script.js'); if (!fs.existsSync(entry)) { - console.error(`运行时入口不存在:${path.relative(projectRoot, entry)}`); + log.error('运行时入口不存在', { path: path.relative(projectRoot, entry) }); process.exitCode = 1; return; } @@ -30,6 +34,7 @@ async function main() { ensureDir(path.dirname(outFile)); try { + const elapsedMs = startTimer(); const result = await esbuild.build({ entryPoints: [entry], outfile: outFile, @@ -41,23 +46,25 @@ async function main() { minify: true, legalComments: 'none', metafile: true, - logLevel: 'info', + logLevel: 'silent', }); + const ms = elapsedMs(); const outputs = result && result.metafile && result.metafile.outputs ? result.metafile.outputs : null; const outKey = outputs ? Object.keys(outputs).find((k) => k.endsWith('dist/script.js')) : ''; const bytes = outKey && outputs && outputs[outKey] ? outputs[outKey].bytes : 0; - if (bytes) { - console.log(`✅ runtime bundle 完成:dist/script.js (${bytes} bytes)`); - } else { - console.log('✅ runtime bundle 完成:dist/script.js'); - } + + const meta = { ms }; + if (bytes) meta.bytes = bytes; + log.ok('输出 dist/script.js', meta); } catch (error) { - console.error( - '❌ runtime bundle 失败(禁止回退旧产物):', - error && error.message ? error.message : error - ); + log.error('构建 dist/script.js 失败', { + message: error && error.message ? error.message : String(error), + }); + if (isVerbose() && error && error.stack) { + console.error(error.stack); + } process.exitCode = 1; } } diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..af79979 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,42 @@ +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); + +const log = createLogger('build'); + +function runNode(scriptPath) { + const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' }); + return result && Number.isFinite(result.status) ? result.status : 1; +} + +async function main() { + const elapsedMs = startTimer(); + log.info('开始', { version: process.env.npm_package_version }); + + const repoRoot = path.resolve(__dirname, '..'); + + const cleanExit = runNode(path.join(repoRoot, 'scripts', 'clean.js')); + if (cleanExit !== 0) { + log.error('clean 失败', { exit: cleanExit }); + process.exitCode = cleanExit; + return; + } + + const generatorExit = runNode(path.join(repoRoot, 'src', 'generator.js')); + if (generatorExit !== 0) { + log.error('generate 失败', { exit: generatorExit }); + process.exitCode = generatorExit; + return; + } + + log.ok('完成', { ms: elapsedMs(), dist: 'dist/' }); +} + +if (require.main === module) { + main().catch((error) => { + log.error('构建失败', { message: error && error.message ? error.message : String(error) }); + if (isVerbose() && error && error.stack) console.error(error.stack); + process.exitCode = 1; + }); +} diff --git a/scripts/check.js b/scripts/check.js new file mode 100644 index 0000000..6d1eb0c --- /dev/null +++ b/scripts/check.js @@ -0,0 +1,49 @@ +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); + +const log = createLogger('check'); + +function runNode(scriptPath) { + const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' }); + return result && Number.isFinite(result.status) ? result.status : 1; +} + +async function main() { + const elapsedMs = startTimer(); + log.info('开始', { version: process.env.npm_package_version }); + + const repoRoot = path.resolve(__dirname, '..'); + + const lintExit = runNode(path.join(repoRoot, 'scripts', 'lint.js')); + if (lintExit !== 0) { + log.error('lint 失败', { exit: lintExit }); + process.exitCode = lintExit; + return; + } + + const testExit = runNode(path.join(repoRoot, 'scripts', 'test.js')); + if (testExit !== 0) { + log.error('test 失败', { exit: testExit }); + process.exitCode = testExit; + return; + } + + const buildExit = runNode(path.join(repoRoot, 'scripts', 'build.js')); + if (buildExit !== 0) { + log.error('build 失败', { exit: buildExit }); + process.exitCode = buildExit; + return; + } + + log.ok('完成', { ms: elapsedMs() }); +} + +if (require.main === module) { + main().catch((error) => { + log.error('执行失败', { message: error && error.message ? error.message : String(error) }); + if (isVerbose() && error && error.stack) console.error(error.stack); + process.exitCode = 1; + }); +} diff --git a/scripts/clean.js b/scripts/clean.js index d81b94d..86d466f 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -1,13 +1,19 @@ const fs = require('fs'); const path = require('path'); +const { createLogger } = require('../src/generator/utils/logger'); + +const log = createLogger('clean'); + const distPath = path.resolve(__dirname, '..', 'dist'); try { fs.rmSync(distPath, { recursive: true, force: true }); - console.log(`Removed ${distPath}`); + log.ok('删除 dist 目录', { path: distPath }); } catch (error) { - console.error(`Failed to remove ${distPath}`); - console.error(error); + log.error('删除 dist 目录失败', { + path: distPath, + message: error && error.message ? error.message : String(error), + }); process.exitCode = 1; } diff --git a/scripts/dev-offline.js b/scripts/dev-offline.js new file mode 100644 index 0000000..fa191d7 --- /dev/null +++ b/scripts/dev-offline.js @@ -0,0 +1,55 @@ +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); +const { startServer } = require('./serve-dist'); + +const log = createLogger('dev:offline'); +let serverRef = null; + +function runNode(scriptPath) { + const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' }); + return result && Number.isFinite(result.status) ? result.status : 1; +} + +async function main() { + const elapsedMs = startTimer(); + log.info('开始', { version: process.env.npm_package_version }); + + const repoRoot = path.resolve(__dirname, '..'); + const generatorPath = path.join(repoRoot, 'src', 'generator.js'); + + const exitCode = runNode(generatorPath); + if (exitCode !== 0) { + log.error('生成失败', { exit: exitCode }); + process.exitCode = exitCode; + return; + } + + const portRaw = process.env.PORT || process.env.MENAV_PORT || '5173'; + const port = Number.parseInt(portRaw, 10) || 5173; + const { server, port: actualPort } = await startServer({ + rootDir: path.join(repoRoot, 'dist'), + host: process.env.HOST || '0.0.0.0', + port, + }); + serverRef = server; + + log.ok('就绪', { ms: elapsedMs(), url: `http://localhost:${actualPort}` }); + + const shutdown = () => { + log.info('正在关闭...'); + if (!serverRef) process.exit(0); + serverRef.close(() => process.exit(0)); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +if (require.main === module) { + main().catch((error) => { + log.error('启动失败', { message: error && error.message ? error.message : String(error) }); + if (isVerbose() && error && error.stack) console.error(error.stack); + process.exitCode = 1; + }); +} diff --git a/scripts/dev.js b/scripts/dev.js new file mode 100644 index 0000000..9d5d71c --- /dev/null +++ b/scripts/dev.js @@ -0,0 +1,63 @@ +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); +const { startServer } = require('./serve-dist'); + +const log = createLogger('dev'); +let serverRef = null; + +function runNode(scriptPath) { + const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' }); + return result && Number.isFinite(result.status) ? result.status : 1; +} + +async function main() { + const elapsedMs = startTimer(); + log.info('开始', { version: process.env.npm_package_version }); + + const repoRoot = path.resolve(__dirname, '..'); + + // best-effort:同步失败不阻断 dev + const syncProjectsExit = runNode(path.join(repoRoot, 'scripts', 'sync-projects.js')); + if (syncProjectsExit !== 0) + log.warn('sync-projects 异常退出,已继续(best-effort)', { exit: syncProjectsExit }); + + const syncArticlesExit = runNode(path.join(repoRoot, 'scripts', 'sync-articles.js')); + if (syncArticlesExit !== 0) + log.warn('sync-articles 异常退出,已继续(best-effort)', { exit: syncArticlesExit }); + + const generatorExit = runNode(path.join(repoRoot, 'src', 'generator.js')); + if (generatorExit !== 0) { + log.error('生成失败', { exit: generatorExit }); + process.exitCode = generatorExit; + return; + } + + const portRaw = process.env.PORT || process.env.MENAV_PORT || '5173'; + const port = Number.parseInt(portRaw, 10) || 5173; + const { server, port: actualPort } = await startServer({ + rootDir: path.join(repoRoot, 'dist'), + host: process.env.HOST || '0.0.0.0', + port, + }); + serverRef = server; + + log.ok('就绪', { ms: elapsedMs(), url: `http://localhost:${actualPort}` }); + + const shutdown = () => { + log.info('正在关闭...'); + if (!serverRef) process.exit(0); + serverRef.close(() => process.exit(0)); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +if (require.main === module) { + main().catch((error) => { + log.error('启动失败', { message: error && error.message ? error.message : String(error) }); + if (isVerbose() && error && error.stack) console.error(error.stack); + process.exitCode = 1; + }); +} diff --git a/scripts/format-check-changed.js b/scripts/format-check-changed.js index a8fe787..6818b2f 100644 --- a/scripts/format-check-changed.js +++ b/scripts/format-check-changed.js @@ -2,6 +2,10 @@ const fs = require('node:fs'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); +const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); + +const log = createLogger('format:check:changed'); + function runGit(args, cwd, options = {}) { const { allowFailure = false, stdio } = options; try { @@ -103,17 +107,13 @@ function collectChangedFiles(repoRoot, range) { const baseExists = gitObjectExists(repoRoot, range.base); const headExists = gitObjectExists(repoRoot, range.head); if (!baseExists || !headExists) { - console.warn( - '格式检查:检测到 diff range 所需提交缺失,尝试补全 git 历史(避免浅克隆导致失败)' - ); + log.warn('检测到 diff range 所需提交缺失,尝试补全 git 历史(避免浅克隆导致失败)'); tryFetchMoreHistory(repoRoot); } const output = runGit(diffArgs, repoRoot, { allowFailure: true }); if (!output) { - console.warn( - '格式检查:无法计算 revision range,回退为 HEAD 变更文件(可能仅覆盖最后一次提交)' - ); + log.warn('无法计算 revision range,回退为 HEAD 变更文件(可能仅覆盖最后一次提交)'); return collectHeadChangedFiles(repoRoot); } @@ -175,6 +175,9 @@ function resolvePrettierBin(repoRoot) { function main() { const repoRoot = path.resolve(__dirname, '..'); + const elapsedMs = startTimer(); + + log.info('开始'); const event = tryReadGithubEvent(process.env.GITHUB_EVENT_PATH); const range = getDiffRangeFromGithubEvent(event); @@ -186,21 +189,33 @@ function main() { const filesToCheck = candidateFiles.filter(shouldCheckFile); if (filesToCheck.length === 0) { - console.log('格式检查:未发现需要检查的文件,跳过。'); + log.ok('未发现需要检查的文件,跳过'); return; } const prettierBin = resolvePrettierBin(repoRoot); if (!prettierBin) { - console.error('格式检查失败:未找到 prettier,可先运行 npm ci / npm install。'); + log.error('未找到 prettier,可先运行 npm ci / npm install'); process.exitCode = 1; return; } - console.log(`格式检查:共 ${filesToCheck.length} 个文件`); - filesToCheck.forEach((filePath) => console.log(`- ${filePath}`)); + log.info('准备检查文件格式', { files: filesToCheck.length }); + if (isVerbose()) { + filesToCheck.forEach((filePath) => log.info('待检查', { file: filePath })); + } - execFileSync(prettierBin, ['--check', ...filesToCheck], { cwd: repoRoot, stdio: 'inherit' }); + try { + execFileSync(prettierBin, ['--check', ...filesToCheck], { cwd: repoRoot, stdio: 'inherit' }); + log.ok('通过', { ms: elapsedMs(), files: filesToCheck.length }); + } catch (error) { + log.error('未通过', { + ms: elapsedMs(), + files: filesToCheck.length, + exit: error && error.status ? error.status : 1, + }); + process.exitCode = error && error.status ? error.status : 1; + } } main(); diff --git a/scripts/format.js b/scripts/format.js new file mode 100644 index 0000000..2b208be --- /dev/null +++ b/scripts/format.js @@ -0,0 +1,67 @@ +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); + +const lifecycleEvent = process.env.npm_lifecycle_event + ? String(process.env.npm_lifecycle_event) + : ''; +const scope = + lifecycleEvent === 'format' || lifecycleEvent.startsWith('format:') ? lifecycleEvent : 'format'; +const log = createLogger(scope); + +const PATTERNS = [ + 'src/**/*.js', + 'scripts/**/*.js', + 'test/**/*.js', + '.github/**/*.yml', + '*.{md,json}', + 'config/**/*.md', + 'config/**/*.yml', +]; + +function parseMode(argv) { + if (argv.includes('--check')) return 'check'; + if (argv.includes('--write')) return 'write'; + return 'check'; +} + +async function main() { + const elapsedMs = startTimer(); + const mode = parseMode(process.argv.slice(2)); + log.info('开始', { mode, version: process.env.npm_package_version }); + + const repoRoot = path.resolve(__dirname, '..'); + const prettierCli = path.join(repoRoot, 'node_modules', 'prettier', 'bin', 'prettier.cjs'); + + const args = []; + if (mode === 'write') args.push('--write'); + else args.push('--check'); + + // Prettier 本身会根据 .prettierignore 过滤;这里不额外做 file list,保持输出简洁 + if (isVerbose()) { + log.info('检查范围', { patterns: PATTERNS.join(' ') }); + } + + const result = spawnSync(process.execPath, [prettierCli, ...args, ...PATTERNS], { + cwd: repoRoot, + stdio: 'inherit', + }); + + const exitCode = result && Number.isFinite(result.status) ? result.status : 1; + if (exitCode !== 0) { + log.error('失败', { ms: elapsedMs(), exit: exitCode }); + process.exitCode = exitCode; + return; + } + + log.ok('完成', { ms: elapsedMs(), mode }); +} + +if (require.main === module) { + main().catch((error) => { + log.error('执行失败', { message: error && error.message ? error.message : String(error) }); + if (isVerbose() && error && error.stack) console.error(error.stack); + process.exitCode = 1; + }); +} diff --git a/scripts/lint.js b/scripts/lint.js index f2322c6..1b46446 100644 --- a/scripts/lint.js +++ b/scripts/lint.js @@ -2,6 +2,10 @@ const fs = require('node:fs'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); +const { createLogger } = require('../src/generator/utils/logger'); + +const log = createLogger('lint'); + function collectJsFiles(rootDir) { const files = []; @@ -39,7 +43,7 @@ function main() { const jsFiles = targetDirs.flatMap((dir) => collectJsFiles(dir)).sort(); if (jsFiles.length === 0) { - console.log('未发现需要检查的 .js 文件,跳过。'); + log.ok('未发现需要检查的 .js 文件,跳过'); return; } @@ -50,17 +54,15 @@ function main() { execFileSync(process.execPath, ['--check', filePath], { stdio: 'inherit' }); } catch (error) { hasError = true; - console.error(`\n语法检查失败:${relativePath}`); - if (error && error.status) { - console.error(`退出码:${error.status}`); - } + log.error('语法检查失败', { file: relativePath, exit: error && error.status }); } }); if (hasError) { + log.error('语法检查未通过', { files: jsFiles.length }); process.exitCode = 1; } else { - console.log(`语法检查通过:${jsFiles.length} 个文件`); + log.ok('语法检查通过', { files: jsFiles.length }); } } diff --git a/scripts/serve-dist.js b/scripts/serve-dist.js new file mode 100644 index 0000000..733bac6 --- /dev/null +++ b/scripts/serve-dist.js @@ -0,0 +1,192 @@ +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); + +const { createLogger, isVerbose } = require('../src/generator/utils/logger'); + +const log = createLogger('serve'); + +function parseInteger(value, fallback) { + const n = Number.parseInt(String(value), 10); + return Number.isFinite(n) ? n : fallback; +} + +function parseArgs(argv) { + const args = Array.isArray(argv) ? argv.slice() : []; + + const getValue = (keys) => { + const idx = args.findIndex((a) => keys.includes(a)); + if (idx === -1) return null; + const next = args[idx + 1]; + return next ? String(next) : null; + }; + + const portArg = getValue(['--port', '-p']); + const hostArg = getValue(['--host', '-h']); + const rootArg = getValue(['--root']); + + return { + port: portArg ? parseInteger(portArg, null) : null, + host: hostArg ? String(hostArg) : null, + root: rootArg ? String(rootArg) : null, + }; +} + +function getContentType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.html') return 'text/html; charset=utf-8'; + if (ext === '.js') return 'text/javascript; charset=utf-8'; + if (ext === '.css') return 'text/css; charset=utf-8'; + if (ext === '.json') return 'application/json; charset=utf-8'; + if (ext === '.svg') return 'image/svg+xml'; + if (ext === '.png') return 'image/png'; + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; + if (ext === '.gif') return 'image/gif'; + if (ext === '.webp') return 'image/webp'; + if (ext === '.ico') return 'image/x-icon'; + if (ext === '.txt') return 'text/plain; charset=utf-8'; + if (ext === '.xml') return 'application/xml; charset=utf-8'; + if (ext === '.map') return 'application/json; charset=utf-8'; + return 'application/octet-stream'; +} + +function sendNotFound(res, rootDir) { + const fallback404 = path.join(rootDir, '404.html'); + if (fs.existsSync(fallback404)) { + res.statusCode = 404; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + fs.createReadStream(fallback404).pipe(res); + return; + } + + res.statusCode = 404; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Not Found'); +} + +function sendFile(req, res, filePath, rootDir) { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return sendNotFound(res, rootDir); + + res.statusCode = 200; + res.setHeader('Content-Type', getContentType(filePath)); + res.setHeader('Content-Length', String(stat.size)); + + if (req.method === 'HEAD') { + res.end(); + return; + } + + fs.createReadStream(filePath).pipe(res); + } catch (error) { + log.warn('读取文件失败', { + path: filePath, + message: error && error.message ? error.message : String(error), + }); + if (isVerbose() && error && error.stack) console.error(error.stack); + sendNotFound(res, rootDir); + } +} + +function buildHandler(rootDir) { + const normalizedRoot = path.resolve(rootDir); + + return (req, res) => { + const rawUrl = req.url || '/'; + const rawPath = rawUrl.split('?')[0] || '/'; + + let decodedPath = '/'; + try { + decodedPath = decodeURIComponent(rawPath); + } catch { + res.statusCode = 400; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Bad Request'); + return; + } + + const safePath = decodedPath.replace(/\\/g, '/'); + const resolved = path.resolve(normalizedRoot, `.${safePath}`); + if (!resolved.startsWith(`${normalizedRoot}${path.sep}`) && resolved !== normalizedRoot) { + res.statusCode = 403; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Forbidden'); + return; + } + + let target = resolved; + try { + if (fs.existsSync(target) && fs.statSync(target).isDirectory()) { + target = path.join(target, 'index.html'); + } + } catch { + // ignore + } + + if (!fs.existsSync(target)) { + sendNotFound(res, normalizedRoot); + return; + } + + sendFile(req, res, target, normalizedRoot); + }; +} + +function startServer(options = {}) { + const { rootDir, host, port } = options; + const normalizedRoot = path.resolve(rootDir); + + if (!fs.existsSync(normalizedRoot)) { + throw new Error(`dist 目录不存在:${normalizedRoot}`); + } + + const handler = buildHandler(normalizedRoot); + const server = http.createServer(handler); + + return new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(port, host, () => { + const addr = server.address(); + const actualPort = addr && typeof addr === 'object' ? addr.port : port; + resolve({ server, port: actualPort, host }); + }); + }); +} + +async function main() { + const repoRoot = path.resolve(__dirname, '..'); + const defaultRoot = path.join(repoRoot, 'dist'); + const args = parseArgs(process.argv.slice(2)); + + const port = + args.port ?? parseInteger(process.env.PORT || process.env.MENAV_PORT || '5173', 5173); + const host = args.host || process.env.HOST || '0.0.0.0'; + const rootDir = args.root ? path.resolve(repoRoot, args.root) : defaultRoot; + + log.info('启动静态服务', { root: path.relative(repoRoot, rootDir) || '.', host, port }); + + const { server, port: actualPort } = await startServer({ rootDir, host, port }); + + log.ok('就绪', { url: `http://localhost:${actualPort}` }); + + const shutdown = () => { + log.info('正在关闭...'); + server.close(() => process.exit(0)); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +if (require.main === module) { + main().catch((error) => { + log.error('启动失败', { message: error && error.message ? error.message : String(error) }); + if (isVerbose() && error && error.stack) console.error(error.stack); + process.exitCode = 1; + }); +} + +module.exports = { + startServer, +}; diff --git a/scripts/sync-articles.js b/scripts/sync-articles.js index a385728..60b5cd5 100644 --- a/scripts/sync-articles.js +++ b/scripts/sync-articles.js @@ -6,6 +6,9 @@ const net = require('node:net'); const Parser = require('rss-parser'); const { loadConfig } = require('../src/generator.js'); +const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); + +const log = createLogger('sync:articles'); const DEFAULT_RSS_SETTINGS = { enabled: true, @@ -488,7 +491,7 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) { throw lastError || new Error('未找到可用 Feed'); }; - const startedAt = Date.now(); + const elapsedMs = startTimer(); for (let i = 0; i <= settings.fetch.maxRetries; i += 1) { try { // eslint-disable-next-line no-await-in-loop @@ -501,7 +504,7 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) { status: 'success', error: '', fetchedAt: new Date().toISOString(), - durationMs: Date.now() - startedAt, + durationMs: elapsedMs(), }, articles: res.articles, }; @@ -518,7 +521,7 @@ async function processSourceSite(sourceSite, settings, parser, deadlineTs) { status: 'failed', error: lastError ? String(lastError.message || lastError) : '未知错误', fetchedAt: new Date().toISOString(), - durationMs: Date.now() - startedAt, + durationMs: elapsedMs(), }, articles: [], }; @@ -582,6 +585,7 @@ async function syncArticlesForPage(pageId, pageConfig, config, settings) { pageConfig && Array.isArray(pageConfig.categories) ? pageConfig.categories : [] ); + const elapsedMs = startTimer(); const startedAt = Date.now(); const deadlineTs = startedAt + settings.fetch.totalTimeoutMs; @@ -635,7 +639,7 @@ async function syncArticlesForPage(pageId, pageConfig, config, settings) { failedSites, skippedSites, totalArticles: limitedArticles.length, - durationMs: Date.now() - startedAt, + durationMs: elapsedMs(), }, }; @@ -670,43 +674,63 @@ function pickArticlesPages(config, onlyPageId) { } async function main() { + const elapsedMs = startTimer(); const args = process.argv.slice(2); const pageArgIndex = args.findIndex((a) => a === '--page'); const onlyPageId = pageArgIndex >= 0 ? args[pageArgIndex + 1] : null; + log.info('开始', { page: onlyPageId || '' }); + const config = loadConfig(); const settings = getRssSettings(config); if (!settings.enabled) { - console.log('[INFO] RSS 已禁用(RSS_ENABLED=false),跳过。'); + log.ok('RSS 已禁用,跳过', { env: 'RSS_ENABLED=false' }); return; } const pages = pickArticlesPages(config, onlyPageId); if (pages.length === 0) { - console.log('[INFO] 未找到需要同步的 articles 页面。'); + log.ok('未找到需要同步的 articles 页面,跳过'); return; } - console.log(`[INFO] 准备同步 ${pages.length} 个 articles 页面缓存…`); + log.info('准备同步 articles 页面缓存', { pages: pages.length }); + + let success = 0; + let failed = 0; for (const { pageId, pageConfig } of pages) { try { // eslint-disable-next-line no-await-in-loop const { cachePath, cache } = await syncArticlesForPage(pageId, pageConfig, config, settings); - console.log( - `[INFO] 已生成缓存:${cachePath}(articles=${cache.stats.totalArticles}, sites=${cache.stats.totalSites})` - ); + success += 1; + log.ok('已生成缓存', { + page: pageId, + cache: cachePath, + articles: cache && cache.stats ? cache.stats.totalArticles : '', + sites: cache && cache.stats ? cache.stats.totalSites : '', + }); } catch (e) { - console.warn(`[WARN] 页面 ${pageId} 同步失败:${e.message || e}`); + failed += 1; + log.warn('页面同步失败,已跳过(best-effort)', { + page: pageId, + message: e && e.message ? e.message : String(e), + }); + if (isVerbose() && e && e.stack) console.error(e.stack); // best-effort:不阻断其他页面/后续 build } } + + log.ok('完成', { ms: elapsedMs(), pages: pages.length, success, failed }); } if (require.main === module) { main().catch((err) => { - console.error('[ERROR] sync-articles 执行失败:', err); + log.error('执行失败(best-effort,不阻断后续 build/deploy)', { + message: err && err.message ? err.message : String(err), + }); + if (isVerbose() && err && err.stack) console.error(err.stack); // best-effort:不阻断后续 build/deploy(错误已输出到日志,便于排查) process.exitCode = 0; }); diff --git a/scripts/sync-projects.js b/scripts/sync-projects.js index d08e34c..965975e 100644 --- a/scripts/sync-projects.js +++ b/scripts/sync-projects.js @@ -3,6 +3,9 @@ const fs = require('fs'); const path = require('path'); const { loadConfig } = require('../src/generator.js'); +const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); + +const log = createLogger('sync:projects'); const DEFAULT_SETTINGS = { enabled: true, @@ -153,9 +156,9 @@ async function loadLanguageColors(settings, cacheBaseDir) { return colors; } } catch (error) { - console.warn( - `[WARN] 获取语言颜色表失败(将不输出 languageColor):${String(error && error.message ? error.message : error)}` - ); + log.warn('获取语言颜色表失败(将不输出 languageColor)', { + message: String(error && error.message ? error.message : error), + }); } return {}; @@ -209,11 +212,14 @@ async function runPool(items, concurrency, worker) { } async function main() { + const elapsedMs = startTimer(); const config = loadConfig(); const settings = getSettings(config); + log.info('开始'); + if (!settings.enabled) { - console.log('[INFO] projects 仓库同步已禁用(PROJECTS_ENABLED=false)'); + log.ok('projects 仓库同步已禁用,跳过', { env: 'PROJECTS_ENABLED=false' }); return; } @@ -226,10 +232,15 @@ async function main() { const pages = findProjectsPages(config); if (!pages.length) { - console.log('[INFO] 未找到 template=projects 的页面,跳过同步'); + log.ok('未找到 template=projects 的页面,跳过同步'); return; } + log.info('准备同步 projects 页面缓存', { pages: pages.length }); + + let pageSuccess = 0; + let pageFailed = 0; + for (const { pageId, page } of pages) { const categories = Array.isArray(page.categories) ? page.categories : []; const sites = []; @@ -244,7 +255,7 @@ async function main() { const repoList = Array.from(unique.values()); if (!repoList.length) { - console.log(`[INFO] 页面 ${pageId}:未发现 GitHub 仓库链接,跳过`); + log.ok('页面未发现 GitHub 仓库链接,跳过', { page: pageId }); continue; } @@ -258,9 +269,10 @@ async function main() { return meta; } catch (error) { failed += 1; - console.warn( - `[WARN] 拉取失败:${repo.canonicalUrl}(${String(error && error.message ? error.message : error)})` - ); + log.warn('拉取仓库元信息失败(best-effort)', { + repo: repo.canonicalUrl, + message: String(error && error.message ? error.message : error), + }); return null; } }); @@ -280,13 +292,24 @@ async function main() { const cachePath = path.join(cacheBaseDir, `${pageId}.repo-cache.json`); fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2), 'utf8'); - console.log( - `[INFO] 页面 ${pageId}:同步完成(成功 ${success} / 失败 ${failed}),写入缓存 ${cachePath}` - ); + if (failed === 0) pageSuccess += 1; + else pageFailed += 1; + + log.ok('页面同步完成', { + page: pageId, + success, + failed, + cache: cachePath, + }); } + + log.ok('完成', { ms: elapsedMs(), pages: pages.length, pageSuccess, pageFailed }); } main().catch((error) => { - console.error('[ERROR] projects 同步异常:', error); + log.error('执行异常(best-effort,不阻断后续 build)', { + message: error && error.message ? error.message : String(error), + }); + if (isVerbose() && error && error.stack) console.error(error.stack); process.exitCode = 0; // best-effort:不阻断后续 build }); diff --git a/scripts/test.js b/scripts/test.js new file mode 100644 index 0000000..a18d195 --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,52 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); + +const log = createLogger('test'); + +function collectTestFiles(repoRoot) { + const testDir = path.join(repoRoot, 'test'); + if (!fs.existsSync(testDir)) return []; + + return fs + .readdirSync(testDir) + .filter((name) => name.endsWith('.js')) + .map((name) => path.join('test', name)) + .sort(); +} + +async function main() { + const elapsedMs = startTimer(); + log.info('开始', { version: process.env.npm_package_version }); + + const repoRoot = path.resolve(__dirname, '..'); + const files = collectTestFiles(repoRoot); + if (files.length === 0) { + log.ok('未发现测试文件,跳过'); + return; + } + + const result = spawnSync(process.execPath, ['--test', ...files], { + cwd: repoRoot, + stdio: 'inherit', + }); + + const exitCode = result && Number.isFinite(result.status) ? result.status : 1; + if (exitCode !== 0) { + log.error('失败', { ms: elapsedMs(), exit: exitCode }); + process.exitCode = exitCode; + return; + } + + log.ok('完成', { ms: elapsedMs(), files: files.length }); +} + +if (require.main === module) { + main().catch((error) => { + log.error('执行失败', { message: error && error.message ? error.message : String(error) }); + if (isVerbose() && error && error.stack) console.error(error.stack); + process.exitCode = 1; + }); +} diff --git a/src/bookmark-processor.js b/src/bookmark-processor.js index ccf7bb3..5e9592a 100644 --- a/src/bookmark-processor.js +++ b/src/bookmark-processor.js @@ -2,6 +2,9 @@ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); const { FileError, wrapAsyncError } = require('./generator/utils/errors'); +const { createLogger, isVerbose, startTimer } = require('./generator/utils/logger'); + +const log = createLogger('import-bookmarks'); // 书签文件夹路径 - 使用相对路径 const BOOKMARKS_DIR = 'bookmarks'; @@ -26,16 +29,12 @@ function ensureUserConfigInitialized() { if (fs.existsSync(CONFIG_DEFAULT_DIR)) { fs.cpSync(CONFIG_DEFAULT_DIR, CONFIG_USER_DIR, { recursive: true }); - console.log( - '[INFO] 检测到 config/user 不存在,已从 config/_default 初始化用户配置(完全替换策略需要完整配置)。' - ); + log.info('config/user 不存在,已从 config/_default 初始化用户配置(完全替换策略)'); return { initialized: true, source: '_default' }; } fs.mkdirSync(CONFIG_USER_DIR, { recursive: true }); - console.log( - '[WARN] 未找到默认配置目录 config/_default,已创建空的 config/user 目录;建议补齐 site.yml 与 pages/*.yml。' - ); + log.warn('未找到 config/_default,已创建空的 config/user;建议补齐 site.yml 与 pages/*.yml'); return { initialized: true, source: 'empty' }; } @@ -49,12 +48,12 @@ function ensureUserSiteYmlExists() { fs.mkdirSync(CONFIG_USER_DIR, { recursive: true }); } fs.copyFileSync(DEFAULT_SITE_YML, USER_SITE_YML); - console.log('[INFO] 未找到 config/user/site.yml,已从 config/_default/site.yml 复制一份。'); + log.info('未找到 config/user/site.yml,已从 config/_default/site.yml 复制'); return true; } - console.log( - '[WARN] 未找到可用的 site.yml,无法自动更新导航;请手动在 config/user/site.yml 添加 navigation(含 id: bookmarks)。' + log.warn( + '未找到可用的 site.yml,无法自动更新导航;请在 config/user/site.yml 添加 navigation(含 id: bookmarks)' ); return false; } @@ -185,7 +184,7 @@ function getLatestBookmarkFile() { // 确保书签目录存在 if (!fs.existsSync(BOOKMARKS_DIR)) { fs.mkdirSync(BOOKMARKS_DIR, { recursive: true }); - console.log('[WARN] 书签目录为空,未找到HTML文件'); + log.warn('bookmarks 目录不存在,已创建;未找到 HTML 书签文件', { dir: BOOKMARKS_DIR }); return null; } @@ -195,7 +194,7 @@ function getLatestBookmarkFile() { .filter((file) => file.toLowerCase().endsWith('.html')); if (files.length === 0) { - console.log('[WARN] 未找到任何HTML书签文件'); + log.warn('未找到任何 HTML 书签文件', { dir: BOOKMARKS_DIR }); return null; } @@ -210,11 +209,12 @@ function getLatestBookmarkFile() { const latestFile = fileStats[0].file; const latestFilePath = path.join(BOOKMARKS_DIR, latestFile); - console.log('[INFO] 选择最新的书签文件:', latestFile); + log.info('选择最新的书签文件', { file: latestFile }); return latestFilePath; } catch (error) { - console.error('[ERROR] 查找书签文件时出错:', error); + log.error('查找书签文件时出错', { message: error && error.message ? error.message : error }); + if (isVerbose() && error && error.stack) console.error(error.stack); return null; } } @@ -612,11 +612,11 @@ function parseBookmarks(htmlContent) { /
]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i ); if (!bookmarkBarMatch) { - console.log('[WARN] 未找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER),使用备用方案'); + log.warn('未找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER),使用备用方案'); // 备用方案:使用第一个

标签 const firstDLMatch = htmlContent.match(/

/i); if (!firstDLMatch) { - console.log('[ERROR] 未找到任何书签容器'); + log.error('未找到任何书签容器'); bookmarks.categories = []; } else { const dlStart = firstDLMatch.index + firstDLMatch[0].length; @@ -651,7 +651,7 @@ function parseBookmarks(htmlContent) { const remainingAfterBar = htmlContent.substring(bookmarkBarStart); const dlMatch = remainingAfterBar.match(/

/i); if (!dlMatch) { - console.log('[ERROR] 未找到书签栏的内容容器

'); + log.error('未找到书签栏的内容容器

'); bookmarks.categories = []; } else { const bookmarkBarContentStart = bookmarkBarStart + dlMatch.index + dlMatch[0].length; @@ -691,11 +691,11 @@ function parseBookmarks(htmlContent) { } } - console.log(`[INFO] 解析完成 - 共找到 ${bookmarks.categories.length} 个顶层分类`); + log.info('解析完成', { categories: bookmarks.categories.length }); // 如果存在根路径书签,创建"根目录书签"特殊分类并插入到首位 if (rootSites.length > 0) { - console.log(`[INFO] 创建"根目录书签"特殊分类,包含 ${rootSites.length} 个书签`); + log.info('创建"根目录书签"特殊分类', { sites: rootSites.length }); const rootCategory = { name: '根目录书签', icon: 'fas fa-star', @@ -705,7 +705,7 @@ function parseBookmarks(htmlContent) { // 插入到数组首位 bookmarks.categories.unshift(rootCategory); - console.log(`[INFO] "根目录书签"已插入到分类列表首位`); + log.info('"根目录书签"已插入到分类列表首位'); } return bookmarks; @@ -742,7 +742,10 @@ ${yamlString}`; return yamlWithComment; } catch (error) { - console.error('Error generating YAML:', error); + log.error('生成 YAML 失败', { + message: error && error.message ? error.message : String(error), + }); + if (isVerbose() && error && error.stack) console.error(error.stack); return null; } } @@ -768,46 +771,44 @@ function updateNavigationWithBookmarks() { // 主函数 async function main() { - console.log('========================================'); - console.log('[INFO] 书签处理脚本启动'); - console.log('[INFO] 时间:', new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })); - console.log('========================================\n'); + const elapsedMs = startTimer(); + log.info('开始', { version: process.env.npm_package_version }); // 获取最新的书签文件 - console.log('[步骤 1/5] 查找书签文件...'); + log.info('查找书签文件', { dir: BOOKMARKS_DIR }); const bookmarkFile = getLatestBookmarkFile(); if (!bookmarkFile) { - console.log('[WARN] 未找到书签文件,已跳过处理(将HTML书签文件放入 bookmarks/ 后再运行)。'); + log.ok('未找到书签文件,跳过', { dir: BOOKMARKS_DIR }); return; } - console.log('[SUCCESS] 找到书签文件\n'); + log.ok('找到书签文件', { file: bookmarkFile }); try { // 读取文件内容 - console.log('[步骤 2/5] 读取书签文件...'); + log.info('读取书签文件', { file: bookmarkFile }); const htmlContent = fs.readFileSync(bookmarkFile, 'utf8'); - console.log('[SUCCESS] 文件读取成功,大小:', htmlContent.length, '字符\n'); + log.ok('读取成功', { chars: htmlContent.length }); // 解析书签 - console.log('[步骤 3/5] 解析书签结构...'); + log.info('解析书签结构'); const bookmarks = parseBookmarks(htmlContent); if (bookmarks.categories.length === 0) { - console.error('[ERROR] HTML文件中未找到书签分类,处理终止'); + log.error('HTML 文件中未找到书签分类,处理终止'); return; } - console.log('[SUCCESS] 解析完成\n'); + log.ok('解析完成', { categories: bookmarks.categories.length }); // 生成YAML - console.log('[步骤 4/5] 生成YAML配置...'); + log.info('生成 YAML 配置'); const yamlContent = generateBookmarksYaml(bookmarks); if (!yamlContent) { - console.error('[ERROR] YAML生成失败,处理终止'); + log.error('YAML 生成失败,处理终止'); return; } - console.log('[SUCCESS] YAML生成成功\n'); + log.ok('YAML 生成成功'); // 保存文件 - console.log('[步骤 5/5] 保存配置文件...'); + log.info('写入配置文件', { path: MODULAR_OUTPUT_FILE }); try { // 完全替换策略:若尚未初始化用户配置,则先从默认配置初始化一份完整配置 ensureUserConfigInitialized(); @@ -829,25 +830,35 @@ async function main() { ]); } - console.log('[SUCCESS] 文件保存成功'); - console.log('[INFO] 输出文件:', MODULAR_OUTPUT_FILE, '\n'); + log.ok('写入成功', { path: MODULAR_OUTPUT_FILE }); // 更新导航 - console.log('[附加步骤] 更新导航配置...'); + log.info('更新导航配置(确保包含 bookmarks 入口)'); const navUpdateResult = updateNavigationWithBookmarks(); if (navUpdateResult.updated) { - console.log(`[SUCCESS] 导航配置已更新(${navUpdateResult.target})\n`); + log.ok('导航配置已更新', { + target: navUpdateResult.target, + reason: navUpdateResult.reason, + }); } else if (navUpdateResult.reason === 'already_present') { - console.log('[INFO] 导航配置已包含书签入口,无需更新\n'); - } else if (navUpdateResult.reason === 'no_navigation_config') { - console.log( - '[WARN] 未找到可更新的导航配置文件;请确认 config/user/site.yml 存在且包含 navigation\n' - ); + log.ok('导航配置已包含书签入口,无需更新', { target: navUpdateResult.target }); + } else if (navUpdateResult.reason === 'no_site_yml') { + log.warn('未找到可用的 site.yml,无法自动更新导航', { path: USER_SITE_YML }); + } else if (navUpdateResult.reason === 'navigation_not_array') { + log.warn('site.yml 中 navigation 不是数组,无法自动更新导航', { path: USER_SITE_YML }); } else if (navUpdateResult.reason === 'error') { - console.log('[WARN] 导航更新失败,请手动检查配置文件格式(详见错误信息)\n'); - console.error(navUpdateResult.error); + log.warn('导航更新失败,请手动检查配置文件格式(详见错误信息)'); + if (navUpdateResult.error) { + log.warn('导航更新错误详情', { + message: navUpdateResult.error.message + ? navUpdateResult.error.message + : String(navUpdateResult.error), + }); + if (isVerbose() && navUpdateResult.error.stack) + console.error(navUpdateResult.error.stack); + } } else { - console.log('[INFO] 导航配置无需更新\n'); + log.info('导航配置无需更新', { reason: navUpdateResult.reason }); } } catch (writeError) { throw new FileError('写入文件时出错', MODULAR_OUTPUT_FILE, [ @@ -857,9 +868,7 @@ async function main() { ]); } - console.log('========================================'); - console.log('[SUCCESS] 书签处理完成!'); - console.log('========================================'); + log.ok('完成', { ms: elapsedMs(), output: MODULAR_OUTPUT_FILE }); } catch (error) { // 如果是自定义错误,直接抛出 if (error instanceof FileError) { diff --git a/src/generator/cache/articles.js b/src/generator/cache/articles.js index e62a7f2..bd328f7 100644 --- a/src/generator/cache/articles.js +++ b/src/generator/cache/articles.js @@ -2,6 +2,9 @@ const fs = require('fs'); const path = require('path'); const { collectSitesRecursively, normalizeUrlKey } = require('../utils/sites'); +const { createLogger } = require('../utils/logger'); + +const log = createLogger('cache:articles'); /** * 读取 articles 页面 RSS 缓存(Phase 2) @@ -68,7 +71,7 @@ function tryLoadArticlesFeedCache(pageId, config) { }, }; } catch (e) { - console.warn(`[WARN] articles 缓存读取失败:${cachePath}(将回退 Phase 1)`); + log.warn('articles 缓存读取失败,将回退 Phase 1', { path: cachePath }); return null; } } diff --git a/src/generator/cache/projects.js b/src/generator/cache/projects.js index 41dcaff..7a961d6 100644 --- a/src/generator/cache/projects.js +++ b/src/generator/cache/projects.js @@ -1,6 +1,10 @@ const fs = require('fs'); const path = require('path'); +const { createLogger } = require('../utils/logger'); + +const log = createLogger('cache:projects'); + function tryLoadProjectsRepoCache(pageId, config) { if (!pageId) return null; @@ -43,7 +47,7 @@ function tryLoadProjectsRepoCache(pageId, config) { }, }; } catch (e) { - console.warn(`[WARN] projects 缓存读取失败:${cachePath}(将仅展示标题与描述)`); + log.warn('projects 缓存读取失败,将仅展示标题与描述', { path: cachePath }); return null; } } diff --git a/src/generator/config/index.js b/src/generator/config/index.js index a466907..46f35f2 100644 --- a/src/generator/config/index.js +++ b/src/generator/config/index.js @@ -10,6 +10,9 @@ const { } = require('./resolver'); const { assignCategorySlugs } = require('./slugs'); const { ConfigError } = require('../utils/errors'); +const { createLogger } = require('../utils/logger'); + +const log = createLogger('config'); function loadConfig() { let config = { @@ -33,10 +36,8 @@ function loadConfig() { } if (!fs.existsSync('config/user/pages')) { - console.warn( - '[WARN] 检测到 config/user/ 目录,但缺少 config/user/pages/。部分页面内容可能为空。' - ); - console.warn('[WARN] 建议:复制 config/_default/pages/ 到 config/user/pages/,再按需修改。'); + log.warn('检测到 config/user/pages/ 缺失,部分页面内容可能为空'); + log.warn('建议复制 config/_default/pages/ 到 config/user/pages/,再按需修改'); } config = loadModularConfig('config/user'); diff --git a/src/generator/config/loader.js b/src/generator/config/loader.js index b4fd998..9ce71c1 100644 --- a/src/generator/config/loader.js +++ b/src/generator/config/loader.js @@ -2,8 +2,18 @@ const fs = require('node:fs'); const path = require('node:path'); const yaml = require('js-yaml'); +const { createLogger, isVerbose } = require('../utils/logger'); + +const log = createLogger('config'); + function handleConfigLoadError(filePath, error) { - console.error(`Error loading configuration from ${filePath}:`, error); + log.error('加载配置失败', { + path: filePath, + message: error && error.message ? error.message : String(error), + }); + if (isVerbose() && error && error.stack) { + console.error(error.stack); + } } function safeLoadYamlConfig(filePath) { @@ -20,9 +30,7 @@ function safeLoadYamlConfig(filePath) { } if (docs.length > 1) { - console.warn( - `Warning: Multiple documents found in ${filePath}. Using the first document only.` - ); + log.warn('检测到 YAML 多文档,仅使用第一个', { path: filePath }); return docs[0]; } @@ -59,7 +67,7 @@ function loadModularConfig(dirPath) { if (siteConfig.navigation) { config.navigation = siteConfig.navigation; - console.log('使用 site.yml 中的导航配置'); + if (isVerbose()) log.info('使用 site.yml 中的 navigation 配置'); } } diff --git a/src/generator/config/validator.js b/src/generator/config/validator.js index efd834c..8215bc4 100644 --- a/src/generator/config/validator.js +++ b/src/generator/config/validator.js @@ -77,7 +77,8 @@ function ensureConfigDefaults(config) { function validateConfig(config) { if (!config || typeof config !== 'object') { - console.error('配置无效: 配置必须是一个对象'); + const { createLogger } = require('../utils/logger'); + createLogger('config').error('配置无效:配置必须是对象'); return false; } diff --git a/src/generator/html/components.js b/src/generator/html/components.js index 50a1e2f..07bf051 100644 --- a/src/generator/html/components.js +++ b/src/generator/html/components.js @@ -4,6 +4,9 @@ const path = require('path'); const { handlebars } = require('../template/engine'); const { getSubmenuForNavItem } = require('../config'); const { escapeHtml } = require('../utils/html'); +const { createLogger, isVerbose } = require('../utils/logger'); + +const log = createLogger('render'); // 生成导航菜单 function generateNavigation(navigation, config) { @@ -104,7 +107,10 @@ function generateSocialLinks(social) { return template(social); // 社交链接模板直接接收数组 } } catch (error) { - console.error('Error rendering social-links template:', error); + log.warn('渲染 social-links 模板失败,已回退到内置渲染', { + message: error && error.message ? error.message : String(error), + }); + if (isVerbose() && error && error.stack) console.error(error.stack); // 出错时回退到原始生成方法 } @@ -124,7 +130,7 @@ function generateSocialLinks(social) { function generatePageContent(pageId, data) { // 确保数据对象存在 if (!data) { - console.error(`Missing data for page: ${pageId}`); + log.warn('页面数据缺失,已回退为占位页面', { page: pageId }); return `

diff --git a/src/generator/html/page-data.js b/src/generator/html/page-data.js index bf1cd94..035c778 100644 --- a/src/generator/html/page-data.js +++ b/src/generator/html/page-data.js @@ -11,10 +11,13 @@ const { buildProjectsMeta, } = require('../cache/projects'); const { getPageConfigUpdatedAtMeta } = require('../utils/pageMeta'); +const { createLogger, isVerbose } = require('../utils/logger'); + +const log = createLogger('render'); function prepareNavigationData(pageId, config) { if (!Array.isArray(config.navigation)) { - console.warn('Warning: config.navigation is not an array in renderPage. Using empty array.'); + log.warn('config.navigation 不是数组,已降级为空数组'); return []; } @@ -162,7 +165,7 @@ function preparePageData(pageId, config) { } if (config[pageId] && config[pageId].template) { - console.log(`页面 ${pageId} 使用指定模板: ${templateName}`); + if (isVerbose()) log.info(`页面 ${pageId} 使用指定模板`, { template: templateName }); } return { data, templateName }; diff --git a/src/generator/main.js b/src/generator/main.js index 841fc76..13c603a 100644 --- a/src/generator/main.js +++ b/src/generator/main.js @@ -26,6 +26,7 @@ const { TemplateError, wrapAsyncError, } = require('./utils/errors'); +const { createLogger, isVerbose, startTimer } = require('./utils/logger'); /** * 渲染单个页面 @@ -120,7 +121,6 @@ function generateHTML(config) { // 渲染模板 return layoutTemplate(layoutData); } catch (error) { - console.error('Error rendering main HTML template:', error); throw error; } } @@ -143,13 +143,20 @@ function tryMinifyStaticAsset(srcPath, destPath, loader) { fs.writeFileSync(destPath, result.code); return true; } catch (error) { - console.error(`Error minifying ${srcPath}:`, 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 }); @@ -161,7 +168,8 @@ function copyStaticFiles(config) { fs.copyFileSync('assets/style.css', 'dist/style.css'); } } catch (e) { - console.error('Error copying style.css:', e); + log.error('复制 style.css 失败', { message: e && e.message ? e.message : String(e) }); + if (isVerbose() && e && e.stack) console.error(e.stack); } try { @@ -169,7 +177,8 @@ function copyStaticFiles(config) { fs.copyFileSync('assets/pinyin-match.js', 'dist/pinyin-match.js'); } } catch (e) { - console.error('Error copying pinyin-match.js:', 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),这里不再复制/覆盖 @@ -194,7 +203,7 @@ function copyStaticFiles(config) { const srcPath = path.join(process.cwd(), normalized); const destPath = path.join(process.cwd(), 'dist', normalized); if (!fs.existsSync(srcPath)) { - console.warn(`[WARN] faviconUrl 本地文件不存在:${normalized}`); + log.warn('faviconUrl 本地文件不存在', { path: normalized }); return; } @@ -227,7 +236,8 @@ function copyStaticFiles(config) { }); } } catch (e) { - console.error('Error copying faviconUrl assets:', e); + log.error('复制 faviconUrl 本地资源失败', { message: e && e.message ? e.message : String(e) }); + if (isVerbose() && e && e.stack) console.error(e.stack); } // 如果配置了 favicon,确保文件存在并复制 @@ -241,16 +251,29 @@ function copyStaticFiles(config) { } 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}`); + log.warn('favicon 文件不存在', { path: config.site.favicon }); } } catch (e) { - console.error('Error copying favicon:', 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 { @@ -259,6 +282,10 @@ function main() { fs.mkdirSync('dist', { recursive: true }); } + renderLog.info('生成页面', { + pages: Array.isArray(config.navigation) ? config.navigation.length : 0, + }); + // 初始化 Handlebars 模板系统 loadHandlebarsTemplates(); @@ -276,7 +303,10 @@ function main() { fs.writeFileSync(path.join('dist', MENAV_EXTENSION_CONFIG_FILE), extensionConfig); } } catch (error) { - console.error('Error writing extension config file:', error); + cmdLog.warn('写入扩展配置文件失败(不影响页面渲染)', { + message: error && error.message ? error.message : String(error), + }); + if (isVerbose() && error && error.stack) console.error(error.stack); } // GitHub Pages 静态路由回退:用于支持 / 形式的路径深链接 @@ -296,6 +326,8 @@ function main() { // 复制静态文件 copyStaticFiles(config); + + cmdLog.ok('完成', { ms: elapsedMs(), dist: 'dist/' }); } catch (e) { // 如果是自定义错误,直接抛出,保留上下文/路径信息 if ( diff --git a/src/generator/template/engine.js b/src/generator/template/engine.js index 1e63f2e..100ffb6 100644 --- a/src/generator/template/engine.js +++ b/src/generator/template/engine.js @@ -3,6 +3,9 @@ const path = require('node:path'); const Handlebars = require('handlebars'); const { registerAllHelpers } = require('../../helpers'); +const { createLogger, isVerbose } = require('../utils/logger'); + +const log = createLogger('template'); const handlebars = Handlebars.create(); registerAllHelpers(handlebars); @@ -82,7 +85,11 @@ function renderTemplate(templateName, data, useLayout = true) { ); } - console.log(`模板 ${templateName}.hbs 不存在,使用通用模板 page.hbs 代替`); + if (isVerbose()) { + log.info('页面模板不存在,已回退到通用模板 page.hbs', { template: `${templateName}.hbs` }); + } else { + log.warn('页面模板不存在,已回退到通用模板 page.hbs', { template: `${templateName}.hbs` }); + } const genericTemplateContent = fs.readFileSync(genericTemplatePath, 'utf8'); const genericTemplate = handlebars.compile(genericTemplateContent); diff --git a/src/generator/utils/errors.js b/src/generator/utils/errors.js index c3d6032..248bf50 100644 --- a/src/generator/utils/errors.js +++ b/src/generator/utils/errors.js @@ -49,38 +49,42 @@ class FileError extends Error { * @param {number} exitCode - 退出码,默认为 1 */ function handleError(error, exitCode = 1) { + const { formatPrefix, isVerbose } = require('./logger'); + // 错误标题行 - console.error(`\n✖ ${error.name}: ${error.message}`); + console.error(`\n${formatPrefix('ERROR')} ${error.name}: ${error.message}`); // 文件路径(如果有) if (error.filePath || error.templatePath) { const path = error.filePath || error.templatePath; - console.error(` 位置: ${path}`); + console.error(`位置: ${path}`); } // 上下文信息(如果有) if (error.context && Object.keys(error.context).length > 0) { - console.error('│'); + console.error('上下文:'); for (const [key, value] of Object.entries(error.context)) { - console.error(`│ ${key}: ${value}`); + console.error(` ${key}: ${value}`); } } // 修复建议(如果有) if (error.suggestions && error.suggestions.length > 0) { - console.error('│'); - console.error('➜ 解决方案:'); + console.error('建议:'); error.suggestions.forEach((suggestion, index) => { - console.error(` ${index + 1}. ${suggestion}`); + console.error(` ${index + 1}) ${suggestion}`); }); } // DEBUG 提示(仅在非 DEBUG 模式下显示) if (process.env.DEBUG) { - console.error('\n堆栈跟踪:'); + console.error('\n堆栈:'); + console.error(error.stack || String(error)); + } else if (isVerbose() && error && error.stack) { + console.error('\n堆栈:'); console.error(error.stack); } else { - console.error('\n(设置 DEBUG=1 查看堆栈跟踪)'); + console.error('\n提示: DEBUG=1 查看堆栈'); } console.error(); // 空行结束 diff --git a/src/generator/utils/logger.js b/src/generator/utils/logger.js new file mode 100644 index 0000000..207e3e7 --- /dev/null +++ b/src/generator/utils/logger.js @@ -0,0 +1,78 @@ +function parseBooleanEnv(value) { + if (value === undefined || value === null || value === '') return false; + const v = String(value).trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'y' || v === 'on'; +} + +function isVerbose() { + return parseBooleanEnv(process.env.MENAV_VERBOSE) || parseBooleanEnv(process.env.DEBUG); +} + +function isColorEnabled() { + if (process.env.NO_COLOR) return false; + if (parseBooleanEnv(process.env.FORCE_COLOR)) return true; + return Boolean( + (process.stdout && process.stdout.isTTY) || (process.stderr && process.stderr.isTTY) + ); +} + +function colorize(text, ansiCode) { + if (!ansiCode || !isColorEnabled()) return text; + return `\x1b[${ansiCode}m${text}\x1b[0m`; +} + +function formatMeta(meta) { + if (!meta || typeof meta !== 'object') return ''; + const entries = Object.entries(meta) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${key}=${String(value)}`); + if (entries.length === 0) return ''; + return ` (${entries.join(', ')})`; +} + +function formatPrefix(level) { + const base = `[${level}]`; + if (level === 'ERROR') return colorize(base, 31); + if (level === 'WARN') return colorize(base, 33); + if (level === 'OK') return colorize(base, 32); + return base; +} + +function writeLine(level, scope, message, meta) { + const prefix = formatPrefix(level); + const scopePart = scope ? ` ${scope}:` : ''; + const line = `${prefix}${scopePart} ${message}${formatMeta(meta)}`; + + if (level === 'ERROR') { + console.error(line); + } else if (level === 'WARN') { + console.warn(line); + } else { + console.log(line); + } +} + +function createLogger(scope) { + const normalized = scope ? String(scope) : ''; + return { + info: (message, meta) => writeLine('INFO', normalized, message, meta), + warn: (message, meta) => writeLine('WARN', normalized, message, meta), + error: (message, meta) => writeLine('ERROR', normalized, message, meta), + ok: (message, meta) => writeLine('OK', normalized, message, meta), + }; +} + +function startTimer() { + const startedAt = process.hrtime.bigint(); + return () => Number((process.hrtime.bigint() - startedAt) / 1_000_000n); +} + +module.exports = { + createLogger, + formatMeta, + formatPrefix, + isColorEnabled, + isVerbose, + parseBooleanEnv, + startTimer, +};