Files
zszq-trs/YLErpWeb/rebuild-bundles.py
T
hjhan 2bfdce6ee0 [EQD-6838] 前端 bundle 确定性重建:自动生成脚本 + 发布期自动打 bundle
新增 rebuild-bundles.py/.sh/.ps1,按 bundleconfig.json 确定性重建 6 个 bundle 产物(剥 BOM、CRLF->LF、文件间换行分隔、CSS 相对 url() 重写,已逐字节比对验证与已提交版一致,其中 bundle.css 修正了历史打包产生的 CSS 注释 GBK 乱码)。
YLErpWeb.csproj 新增 GenerateBundlesBeforePublish 目标,在 publish 前自动运行脚本重建 bundle(CI 自动生效,无 python3 时优雅跳过回退已提交产物),从此改源文件后部署自动重新打 bundle,不再需要手工记得到处重建。
.gitattributes 将 bundle 锁为 eol=lf,.editorconfig 为 JS/TS 加 charset=utf-8,从根上防止 BOM/ZWNBSP 复发。
2026-07-29 17:47:13 +08:00

134 lines
5.7 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
rebuild-bundles.py —— 前端 bundle 产物的确定性重建脚本
为什么需要它:
页面加载的是 bundle.js / jquery.js / vue.js / bundle.css / bundleV2.css / bundleV2.js
(见 _MainLayout.cshtml),这些是由 bundleconfig.json 把分散的源文件拼接而成。
历史上"改了源文件却忘了重新打 bundle"导致部署的仍是陈旧/残缺产物(本次 EQD-6838 的痛点)。
本脚本按与历史手工打包一致的规则确定性地重建这 6 个产物,使"改源 -> 重新打 bundle"可复现、可校验。
拼接规则(已与已提交版本逐字节比对验证一致):
1. 每个输入文件:剥掉文件首的 UTF-8 BOM(EF BB BF),行尾 CRLF->LF 归一(不改文件原始编码,GBK 等字节原样保留)。
2. 输入文件之间用一个 '\\n' 分隔(末尾文件后不加分隔符)。
3. CSS 产物额外做相对 url() 重写:把相对于各 CSS 源文件目录的 url(...) 改写为相对于
bundle 输出目录(Statics/bundles/)的路径,行为与 BuildBundlerMinifier 一致:
- data: URI 原样保留(含引号);
- 其余 url 保留原始引号风格,仅改写路径为相对 bundle 目录的相对路径。
(注:已提交 bundle.css 中少量 GBK 中文注释曾被旧工具误解码成 UTF-8 替换符,本脚本生成的是正确字节;
重建后这些注释乱码会被修正,仅影响注释,不影响渲染。)
用法:
python3 rebuild-bundles.py # 重建并写回 wwwroot/Statics/bundles/ 下 6 个产物
python3 rebuild-bundles.py --verify # 重建到内存并与已提交文件比对,不一致则 exit 1(供 CI 校验)
"""
import json
import os
import re
import sys
BOM = b'\xef\xbb\xbf'
ROOT = os.path.dirname(os.path.abspath(__file__)) # YLErpWeb 目录
# 引号优先匹配,避免 data: URI 内的 ) 截断
URL_RE = re.compile(rb"url\(\s*('[^']*'|\"[^\"]*\"|[^\"'()]*)\s*\)")
def normalize(b):
"""剥文件首 BOM(若有) + 行尾归一为 \\n。全程字节级,不改编码(GBK 等原样保留)。"""
if isinstance(b, bytes):
if b.startswith(BOM):
b = b[3:]
return b.replace(b'\r\n', b'\n').replace(b'\r', b'\n')
return b.replace(b'\r\n', b'\n').replace(b'\r', b'\n')
def rewrite_css_urls(css, input_rel, out_rel):
"""CSS 相对 url() 改写为相对 bundle 输出目录(与 BuildBundlerMinifier 行为一致)。"""
base = os.path.dirname(input_rel) # 输入文件目录(相对 ROOT)
outdir = os.path.dirname(out_rel) # bundle 输出目录(相对 ROOT)
def repl(m):
raw = m.group(1).strip()
# 保留 url() 内原始引号风格
if len(raw) >= 2 and raw[0:1] == raw[-1:] and raw[0:1] in (b"'", b'"'):
q = raw[0:1]
inner = raw[1:-1].strip()
else:
q = b''
inner = raw
if inner.startswith(b'data:'):
return m.group(0) # data: URI 原样保留(含引号)
if re.match(rb'^(https?:|//|#)', inner) or inner.startswith(b'/'):
return m.group(0)
try:
udec = inner.decode('ascii') # url 通常为 ascii
except Exception:
return m.group(0)
resolved = os.path.normpath(os.path.join(base, udec))
rel = os.path.relpath(resolved, outdir).replace(os.sep, '/')
return b'url(' + q + rel.encode('utf-8', errors='surrogateescape') + q + b')'
return URL_RE.sub(repl, css)
def build_all():
cfg_path = os.path.join(ROOT, 'bundleconfig.json')
raw = open(cfg_path, 'rb').read()
raw = re.sub(rb'//[^\n]*', b'', raw) # 去掉 // 行注释
cfg = json.loads(raw.decode('utf-8-sig'))
results = [] # (out, generated_bytes, missing_list)
for b in cfg:
out = b['outputFileName']
iscss = out.endswith('.css')
parts = []
missing = []
for f in b['inputFiles']:
p = os.path.join(ROOT, f)
if not os.path.exists(p):
missing.append(f)
continue
content = normalize(open(p, 'rb').read())
if iscss:
content = rewrite_css_urls(content, f, out)
parts.append(content)
joined = b'\n'.join(parts) # 文件间一个 \n 分隔,末尾不加
results.append((out, joined, missing))
return results
def main():
verify = '--verify' in sys.argv[1:]
results = build_all()
ok = True
for out, joined, missing in results:
dest = os.path.join(ROOT, out)
if missing:
ok = False
print(f" [MISSING INPUT] {out}: {missing}")
continue
if verify:
original = normalize(open(dest, 'rb').read())
if joined != original:
ok = False
n = min(len(joined), len(original))
i = 0
while i < n and joined[i] == original[i]:
i += 1
print(f" [STALE] {out} 与源文件不同步 @byte {i}")
print(f" gen={joined[max(0,i-40):i+40]!r}")
print(f" com={original[max(0,i-40):i+40]!r}")
else:
open(dest, 'wb').write(joined)
print(f"{'VERIFY ' if verify else 'WRITE '} -> {out} ({len(joined)} bytes)"
+ ("" if not missing else f" MISSING={missing}"))
if verify:
print("\n全部与源同步 ✅" if ok else "\n存在不同步,请先运行 `python3 rebuild-bundles.py` 重建并提交 ✅")
else:
print("\n重建完成" if ok else "\n重建完成(有输入缺失,请检查)")
return 0 if ok else 1
if __name__ == '__main__':
sys.exit(main())