import path from 'node:path'; import fs from 'node:fs'; // 相对资源引用改写规则: // - 绝对地址(带 scheme 或 //)保持原值 // - @/ 开头或含 /assets 的相对路径 → 解析后落在 cms/ 内且存在才改写为 CDN URL // - 其余字符串不动 // @/ 相对 cms/ 根,其余相对 entryDir;越界或缺失在校验期报错,改写期保持原值。 // 匹配带 scheme(http:、cloud:、data: 等)或协议相对 // const ABSOLUTE_REF_RE = /^(?:[a-zA-Z][a-zA-Z0-9+.\-]*:|\/\/)/; function pathHead(v) { return v.split(/[?#]/)[0]; } // 候选条件:@/ 开头 或 含 /assets function isResourceRef(v) { const head = pathHead(v); if (ABSOLUTE_REF_RE.test(head)) return false; return head.startsWith('@/') || head.includes('/assets'); } // @/ 相对 cms/ 根,其余相对 entryDir function resolveRefPath(v, entryDir, cmsRoot) { const head = pathHead(v); if (head.startsWith('@/')) { return path.resolve(cmsRoot, head.slice(2)); } return path.resolve(entryDir, head); } function isInsideCmsRoot(abs, cmsRoot) { const rel = path.relative(cmsRoot, abs); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); } /** * @template T * @param {T} data * @param {{ env: string, cdnBase: string, version: string, entryDir: string, cmsRoot: string, onAsset?: (absInRepo: string, relFromCms: string) => void }} ctx * @returns {T} */ export function rewriteAssetFields(data, ctx) { return walk(data, ctx); } function walk(node, ctx) { if (typeof node === 'string') { return tryRewriteString(node, ctx); } if (Array.isArray(node)) { return node.map((v) => walk(v, ctx)); } if (node && typeof node === 'object') { const out = {}; for (const [k, v] of Object.entries(node)) { out[k] = walk(v, ctx); } return out; } return node; } function tryRewriteString(v, ctx) { if (v.length === 0) return v; if (ABSOLUTE_REF_RE.test(pathHead(v))) return v; if (!isResourceRef(v)) return v; const abs = resolveRefPath(v, ctx.entryDir, ctx.cmsRoot); // 校验期已报错,改写期保持原值 if (!isInsideCmsRoot(abs, ctx.cmsRoot)) return v; if (!fs.existsSync(abs)) return v; try { return toCdnUrl(v, ctx); } catch (err) { console.warn(`[rewrite] ${err.message},资源保持原值: "${v}"`); return v; } } // 相对引用 → {cdnBase}/cms/{env}/{relFromCms}?v={version} // 改写成功回调 onAsset 通知构建期拷贝(assets/ 已整目录拷贝,build 侧自行跳过) export function toCdnUrl(relPath, ctx) { const absInRepo = resolveRefPath(relPath, ctx.entryDir, ctx.cmsRoot); const relFromCms = path.relative(ctx.cmsRoot, absInRepo).split(path.sep).join('/'); if (relFromCms.startsWith('..')) { throw new Error( `[rewrite] 资源路径越出 cms/ 目录: "${relPath}" (resolved: ${absInRepo})`, ); } const url = `${stripTrailingSlash(ctx.cdnBase)}/cms/${ctx.env}/${relFromCms}?v=${ctx.version}`; if (typeof ctx.onAsset === 'function') { ctx.onAsset(absInRepo, relFromCms); } return url; } // 递归校验相对资源引用真实存在;越界或缺失记为 error export function checkAssetFieldsExist(data, entryDir, cmsRoot, errors, scope) { walkCheck(data, entryDir, cmsRoot, errors, scope, ''); } function walkCheck(node, entryDir, cmsRoot, errors, scope, jsonPath) { if (typeof node === 'string') { checkAssetString(node, entryDir, cmsRoot, errors, scope, jsonPath); return; } if (Array.isArray(node)) { node.forEach((v, i) => walkCheck(v, entryDir, cmsRoot, errors, scope, `${jsonPath}[${i}]`)); return; } if (node && typeof node === 'object') { for (const [k, v] of Object.entries(node)) { walkCheck(v, entryDir, cmsRoot, errors, scope, jsonPath ? `${jsonPath}.${k}` : k); } } } function checkAssetString(v, entryDir, cmsRoot, errors, scope, jsonPath) { if (v.length === 0) return; if (ABSOLUTE_REF_RE.test(pathHead(v))) return; if (!isResourceRef(v)) return; const abs = resolveRefPath(v, entryDir, cmsRoot); if (!isInsideCmsRoot(abs, cmsRoot)) { errors.push(`[${scope}#${jsonPath}] 资源路径越出 cms/ 目录: "${v}"`); return; } if (!fs.existsSync(abs)) { errors.push(`[${scope}#${jsonPath}] 资源文件不存在: "${v}" (期望: ${abs})`); } } function stripTrailingSlash(s) { return s.replace(/\/+$/, ''); }