import path from 'node:path'; import fs from 'node:fs'; import url from 'node:url'; import dotenv from 'dotenv'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { registry } from '../data/index.ts'; import { validateEntry } from './validate.ts'; import { rewriteAssetFields } from './rewrite.ts'; import { buildManifest, sha256 } from './manifest.ts'; const __filename = url.fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // .env 位于 cms/ 根(lib/ 的父目录) dotenv.config({ path: path.join(__dirname, '..', '.env'), quiet: true }); // lib/ 在 cms/ 下,CMS_ROOT 取父目录 const CMS_ROOT = path.resolve(__dirname, '..'); const REPO_ROOT = path.resolve(CMS_ROOT, '..'); const OUT_ROOT = path.join(CMS_ROOT, 'dist'); const ASSETS_DIR = path.join(CMS_ROOT, 'assets'); const CDN_BASE = (process.env.CDN_BASE ?? '').trim(); // 默认 pre,仅 CMS_ENV=prod 时构建 prod const CMS_ENV = process.env.CMS_ENV === 'prod' ? 'prod' : 'pre'; export async function runBuild() { if (!CDN_BASE) { throw new Error('环境变量 CDN_BASE 未设置,请先在 cms/.env 中配置'); } const entries = Object.entries(registry); if (!entries.length) throw new Error('registry 中没有任何内容条目'); console.log(`[build] env=${CMS_ENV} cdn=${CDN_BASE} → ${path.relative(REPO_ROOT, OUT_ROOT)}`); fs.rmSync(OUT_ROOT, { recursive: true, force: true }); let totalErrors = 0; let totalWarnings = 0; // env 仅参与资源 URL 生成(cms//...),产物目录不再按 env 分层 const env = CMS_ENV; const outAssets = path.join(OUT_ROOT, 'assets'); if (fs.existsSync(ASSETS_DIR)) { copyDir(ASSETS_DIR, outAssets); console.log(`[build] copied assets → ${path.relative(REPO_ROOT, outAssets)}`); } else { console.warn('[build] 未发现 cms/assets/,跳过资源拷贝'); } const manifestFiles = {}; // 收集 assets/ 之外、被相对引用的资源(需额外拷贝到 dist) const extraAssets = new Map(); for (const [entry, mod] of entries) { const { schema, data, dir: eDir } = mod; if (Object.keys(data).length === 0) { totalErrors += 1; console.error(` ❌ [${entry}] 未提供任何 locale 数据`); continue; } // zod strict 校验 + 资源路径安全(穿越/缺失)检查 const { errors, warnings } = validateEntry({ entry, schema, data, entryDir: eDir, cmsRoot: CMS_ROOT, }); if (warnings.length) { totalWarnings += warnings.length; for (const w of warnings) console.warn(` ⚠️ [${entry}] ${w}`); } if (errors.length) { totalErrors += errors.length; for (const e of errors) console.error(` ❌ [${entry}] ${e}`); continue; } // 不做构建期排序,产物顺序与源数据一致 // 版本号 = 各 locale 数据联合 hash(不含生成的 schema,避免 zod→json 生成细节波动) const sortedLocales = Object.keys(data).sort(); const entryHash = sha256( JSON.stringify(sortedLocales.map((k) => data[k])), ).slice(0, 8); const ctx = { env, cdnBase: CDN_BASE, version: entryHash, entryDir: eDir, cmsRoot: CMS_ROOT, onAsset: (abs, rel) => { // assets/ 已整目录拷贝,跳过;其余按 relFromCms 去重记录 if (rel.startsWith('assets/')) return; if (!extraAssets.has(rel)) extraAssets.set(rel, abs); }, }; // 兼容 array / object 两种数据形态逐行改写资源字段 const out = {}; let rowCountLabel = 'single'; for (const [locale, value] of Object.entries(data)) { if (Array.isArray(value)) { out[locale] = value.map((row) => rewriteAssetFields(row, ctx)); rowCountLabel = `rows=${out[locale].length}`; } else { out[locale] = rewriteAssetFields(value, ctx); } } // 由 zod 反向生成 JSON Schema,保持前端 CDN 契约不变 const jsonSchema = generateJsonSchema(schema); // 产物多挖一层 data/,与 assets/ 物理隔离 const outDir = path.join(OUT_ROOT, 'data', entry); fs.mkdirSync(outDir, { recursive: true }); writeJSON(path.join(outDir, 'schema.json'), jsonSchema); for (const [locale, value] of Object.entries(out)) { writeJSON(path.join(outDir, `${locale}.json`), value); } manifestFiles[`data/${entry}/schema.json`] = sha256(JSON.stringify(jsonSchema)); for (const [locale, value] of Object.entries(out)) { manifestFiles[`data/${entry}/${locale}.json`] = sha256(JSON.stringify(value)); } manifestFiles[`data/${entry}/__version`] = entryHash; console.log(` ✅ [${entry}] v=${entryHash} ${rowCountLabel}`); } // 有错误时清理半成品产物,避免 dist 留下不完整数据 if (totalErrors > 0) { fs.rmSync(OUT_ROOT, { recursive: true, force: true }); throw new Error(`构建失败:存在 ${totalErrors} 个错误,已清理产物目录`); } // 拷贝 assets/ 之外被引用的资源 for (const [rel, abs] of extraAssets) { const dst = path.join(OUT_ROOT, rel); fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.copyFileSync(abs, dst); } if (extraAssets.size) console.log(`[build] copied ${extraAssets.size} extra asset(s)`); const manifestPath = path.join(OUT_ROOT, 'manifest.json'); writeJSON(manifestPath, buildManifest({ env, files: manifestFiles })); console.log(`\n[build] done. errors=${totalErrors}, warnings=${totalWarnings}`); } export function generateJsonSchema(schema) { const base = { $schema: 'https://json-schema.org/draft-07/schema' }; const jsonSchema = zodToJsonSchema(schema, { target: 'jsonSchema7' }); return { ...base, ...jsonSchema }; } // ===== 工具函数 ===== function writeJSON(p, data) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf8'); } function copyDir(src, dst) { fs.mkdirSync(dst, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const s = path.join(src, entry.name); const d = path.join(dst, entry.name); if (entry.isDirectory()) { copyDir(s, d); } else if (entry.isSymbolicLink()) { const target = fs.readlinkSync(s); fs.symlinkSync(target, d); } else if (entry.isFile()) { fs.copyFileSync(s, d); } else { console.warn(`[build] 跳过非常规文件类型: ${s}`); } } } const __mainFile = url.fileURLToPath(import.meta.url); if (process.argv[1] && path.resolve(process.argv[1]) === __mainFile) { runBuild().catch((err) => { console.error(err); process.exit(1); }); }