build(frontend): fail css-check on a nested rule the phone drops
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 8m55s

The device renders in Chrome 113, which predates relaxed CSS nesting, so
a nested rule whose selector starts with an element name is not a parse
error anyone would notice -- the rule simply does not exist, there and
nowhere else. Three were live in `index.css`, and the one that mattered
was the `text-overflow: ellipsis` on the bottom bar's title and artist,
which had therefore never truncated on the device. No tier here can see
the class at all: the component tier, the e2e tier and `make ui-visual`
all run a current engine, where the rule applies normally.

So `make css-check` carries a second script. It reads `index.css` and
the `css` literals in `src/**/*.ts` alike, since a shadow-root
stylesheet is parsed by the same engine, and it names the file, the line
and the fix -- a leading `&`, which is valid in both syntaxes.

The detection walks blocks rather than matching lines, and both things
it has to get right fall out of one rule: a rule is nested when a
*style* rule is somewhere above it, not when its immediate parent is a
block. That leaves `@media (...) { bottom-nav { ... } }` at the top
level alone, which is the majority of what a regex over the file would
report, and still flags the same rule inside an at-rule that is itself
inside a style rule. Strings and comments are read through, so a brace
in a `url()` is not a block.

The tree has no violation left, so the check would pass just as happily
over an empty glob: it refuses one, and `test/utils/css-nesting.test.ts`
pins the semantics that make the sweep mean something. The literal
scanner the two checks share is lifted into `css-literals.mjs`
unchanged, except that a `${}` substitution is now blanked keeping its
newlines so a line number survives it.

Closes #154
This commit is contained in:
2026-08-21 05:38:34 -04:00
parent 14e3ab574c
commit 4bf2414653
9 changed files with 412 additions and 82 deletions
+5 -79
View File
@@ -23,97 +23,23 @@
* as the literal contains an unterminated `/*`. Nothing else produces
* that, and a legitimate literal cannot contain one.
*/
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
import { globSync, readFileSync } from 'node:fs';
import { taggedLiterals } from './css-literals.mjs';
const TAGS = ['css', 'html', 'svg'];
/**
* Find the end of a template literal that starts at `start` (the index
* of its opening backtick), respecting escapes and `${}` substitutions.
* Returns the index of the closing backtick, or -1.
*/
function endOfTemplate(src, start) {
let depth = 0;
for (let i = start + 1; i < src.length; i++) {
const c = src[i];
if (c === '\\') {
i++;
continue;
}
if (c === '$' && src[i + 1] === '{') {
depth++;
i++;
continue;
}
if (c === '}' && depth > 0) {
depth--;
continue;
}
if (c === '`' && depth === 0) return i;
}
return -1;
}
/** Strip `${...}` substitutions, which may legitimately contain anything. */
function stripSubstitutions(text) {
let out = '';
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '$' && text[i + 1] === '{') {
depth++;
i++;
continue;
}
if (text[i] === '}' && depth > 0) {
depth--;
continue;
}
if (depth === 0) out += text[i];
}
return out;
}
function lineOf(src, index) {
return src.slice(0, index).split('\n').length;
}
const files = globSync('src/**/*.ts', { cwd: process.cwd() });
const problems = [];
for (const file of files) {
const src = readFileSync(file, 'utf8');
const tagPattern = new RegExp(`(^|[^\\w$.])(${TAGS.join('|')})\``, 'g');
let match;
while ((match = tagPattern.exec(src)) !== null) {
const open = match.index + match[0].length - 1;
const close = endOfTemplate(src, open);
if (close === -1) continue;
const body = stripSubstitutions(src.slice(open + 1, close));
for (const { tag, body, line } of taggedLiterals(src, TAGS)) {
const opens = (body.match(/\/\*/g) ?? []).length;
const closes = (body.match(/\*\//g) ?? []).length;
if (opens > closes) {
problems.push({
file,
line: lineOf(src, open),
tag: match[2],
});
}
if (opens > closes) problems.push({ file, line, tag });
}
}