新增 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 复发。
134 lines
5.7 KiB
Python
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())
|