build(frontend): fail css-check on a nested rule the phone drops
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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fail on a nested rule whose selector starts with an element name.
|
||||
*
|
||||
* See `css-nesting.mjs` for what the phone does with one. No tier here
|
||||
* can see it: the component tier, the e2e tier and `make ui-visual` all
|
||||
* run a current Chromium, where the rule applies normally, so the only
|
||||
* report is a screenshot of the device — which is how the bottom bar's
|
||||
* title came to have never truncated there.
|
||||
*
|
||||
* It covers `index.css` and the `css` literals in the components alike,
|
||||
* because a shadow-root stylesheet is parsed by the same engine.
|
||||
*/
|
||||
import { globSync, readFileSync } from 'node:fs';
|
||||
|
||||
import { taggedLiterals } from './css-literals.mjs';
|
||||
import { findBareNestedRules } from './css-nesting.mjs';
|
||||
|
||||
const problems = [];
|
||||
|
||||
for (const { line, selector } of findBareNestedRules(
|
||||
readFileSync('index.css', 'utf8'),
|
||||
)) {
|
||||
problems.push({ file: 'index.css', line, selector });
|
||||
}
|
||||
|
||||
const sources = globSync('src/**/*.ts', { cwd: process.cwd() });
|
||||
|
||||
// A sweep over an empty glob passes, and this one is expected to find
|
||||
// nothing, so "it found nothing" has to mean it looked.
|
||||
if (sources.length === 0) {
|
||||
console.error('css-nesting-check: no sources matched src/**/*.ts');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const file of sources) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
|
||||
for (const literal of taggedLiterals(src, ['css'])) {
|
||||
for (const { line, selector } of findBareNestedRules(literal.body)) {
|
||||
problems.push({ file, line: literal.line + line - 1, selector });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const p of problems) {
|
||||
console.error(
|
||||
`${p.file}:${p.line}: nested rule "${p.selector.split('\n')[0]}" starts ` +
|
||||
'with an element name — write it as "& ' +
|
||||
`${p.selector.split('\n')[0]}"`,
|
||||
);
|
||||
}
|
||||
|
||||
console.error(
|
||||
`\ncss-nesting-check: ${problems.length} problem(s). ` +
|
||||
'Chrome 113 (the device) drops a nested rule that does not start ' +
|
||||
'with a symbol; the leading & is valid in both syntaxes.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`css-nesting-check: index.css + ${sources.length} files, no bare nested rules`,
|
||||
);
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Finding the `css` tagged templates in a TypeScript source.
|
||||
*
|
||||
* Two checks read them — the unterminated-comment one and the nesting
|
||||
* one — and a second scanner would be a second thing to keep in step
|
||||
* with how a template literal actually ends.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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.
|
||||
*
|
||||
* Newlines inside them are kept, so a line number taken from the
|
||||
* stripped text still names the right line of the file it came from.
|
||||
*/
|
||||
export 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];
|
||||
else if (text[i] === '\n') out += '\n';
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The 1-based line number of `index` in `src`. */
|
||||
export function lineOf(src, index) {
|
||||
return src.slice(0, index).split('\n').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tagged template literal in `src` whose tag is in `tags`.
|
||||
*
|
||||
* `body` has its substitutions stripped and `line` is the line its
|
||||
* opening backtick sits on, so `line + (n - 1)` is the file line of the
|
||||
* body's own line `n`.
|
||||
*/
|
||||
export function taggedLiterals(src, tags) {
|
||||
const pattern = new RegExp(`(^|[^\\w$.])(${tags.join('|')})\``, 'g');
|
||||
const found = [];
|
||||
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(src)) !== null) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = endOfTemplate(src, open);
|
||||
|
||||
if (close === -1) continue;
|
||||
|
||||
found.push({
|
||||
tag: match[2],
|
||||
body: stripSubstitutions(src.slice(open + 1, close)),
|
||||
line: lineOf(src, open),
|
||||
});
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* A nested rule whose selector starts with an element name is silently
|
||||
* dropped on the phone.
|
||||
*
|
||||
* The device renders in Chrome 113, which predates relaxed CSS nesting
|
||||
* (Chrome 120): before that a nested selector had to start with
|
||||
* something that could not be read as the beginning of a declaration,
|
||||
* so `.bottom-bar { audio-player { … } }` is not a parse error anyone
|
||||
* would notice — the inner rule simply does not exist, on the phone and
|
||||
* only on the phone. Three were live in `index.css`, one of them the
|
||||
* `text-overflow: ellipsis` on the bottom bar's title, which had
|
||||
* therefore never truncated on the device.
|
||||
*
|
||||
* `& audio-player` is valid in both syntaxes, so no nested rule here
|
||||
* has any reason to omit it.
|
||||
*
|
||||
* Two things the detection has to get right:
|
||||
*
|
||||
* - **A rule directly inside an at-rule is not nested.**
|
||||
* `@media (…) { bottom-nav { … } }` at the top level is an ordinary
|
||||
* rule and is fine — and it is the majority of the matches a regex
|
||||
* over the file would produce. What decides it is whether a *style*
|
||||
* rule is somewhere above, not what the immediate parent is: inside
|
||||
* `.bar { @media (…) { audio-player { … } } }` the inner rule is
|
||||
* nested, at-rule in between or not.
|
||||
* - **A declaration is not a rule.** `background: url(…)` and any
|
||||
* string or comment can hold a brace, so this tracks them rather than
|
||||
* matching lines.
|
||||
*/
|
||||
|
||||
/** Does this selector start with an identifier, rather than a symbol? */
|
||||
function startsWithIdent(selector) {
|
||||
return /^[A-Za-z_\u00A0-\uFFFF]/.test(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every nested style rule in `css` whose selector starts with an
|
||||
* element name, as `{ line, selector }` with a 1-based line.
|
||||
*/
|
||||
export function findBareNestedRules(css) {
|
||||
const found = [];
|
||||
/** The blocks we are inside, innermost last: 'style' or 'at'. */
|
||||
const stack = [];
|
||||
/** The text since the last `{`, `}` or `;` — a prelude, if a `{` follows. */
|
||||
let prelude = '';
|
||||
let preludeLine = 1;
|
||||
let line = 1;
|
||||
|
||||
const startPrelude = () => {
|
||||
prelude = '';
|
||||
preludeLine = line;
|
||||
};
|
||||
|
||||
for (let i = 0; i < css.length; i++) {
|
||||
const c = css[i];
|
||||
|
||||
if (c === '\n') {
|
||||
line++;
|
||||
if (prelude.trim() === '') preludeLine = line;
|
||||
prelude += c;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === '/' && css[i + 1] === '*') {
|
||||
const end = css.indexOf('*/', i + 2);
|
||||
const comment = css.slice(i, end === -1 ? css.length : end + 2);
|
||||
|
||||
line += (comment.match(/\n/g) ?? []).length;
|
||||
i += comment.length - 1;
|
||||
if (prelude.trim() === '') preludeLine = line;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === '"' || c === "'") {
|
||||
let j = i + 1;
|
||||
|
||||
while (j < css.length && css[j] !== c) {
|
||||
if (css[j] === '\\') j++;
|
||||
j++;
|
||||
}
|
||||
|
||||
prelude += css.slice(i, j + 1);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === '{') {
|
||||
const selector = prelude.trim();
|
||||
const kind = selector.startsWith('@') ? 'at' : 'style';
|
||||
|
||||
if (
|
||||
kind === 'style' &&
|
||||
stack.includes('style') &&
|
||||
startsWithIdent(selector)
|
||||
) {
|
||||
found.push({ line: preludeLine, selector });
|
||||
}
|
||||
|
||||
stack.push(kind);
|
||||
startPrelude();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === '}') {
|
||||
stack.pop();
|
||||
startPrelude();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === ';') {
|
||||
startPrelude();
|
||||
continue;
|
||||
}
|
||||
|
||||
prelude += c;
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
Reference in New Issue
Block a user