import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const require = createRequire(path.join(repoRoot, 'assets/admin/package.json')); const { transform } = require('esbuild'); const cssBuildDir = path.join(repoRoot, 'assets/css/build'); const jsBuildDir = path.join(repoRoot, 'assets/js/build'); const cssBundles = { // bootstrap / fontawesome5 / main.min.css 由 PHP 单独入队,不在此重复打包。 'public.css': [ 'style.css', 'assets/css/core/tokens.css', 'assets/css/core/reset.css', 'assets/css/core/typography.css', 'assets/css/core/buttons.css', 'assets/css/core/forms.css', 'assets/css/core/alerts.css', 'assets/css/core/animations.css', 'assets/css/core/components-ext.css', 'assets/css/core/color-utilities.css', 'assets/css/frontend/base.css', 'assets/css/frontend/header-userbox.css', 'assets/css/frontend/card-primitives.css', 'assets/css/frontend/overlay-primitives.css', 'assets/css/frontend/layout.css', 'assets/css/frontend/footer.css', 'assets/css/frontend/floating-buttons.css', 'assets/css/frontend/enhancements.css', 'assets/css/frontend/empty-state.css', 'assets/css/frontend/pagination.css', 'assets/css/frontend/modal-runtime.css', 'assets/css/frontend/notyf.css', 'assets/css/shared/captcha.css', 'assets/css/frontend/single-post/rewards.css', ], 'homepage.css': [ 'assets/css/frontend/post-card.css', 'assets/css/frontend/sidebar.css', 'assets/css/frontend/breadcrumb.css', 'assets/css/frontend/homepage-fixed/base.css', ], 'single.css': [ 'assets/css/frontend/legacy-utilities.css', 'assets/css/frontend/single-post/shell.css', 'assets/css/frontend/single-post/content.css', 'assets/css/frontend/single-post/author-actions.css', ], 'comments.css': [ 'assets/css/frontend/comments/layout.css', 'assets/css/frontend/comments/shell.css', 'assets/css/frontend/comments/mobile-item.css', ], }; const runtimePreludeScripts = [ 'assets/js/notyf.js', 'assets/js/core/modal-telemetry.js', 'assets/js/core/request.js', 'assets/js/core/modal-cache.js', 'assets/js/core/modal-state.js', 'assets/js/core/modal-runtime.js', 'assets/js/core/modal-triggers.js', 'assets/js/core/article.js', 'assets/js/core/comments.js', 'assets/js/core/comment-fixed-input.js', 'assets/js/core/comment-move-form.js', 'assets/js/core/ajax-list.js', 'assets/js/core/pagination.js', 'assets/js/core/archive.js', 'assets/js/core/footer-tabbar.js', 'assets/js/core/misc.js', 'assets/js/core/theme.js', 'assets/js/follow.js', 'assets/js/enhancements.js', 'assets/js/enhancements2.js', 'assets/js/color-extract.js', 'assets/js/effects.js', 'assets/js/captcha-front.js', 'assets/js/instant-page.js', ]; const imgboxScripts = [ 'assets/js/runtime/imgbox.js', ]; function normalizePath(filePath) { return filePath.split(path.sep).join('/'); } function rewriteCssUrls(css, sourcePath, outputPath) { const outputDir = path.dirname(outputPath); return css.replace(/url\(\s*(['"]?)([^)'"\s]+)\1\s*\)/gi, (match, quote, rawUrl) => { if (/^(?:data:|https?:|\/\/|\/|#|var\()/i.test(rawUrl)) { return match; } const suffixIndex = rawUrl.search(/[?#]/); const pathname = suffixIndex === -1 ? rawUrl : rawUrl.slice(0, suffixIndex); const suffix = suffixIndex === -1 ? '' : rawUrl.slice(suffixIndex); const absoluteAsset = path.resolve(path.dirname(sourcePath), pathname); let relativeAsset = normalizePath(path.relative(outputDir, absoluteAsset)); if (!relativeAsset.startsWith('.')) { relativeAsset = `./${relativeAsset}`; } const wrapper = quote || '"'; return `url(${wrapper}${relativeAsset}${suffix}${wrapper})`; }); } async function expandCss(sourcePath, outputPath, stack = []) { const absoluteSource = path.resolve(sourcePath); if (stack.includes(absoluteSource)) { throw new Error(`Circular CSS import: ${normalizePath(path.relative(repoRoot, absoluteSource))}`); } const source = await readFile(absoluteSource, 'utf8'); const importPattern = /@import\s+(?:url\(\s*)?['"]([^'"]+\.css)['"]\s*\)?\s*;/gi; let expanded = ''; let cursor = 0; let match; while ((match = importPattern.exec(source)) !== null) { expanded += rewriteCssUrls(source.slice(cursor, match.index), absoluteSource, outputPath); const importedPath = path.resolve(path.dirname(absoluteSource), match[1]); expanded += await expandCss(importedPath, outputPath, [...stack, absoluteSource]); cursor = importPattern.lastIndex; } expanded += rewriteCssUrls(source.slice(cursor), absoluteSource, outputPath); return expanded.trimEnd(); } async function buildCssBundle(outputName, sources) { const outputPath = path.join(cssBuildDir, outputName); const sections = []; for (const source of sources) { const sourcePath = path.join(repoRoot, source); sections.push(`/* source: ${source} */\n${await expandCss(sourcePath, outputPath)}`); } const bundle = `${sections.join('\n\n')}\n`; const minified = await transform(bundle, { charset: 'utf8', legalComments: 'inline', loader: 'css', minify: true, sourcefile: outputName, }); const minifiedPath = outputPath.replace(/\.css$/, '.min.css'); await Promise.all([ writeFile(outputPath, bundle, 'utf8'), writeFile(minifiedPath, minified.code, 'utf8'), ]); } async function buildScriptBundle(sources, outputName) { const sections = []; for (const source of sources) { const content = (await readFile(path.join(repoRoot, source), 'utf8')).trimEnd(); sections.push(`/* source: ${source} */\n${content}\n;`); } const bundle = `${sections.join('\n\n')}\n`; const minified = await transform(bundle, { charset: 'utf8', legalComments: 'inline', loader: 'js', // 保留原始标识符:压缩产生的 `$` 局部变量会破坏「禁用美元别名」契约。 minifyIdentifiers: false, minifySyntax: true, minifyWhitespace: true, sourcefile: outputName, }); await writeFile(path.join(repoRoot, outputName), minified.code, 'utf8'); } // 评论区硬切换后 main.min.css 由 main.css 重新生成,保持两者同步。 async function buildMainMin() { const sourcePath = path.join(repoRoot, 'assets/css/main.css'); const outputPath = path.join(repoRoot, 'assets/css/main.min.css'); const source = await readFile(sourcePath, 'utf8'); const minified = await transform(source, { charset: 'utf8', legalComments: 'inline', loader: 'css', minify: true, sourcefile: 'main.css', }); await writeFile(outputPath, minified.code, 'utf8'); } await mkdir(cssBuildDir, { recursive: true }); await mkdir(jsBuildDir, { recursive: true }); // 先刷新 main.min.css,public.css 打包时才能读到最新内容。 await buildMainMin(); for (const [outputName, sources] of Object.entries(cssBundles)) { await buildCssBundle(outputName, sources); } await buildScriptBundle([...runtimePreludeScripts, 'assets/js/runtime/main.js'], 'assets/js/runtime/main.min.js'); await buildScriptBundle(imgboxScripts, 'assets/js/runtime/imgbox.min.js'); console.log('Built public CSS and runtime JS bundles.');