update 2026-09-25 19:24:22
marry-jell / merge (push) Canceled after 0s

This commit is contained in:
action committed 2026-09-25 19:24:22 +08:00
1 parent e8f9e08c4a
commit ba0a17863f
145 files changed
+23807 -5960

No files matched your search

+125 -148
View File
@@ -37,8 +37,8 @@ trap 'rm -f "$TMP" "$TMP.min" "$TMP.layer"' EXIT
mkdir -p "$(dirname "$OUT")"
# One `@layer X{` per LAYER, not one per FILE. Every source file carries its own wrapper so it can
# be read and edited alone; concatenated, that is 38 copies of the same six-to-eleven bytes, 554 of
# them, plus two files that are all comment and emit nothing but `@layer page{}`.
# be read and edited alone; concatenated, that is 37 copies of the same six-to-eleven bytes, 542 of
# them.
#
# The wrapper now comes from the DIRECTORY, so a file filed under the wrong layer would be silently
# re-layered instead of just being wrong — hence the check that each file opens with the layer its
@@ -53,13 +53,13 @@ emit_layer() {
if head -1 "$f" | grep -q '^@layer '; then
head -1 "$f" | grep -q "^@layer $layer {\$" || {
echo "build-css: $f is in a $layer directory but does not open with '@layer $layer {'" >&2
rm -f "$body"; exit 1; }
exit 1; }
# the file's own wrapper: its first line, and its last line, which is that wrapper's `}`
sed '1d;$d' "$f" >> "$body"
elif [ -s "$body" ]; then
echo "build-css: $f has no @layer wrapper but follows one that does — it would be" >&2
echo "build-css: swallowed into the $layer block instead of staying above it." >&2
rm -f "$body"; exit 1
exit 1
else
cat "$f"
fi
@@ -69,6 +69,7 @@ emit_layer() {
cat "$body"
printf '}\n'
fi
# not covered by the EXIT trap: the success path runs `trap - EXIT` first
rm -f "$body"
}
@@ -80,20 +81,35 @@ emit_layer() {
emit_layer page "$D"/styles/pages/*.css
} > "$TMP"
# Strip /* … */, keep /*! … */ (the licence banner), drop indentation and blank lines.
#
# String-aware: a scanner that just hunts for the next "/*" lets `content: "/*"` open a comment and
# eat every rule up to the next "*/", with only the brace count below as a guard — and two such
# literals balance each other, so rules can vanish in silence. Quoted data-URIs run through here on
# every build.
strip_comments() {
awk '
BEGIN { inc = 0; q = "" }
# Comments, and — unless told not to — the whitespace CSS ignores anyway, in one string-aware
# pass rather than two chained ones. squeeze=0 (the pre-compression rule count below, and every
# `--dev` build) strips comments and trims each line; squeeze=1 also does the ~9.5 KB whitespace
# pass. Why a scan has to be string-aware, what the whitespace pass leaves alone and why, and the
# sed bug this replaced: docs/css.md, "The build".
squeeze() {
awk -v SQUEEZE="$2" '
BEGIN { q = ""; inc = 0; banner = 0; lastc = ""; buf = ""; lastreal = "" }
{
line = $0; out = ""; i = 1; n = length(line)
line = $0
# a banner already open on an earlier line: nothing on a pure continuation line can
# change q or inc, so the whole line is banner content until its own closing "*/".
if (banner) {
trimmed = line
sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed)
print trimmed
lastc = ""; lastreal = ""
if (index(line, "*/")) banner = 0
next
}
# The line BREAK a squeeze pass is about to swallow is whitespace, and a declaration
# may be wrapped across it. Feed it to the whitespace-run logic below as a leading
# space: it survives only where a space would and is dropped next to { } ; , : —
# lastc == "" means output is already at the start of a line, with nothing to glue to.
if (SQUEEZE && lastc != "" && q == "" && !inc) line = " " line
out = ""; i = 1; n = length(line); opened = 0
while (i <= n) {
c = substr(line, i, 1)
if (inc) { # inside /* ... */
if (inc) { # inside a stripped /* ... */
if (c == "*" && substr(line, i + 1, 1) == "/") { inc = 0; i += 2; continue }
i++; continue
}
@@ -101,120 +117,90 @@ strip_comments() {
out = out c
if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue }
if (c == q) q = ""
i++; continue
}
if (c == "\"" || c == "'"'"'") { q = c; out = out c; i++; continue }
if (c == "/" && substr(line, i + 1, 1) == "*") {
# the banner: keep it, and everything after it on this line
if (substr(line, i + 2, 1) == "!") { out = out substr(line, i); break }
inc = 1; i += 2; continue
}
out = out c; i++
}
sub(/^[ \t]+/, "", out)
sub(/[ \t]+$/, "", out)
if (length(out)) print out
}
' "$1"
}
# Squeeze the whitespace CSS ignores — wire AND flash bytes, since uhttpd does not compress.
#
# Removed (~9.5 KB): the space after `:`, the spaces around `{ } ; ,`, the last `;` of a block, the
# newline after every declaration. lightningcss would save ~13 KB, but its extra 3.5 KB comes from
# rewriting colours and merging rules — transforms that can change behaviour. These cannot.
#
# Left alone, each for a reason:
# - the single space between selectors: `.a .b` is a DESCENDANT combinator, `.a.b` is not;
# - spaces inside calc(): required around `*`, `/` and the `-` of `calc(100% - 8px)`;
# - the LINE BREAK inside a declaration, which is whitespace too — joining lines with nothing
# between them turns a wrapped calc() into `…))- .004 …`, and a `-` with no space before it is
# a parse error, so the declaration drops and the token it defined goes undefined, silently —
# --fs-bg then became invalid at computed-value time and the canvas fell back to white
# (export-tier.mjs caught it: contrast 1.5:1);
# - `>` `+` `~` spaces: stripping them is safe but buys only ~200 bytes;
# - anything inside a string: every data-URI here is quoted and full of `:`, `;` and spaces;
# - one newline after `}`, so the shipped file stays greppable.
squeeze() {
awk '
BEGIN { q = ""; ban = 0; lastc = ""; buf = ""; lastreal = "" }
{
line = $0
# The /*! banner is an Apache-2.0 attribution, not formatting: it must survive
# BYTE FOR BYTE. Squeezing it made "Twitter, Inc" into "Twitter,Inc" and glued its
# lines together. Copy it out untouched.
if (ban) { print line; lastc = ""; lastreal = ""; if (index(line, "*/")) ban = 0; next }
if (substr(line, 1, 3) == "/*!") {
print line; lastc = ""; lastreal = ""
if (!index(substr(line, 4), "*/")) ban = 1
next
}
# The line BREAK we are about to swallow is whitespace, and a declaration may be
# wrapped across it. Feed it to the whitespace-run logic below as a leading space:
# it survives only where a space would (between two tokens) and is dropped next to
# { } ; , : — lastc == "" means output is already at the start of a line, with
# nothing to glue to.
if (lastc != "" && q == "") line = " " line
out = ""; i = 1; n = length(line)
while (i <= n) {
c = substr(line, i, 1)
if (q != "") { # inside a string: copy verbatim
out = out c
if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue }
if (c == q) q = ""
lastreal = "" # a char inside a string is not structure
lastreal = ""
i++; continue
}
if (c == "\"" || c == "'"'"'") { q = c; out = out c; lastreal = ""; i++; continue }
if (c == " " || c == "\t") { # collapse a run of whitespace to one space
while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++
# the last char EMITTED, which on a continuation line lives on the
# previous output line — hence lastc, not just `out`.
prev = (length(out) ? substr(out, length(out), 1) : lastc)
nxt = (i <= n ? substr(line, i, 1) : "")
# drop it entirely next to a delimiter; otherwise it may be a combinator
if (c == "/" && substr(line, i + 1, 1) == "*") {
if (substr(line, i + 2, 1) == "!") {
# a genuine banner open: reached only with q=="" and inc==0 AT THIS
# CHARACTER, the same guard strip_comments used before this pass absorbed
# it — so a "/*!" that is really bytes inside a string or an already-open
# ordinary comment never lands here. Copied raw from the marker on, byte
# for byte: it is an Apache-2.0 attribution, not formatting.
banner = 1
rest = substr(line, i)
out = out rest
if (index(rest, "*/")) banner = 0
opened = 1
break
}
inc = 1; i += 2; continue
}
if (SQUEEZE) {
if (c == " " || c == "\t") { # collapse a run of whitespace to one space
while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++
# the last char EMITTED, which on a continuation line lives on the
# previous output line — hence lastc, not just `out`.
prev = (length(out) ? substr(out, length(out), 1) : lastc)
nxt = (i <= n ? substr(line, i, 1) : "")
# drop it entirely next to a delimiter; otherwise it may be a combinator
#
# `>` is a delimiter too, and the only one that is itself a combinator: a
# space either side of it is decoration. Safe because a `>` outside a
# string can only be the child combinator — the sheet has no media range
# syntax (`@media (width > 600px)`) — and a `>` inside a string never
# reaches here, the scanner having copied it verbatim above. `~` and `+`
# are deliberately NOT joined: `[attr~=v]` and `calc(100% - 10px)` make
# them ambiguous without tracking bracket depth.
if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">")
continue
if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">")
continue
out = out " "; lastreal = " "
continue
}
# THE LAST `;` OF A BLOCK IS REDUNDANT — dropped as the `}` is emitted, i.e.
# INSIDE the string-aware scanner. It used to be a `| sed "s/;}/}/g"` bolted
# onto the awk output, and sed cannot see strings: `content: ";}"` came out as
# `content: "}"`, and a data-URI containing `;}` was corrupted the same way
# (both reproduced). Nothing in the tree holds that byte pair today — which is
# how such a bug waits for whoever adds the first one.
#
# `>` is a delimiter too, and the only one that is itself a combinator: a
# space either side of it is decoration. 516 of them in the sheet, so it is
# 1,032 B — the file header used to guess "~200 bytes" for this whole pass.
# Safe because a `>` outside a string can only be the child combinator: the
# sheet has no media range syntax (`@media (width > 600px)`), and the 107
# `>` inside string literals never reach here, the scanner having copied them
# verbatim above. `~` and `+` are deliberately NOT joined: `[attr~=v]` and
# `calc(100% - 10px)` make them ambiguous without tracking bracket depth,
# and they are worth 14 B and 34 B.
if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">")
continue
if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">")
continue
out = out " "; lastreal = " "
continue
# The `;` may already sit in the previous line output, so text is held in
# `buf` until the rule closes: a `;` already printed cannot be taken back.
if (c == "}") {
if (length(out) && substr(out, length(out), 1) == ";")
out = substr(out, 1, length(out) - 1)
else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";")
buf = substr(buf, 1, length(buf) - 1)
}
out = out c; lastreal = c; i++
} else {
out = out c; i++
}
# THE LAST `;` OF A BLOCK IS REDUNDANT — dropped as the `}` is emitted, i.e.
# INSIDE the string-aware scanner. It used to be a `| sed "s/;}/}/g"` bolted onto
# the awk output, and sed cannot see strings: `content: ";}"` came out as
# `content: "}"`, and a data-URI containing `;}` was corrupted the same way (both
# reproduced). Nothing in the tree holds that byte pair today — which is how such
# a bug waits for whoever adds the first one.
#
# The `;` may already sit in the previous line output, so text is held in `buf`
# until the rule closes: a `;` already printed cannot be taken back.
if (c == "}") {
if (length(out) && substr(out, length(out), 1) == ";")
out = substr(out, 1, length(out) - 1)
else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";")
buf = substr(buf, 1, length(buf) - 1)
}
out = out c; lastreal = c; i++
}
buf = buf out
if (length(out)) lastc = substr(out, length(out), 1)
# newline only after a closing brace — one rule per line. lastreal, not lastc: a
# line ending in a QUOTED `}` (content: "}") is not the end of a rule, and flushing
# there would split the rule and lose the space before its next token.
if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" }
if (opened) {
trimmed = out
sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed)
print trimmed
lastc = ""; lastreal = ""
next
}
if (SQUEEZE) {
buf = buf out
if (length(out)) lastc = substr(out, length(out), 1)
# newline only after a closing brace — one rule per line. lastreal, not lastc: a
# line ending in a QUOTED `}` (content: "}") is not the end of a rule, and
# flushing there would split the rule and lose the space before its next token.
if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" }
} else {
sub(/^[ \t]+/, "", out)
sub(/[ \t]+$/, "", out)
if (length(out)) print out
}
}
END { if (length(buf)) print buf; else printf "\n" }
END { if (SQUEEZE) { if (length(buf)) print buf; else printf "\n" } }
' "$1"
}
@@ -250,47 +236,38 @@ brace_count() {
}' "$1"
}
# Always brace-check a COMMENT-STRIPPED copy, --dev included: counting braces in the raw
# file made a stray "{" in prose fail the build on perfectly valid CSS.
strip_comments "$TMP" > "$TMP.min"
# Always brace-check a COMMENT-STRIPPED, UNSQUEEZED copy, --dev included: counting braces in the
# raw file made a stray "{" in prose fail the build on perfectly valid CSS.
squeeze "$TMP" 0 > "$TMP.min"
RULES_BEFORE=$(brace_count "$TMP.min") || exit 1
rm -f "$TMP.min"
if [ "$DEV" -eq 0 ]; then
# comments gone; now squeeze the whitespace CSS ignores
squeeze "$TMP.min" > "$TMP"
rm -f "$TMP.min"
squeeze "$TMP" 1 > "$TMP.min"
mv "$TMP.min" "$TMP"
# AND AGAIN, on what actually ships: the check above only saw the squeeze's INPUT, yet
# the squeeze is the pass most able to corrupt the sheet — it tracks strings, joins lines
# and deletes the `;` before a `}`. An unchanged rule count is what says it did not.
#
# That equality — not brace balance on its own — is also why the 80 KB FS_CSS_FLOOR size
# guard once here is gone. Balance alone misses a cut right after any of the four
# `@layer NAME { … }` wrappers closes: everything up to that point is still a matched set, so
# a truncated write — a full disk, a compression that ate the tail — that stops exactly there
# reads as a valid, complete-looking file. Fed the real sheet, a cut right after the base
# layer's own close counts a balanced 283 rules against the file's true 1057. What catches
# that is the COUNT: RULES_BEFORE is measured from the untruncated concatenation before this
# pass runs, so a write that stops early can only ever come up short against it.
RULES_AFTER=$(brace_count "$TMP") || exit 1
if [ "$RULES_BEFORE" != "$RULES_AFTER" ]; then
echo "build-css: the squeeze changed the rule count ($RULES_BEFORE -> $RULES_AFTER)." >&2
exit 1
fi
else
# --dev keeps comments AND formatting: this output is for reading, not shipping
rm -f "$TMP.min"
fi
# --dev: $TMP is still the raw concatenation — comments and formatting intact, for reading on a
# router rather than shipping.
# BROKEN-BUILD FLOOR (no upper size budget — removed). With only an upper bound, every way of
# producing a SHORT file — a truncated write, a full disk, a squeeze that ate the tail — passed
# and shipped a stylesheet with its second half missing. The floor catches that; it is a
# correctness guard, not a size limit.
#
# Measured on $TMP and BEFORE the mv, with the EXIT trap still armed — like the brace checks above,
# which had this order right. It used to run after, so the guard's own failure path LEFT the mangled
# sheet at $OUT with nothing to clean it up: make aborts in Build/Prepare, fine, but dev-sync.sh
# writes straight into htdocs/luci-static/footstrap/cascade.css, so a truncated sheet stayed in the
# working tree and the CSS-only iterate loop (scp that same file) would ship it.
SIZE=$(wc -c < "$TMP" | tr -d ' ')
FLOOR=${FS_CSS_FLOOR:-81920} # 80 KB — well under the real sheet; only a mangled build lands here
if [ "$DEV" -eq 0 ] && [ "$SIZE" -lt "$FLOOR" ]; then
echo "build-css: cascade.css would be only $SIZE bytes, under the $FLOOR-byte floor." >&2
echo "build-css: that is not a smaller stylesheet, that is a broken one. $OUT left untouched." >&2
exit 1
fi
mv "$TMP" "$OUT"
trap - EXIT