| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
set -u |
| 12 |
ROOT="$(cd "$(dirname "$0")/.." && pwd)" |
| 13 |
STATIC="$ROOT/static" |
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
SITE_SHEETS="$STATIC/style.css $STATIC/wizard.css $STATIC/media-player.css" |
| 19 |
|
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
GENERATED_SHEETS="$STATIC/geometry.css $STATIC/layout.css $STATIC/typography.css $STATIC/timing.css" |
| 25 |
|
| 26 |
violations=0 |
| 27 |
|
| 28 |
report() { |
| 29 |
local rule="$1"; shift |
| 30 |
local msg="$1"; shift |
| 31 |
if [ -n "$*" ]; then |
| 32 |
echo |
| 33 |
echo "[$rule] $msg" |
| 34 |
echo "$*" |
| 35 |
violations=$((violations + 1)) |
| 36 |
fi |
| 37 |
} |
| 38 |
|
| 39 |
|
| 40 |
|
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
|
| 46 |
|
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
|
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
hits=$(python3 - "$GENERATED_SHEETS" "$SITE_SHEETS" <<'PY' |
| 56 |
import re, sys |
| 57 |
|
| 58 |
def rules(path): |
| 59 |
"""(selector, body, line) for every rule with declarations, @media included.""" |
| 60 |
raw = open(path).read() |
| 61 |
# Blank comments out rather than deleting them, so line numbers survive. |
| 62 |
src = re.sub(r'/\*.*?\*/', lambda m: re.sub(r'[^\n]', ' ', m.group()), raw, flags=re.S) |
| 63 |
out, stack, cur, i = [], [], '', 0 |
| 64 |
while i < len(src): |
| 65 |
c = src[i] |
| 66 |
if c == '{': |
| 67 |
stack.append((cur.strip(), i)); cur = '' |
| 68 |
elif c == '}': |
| 69 |
if stack: |
| 70 |
sel, start = stack.pop() |
| 71 |
body = src[start + 1:i] |
| 72 |
if '{' not in body: |
| 73 |
out.append((sel, body, raw[start + 1:i], src.count('\n', 0, start) + 1)) |
| 74 |
cur = '' |
| 75 |
else: |
| 76 |
cur += c |
| 77 |
i += 1 |
| 78 |
return out |
| 79 |
|
| 80 |
def props(body): |
| 81 |
return {d.split(':')[0].strip() for d in body.split(';') if ':' in d and d.split(':')[0].strip().startswith(('-', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'))} |
| 82 |
|
| 83 |
def keys(sel): |
| 84 |
"""(class, state) per compound in the selector, so a rule is only ever |
| 85 |
compared against the generated rule for the SAME state. Unioning the |
| 86 |
states instead reports .card { color } against the generated |
| 87 |
.card:disabled, which is not a contest: they never both apply.""" |
| 88 |
out = set() |
| 89 |
for compound in re.split(r'[\s>+~,]+', sel): |
| 90 |
m = re.match(r'\.([a-z][a-z0-9-]*)', compound) |
| 91 |
if not m: |
| 92 |
continue |
| 93 |
# Everything else in the compound is state: further classes as much as |
| 94 |
# pseudo-classes. Dropping the extra classes keys .tab.chosen as plain |
| 95 |
# .tab and lends the base primitive every property its modifiers set. |
| 96 |
state = ''.join(sorted( |
| 97 |
re.findall(r'(::?[a-z-]+(?:\([^)]*\))?|\[[^\]]*\])', compound) |
| 98 |
+ re.findall(r'\.[a-z][a-z0-9-]*', compound)[1:])) |
| 99 |
out.add((m.group(1), state)) |
| 100 |
return out |
| 101 |
|
| 102 |
generated = {} |
| 103 |
for path in sys.argv[1].split(): |
| 104 |
for sel, body, _raw, _line in rules(path): |
| 105 |
for k in keys(sel): |
| 106 |
generated.setdefault(k, set()).update(props(body)) |
| 107 |
|
| 108 |
bad = [] |
| 109 |
for path in sys.argv[2].split(): |
| 110 |
for sel, body, raw_body, line in rules(path): |
| 111 |
if 'respec-ok:' in raw_body: |
| 112 |
continue |
| 113 |
for cls, state in keys(sel): |
| 114 |
clash = generated.get((cls, state), set()) & props(body) |
| 115 |
if clash: |
| 116 |
short = ' '.join(sel.split())[:70] |
| 117 |
bad.append(f" {path.split('/')[-1]}:{line} {short}\n" |
| 118 |
f" re-specifies .{cls}{state}: {', '.join(sorted(clash))}") |
| 119 |
for b in sorted(set(bad)): |
| 120 |
print(b) |
| 121 |
PY |
| 122 |
) |
| 123 |
report "no-primitive-respec" \ |
| 124 |
"A local rule re-specifies what the generated sheet already sets. Delete it and take the generated look, or add /* respec-ok: <reason> */ naming what the generated sheet cannot express." \ |
| 125 |
"$hits" |
| 126 |
|
| 127 |
|
| 128 |
|
| 129 |
|
| 130 |
|
| 131 |
|
| 132 |
|
| 133 |
|
| 134 |
|
| 135 |
|
| 136 |
hits=$(python3 - "$GENERATED_SHEETS $SITE_SHEETS" <<'PY' |
| 137 |
import re, sys |
| 138 |
|
| 139 |
paths = sys.argv[1].split() |
| 140 |
defined = set() |
| 141 |
for p in paths: |
| 142 |
defined |= set(re.findall(r'^\s*(--[a-z0-9-]+)\s*:', open(p).read(), re.M)) |
| 143 |
|
| 144 |
for p in paths: |
| 145 |
for n, line in enumerate(open(p), 1): |
| 146 |
for m in re.finditer(r'var\(\s*(--[a-z0-9-]+)\s*([,)])', line): |
| 147 |
if m.group(2) == ',': |
| 148 |
continue # has a fallback: JS-set at runtime |
| 149 |
if m.group(1) not in defined: |
| 150 |
print(f" {p.split('/')[-1]}:{n} {m.group(1)} is used but never defined") |
| 151 |
PY |
| 152 |
) |
| 153 |
report "no-undefined-token" \ |
| 154 |
"var(--token) with no definition and no fallback. The whole declaration is dropped at computed-value time." \ |
| 155 |
"$hits" |
| 156 |
|
| 157 |
|
| 158 |
|
| 159 |
|
| 160 |
|
| 161 |
|
| 162 |
|
| 163 |
|
| 164 |
hits=$(python3 - "$SITE_SHEETS" <<'PY' |
| 165 |
import re, sys |
| 166 |
|
| 167 |
LITERAL = re.compile(r'(#[0-9a-fA-F]{3,8}\b|rgba?\(\s*[0-9])') |
| 168 |
for p in sys.argv[1].split(): |
| 169 |
src = open(p).read() |
| 170 |
src = re.sub(r'/\*.*?\*/', lambda m: re.sub(r'[^\n]', ' ', m.group()), src, flags=re.S) |
| 171 |
for n, line in enumerate(src.split('\n'), 1): |
| 172 |
if not LITERAL.search(line): |
| 173 |
continue |
| 174 |
decl = line.split(':')[0].strip() |
| 175 |
# A literal is allowed as the value of a custom property: that is what |
| 176 |
# a token IS. It is not allowed as the value of anything else. |
| 177 |
if decl.startswith('--'): |
| 178 |
continue |
| 179 |
print(f" {p.split('/')[-1]}:{n} {line.strip()[:80]}") |
| 180 |
PY |
| 181 |
) |
| 182 |
report "no-colour-literal" \ |
| 183 |
"Raw colour literal outside the intent :root and the APP-LOCAL CONSTANTS block. Use a token so it re-themes." \ |
| 184 |
"$hits" |
| 185 |
|
| 186 |
|
| 187 |
|
| 188 |
|
| 189 |
|
| 190 |
|
| 191 |
hits="" |
| 192 |
for sheet in $SITE_SHEETS; do |
| 193 |
if ! grep -qE '^@layer components \{' "$sheet"; then |
| 194 |
hits="$hits ${sheet##*/} does not open a components layer"$'\n' |
| 195 |
fi |
| 196 |
done |
| 197 |
report "layer-adoption" \ |
| 198 |
"A site sheet is unlayered, so it beats every named layer regardless of specificity." \ |
| 199 |
"$(echo "$hits" | sed '/^$/d')" |
| 200 |
|
| 201 |
if [ $violations -eq 0 ]; then |
| 202 |
echo "frontend lint: clean" |
| 203 |
exit 0 |
| 204 |
else |
| 205 |
echo |
| 206 |
echo "frontend lint: $violations rule(s) failed" |
| 207 |
exit 1 |
| 208 |
fi |
| 209 |
|