97 lines
3.6 KiB
JavaScript
97 lines
3.6 KiB
JavaScript
/* 构建产物 CSS 自检:拦住「滚动驱动动画被压缩器并进 animation 简写」这一类静默回归。
|
||
*
|
||
* 背景(2026-09-21 的实际事故):
|
||
* 线上「回到顶部」按钮不显示,本地 dev 却正常。
|
||
* 原因是 Lightning CSS(Tailwind v4 的 CSS 压缩器)把
|
||
* animation: back-to-top-reveal linear both;
|
||
* animation-timeline: scroll(root block);
|
||
* 合并成了
|
||
* animation: linear both back-to-top-reveal scroll(root);
|
||
* 而 WebKit 不接受 animation 简写里的 <single-animation-timeline> 分量
|
||
* (webkit#293276),于是**整条 animation 声明被判无效丢弃**,元素永远停在
|
||
* 初始的 opacity:0 / visibility:hidden。
|
||
*
|
||
* 最要命的是它只在构建产物里出现:astro dev 不做这个压缩,所以本地怎么试都正常;
|
||
* 而 CSS.supports("animation-timeline: scroll()") 又照样返回 true,
|
||
* 运行时特性检测也发现不了。见 lightningcss#815。
|
||
*
|
||
* 这个脚本在 build 之后跑,扫 dist 下所有 CSS:只要 animation 简写的值里出现
|
||
* scroll() / view() 时间线函数,就让构建失败。
|
||
*
|
||
* 用法:
|
||
* node scripts/verify-build-css.mjs # 失败时退出码 1
|
||
* node scripts/verify-build-css.mjs --warn # 只警告,不失败
|
||
*/
|
||
|
||
import fs from "node:fs"
|
||
import path from "node:path"
|
||
|
||
const DIST = "./dist"
|
||
const WARN_ONLY = process.argv.includes("--warn")
|
||
|
||
/** animation 简写里不该出现的时间线函数 */
|
||
const TIMELINE_FN = /(?:^|[^-\w])(?:scroll|view)\(/
|
||
|
||
/** 收集 dist 下所有 css 文件 */
|
||
function collectCssFiles(dir) {
|
||
if (!fs.existsSync(dir)) return []
|
||
const found = []
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const full = path.join(dir, entry.name)
|
||
if (entry.isDirectory()) found.push(...collectCssFiles(full))
|
||
else if (entry.name.endsWith(".css")) found.push(full)
|
||
}
|
||
return found
|
||
}
|
||
|
||
/**
|
||
* 只检查 `animation:` 简写(以及 -webkit-animation:),不碰
|
||
* `animation-timeline:` / `animation-range:` 这两个长写属性 —— 后者是正确写法。
|
||
*/
|
||
function findShorthandViolations(css) {
|
||
const hits = []
|
||
// 负向回顾避免匹配到 animation-timeline / animation-name 这类长写属性
|
||
const re = /(?:^|[;{}(])\s*(?:-webkit-)?animation\s*:\s*([^;}]*)/g
|
||
let match
|
||
while ((match = re.exec(css)) !== null) {
|
||
const value = match[1]
|
||
if (TIMELINE_FN.test(value)) {
|
||
hits.push(value.trim().slice(0, 120))
|
||
}
|
||
}
|
||
return hits
|
||
}
|
||
|
||
const files = collectCssFiles(DIST)
|
||
if (files.length === 0) {
|
||
console.error(`❌ ${DIST} 下没有找到 CSS,构建产物异常`)
|
||
process.exit(1)
|
||
}
|
||
|
||
const problems = []
|
||
for (const file of files) {
|
||
const css = fs.readFileSync(file, "utf-8")
|
||
for (const value of findShorthandViolations(css)) {
|
||
problems.push({ file, value })
|
||
}
|
||
}
|
||
|
||
if (problems.length === 0) {
|
||
console.log(`✅ 构建产物 CSS 自检通过(${files.length} 个文件,没有时间线函数混进 animation 简写)`)
|
||
process.exit(0)
|
||
}
|
||
|
||
console.error("❌ 构建产物 CSS 自检失败:animation 简写里混进了时间线函数。")
|
||
console.error("")
|
||
for (const { file, value } of problems) {
|
||
console.error(` ${file}`)
|
||
console.error(` animation: ${value}`)
|
||
}
|
||
console.error("")
|
||
console.error(" 这类声明会被 WebKit 整条丢弃(webkit#293276),元素会停在初始样式上。")
|
||
console.error(" 修法:把 animation-timeline / animation-range 挪到**独立的规则块**里,")
|
||
console.error(" 不要和 animation 简写放在同一组声明中(lightningcss#815)。")
|
||
console.error("")
|
||
|
||
process.exit(WARN_ONLY ? 0 : 1)
|