Skip to content
Merged
Changes from 3 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
7e41796
fix(bulma-ui): a compound matches one simple selector, not co-occurrence
Sep 19, 2026
1ba7bdf
fix(bulma-ui): a :not() class is absent, not present
Sep 19, 2026
6a2daea
fix(bulma-ui): strip pseudo-class arguments before splitting, not after
Sep 19, 2026
4e3b517
fix(bulma-ui): read an escaped dot as part of the class name, not a s…
Sep 19, 2026
f395e76
fix(bulma-ui): only :not() drops its argument, and pin the direction
Sep 19, 2026
bc05622
fix(bulma-ui): a requirement is a requirement, parentheses or not
Sep 19, 2026
64f3d7c
fix(bulma-ui): a type or ID selector is a requirement the set has to …
Sep 19, 2026
7059957
fix(bulma-ui): a rule styles its subject, and only a class prohibitio…
Sep 20, 2026
4274e47
fix(bulma-ui): split the class-list check instead of nesting its quan…
Sep 20, 2026
662c5dd
test(bulma-ui): pin the pseudo-element exception with a query that re…
Sep 20, 2026
4f3b7c2
fix(bulma-ui): resolve pseudo-class arguments once, and count the at-…
Sep 20, 2026
697f9dd
fix(bulma-ui): a statement at-rule is not a heading, and an argument …
Sep 20, 2026
cb8d7c3
fix(bulma-ui): ask the two questions separately, and stop parsing for…
Sep 20, 2026
4e4076e
fix(bulma-ui): read nesting off the brace stack, not off the punctuation
Sep 20, 2026
caf5190
fix(bulma-ui): a string is content, and content is not structure
Sep 20, 2026
fe5696b
test(bulma-ui): pin the escape and the container query, which nothing…
Sep 20, 2026
dc1ccff
fix(bulma-ui): the must-ship check asks whether a helper renders, not…
Sep 20, 2026
4c64338
test(bulma-ui): one escape case per way of getting the escape wrong
Sep 20, 2026
5fb3249
fix(bulma-ui): the last pass that deleted text now stands something i…
Sep 20, 2026
2c465f9
fix(bulma-ui): three more spans the strip could not see, and an escap…
Sep 20, 2026
8cbda8e
fix(bulma-ui): an unquoted attribute value is an identifier, and iden…
Sep 20, 2026
acbd962
fix(bulma-ui): the attribute strip sees its own escapes, and a numeri…
Sep 20, 2026
c71a235
fix(bulma-ui): one scan for every delimiter, instead of a lookbehind …
Sep 20, 2026
72d82a3
fix(bulma-ui): the attribute strip joins the scan, and a scope root i…
Sep 20, 2026
aaa2750
fix(bulma-ui): the scope harvest gets the guard it was missing, and i…
Sep 20, 2026
d50b83b
test(bulma-ui): pin the harvest's other readers, and the one below it…
Sep 20, 2026
7d15786
fix(bulma-ui): list the at-rules that are transparent, not the ones t…
Sep 20, 2026
89d7a3f
fix(bulma-ui): inside brackets nothing is structure, whatever the fun…
Sep 20, 2026
1d08e11
fix(bulma-ui): a comment opener is content in there too, and brackets…
Sep 20, 2026
1608f1e
fix(bulma-ui): brackets have to balance as they go, not on the net
Sep 20, 2026
3965c04
fix(bulma-ui): a string ends at its line, and the transparent list st…
Sep 20, 2026
ce9b565
fix(bulma-ui): all three line breaks end a string, and a feature quer…
Sep 20, 2026
9082024
fix(bulma-ui): a CRLF is one line ending, and only a selector() names…
Sep 20, 2026
0edfb47
fix(bulma-ui): read the selector condition with a scan, since it has …
Sep 20, 2026
5557f53
test(bulma-ui): pin the condition scan's escape, and both cases it ig…
Sep 20, 2026
b4e0377
fix(bulma-ui): the function needs a boundary, and every keyword case …
Sep 20, 2026
484498a
fix(bulma-ui): track which bracket is open, not how many
Sep 20, 2026
e24dbc6
test(bulma-ui): the safety argument is about the callers, so say that
Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 63 additions & 43 deletions scripts/color-tuple-css.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,61 +98,81 @@ function stylesheet() {
}

/**
* Does the stylesheet carry this exact class, not a longer one starting with
* it?
* Does the stylesheet carry this class, or this exact compound?
*
* Single classes are matched literally. A COMPOUND (`notification.is-primary`)
* is matched order-independently, because CSS does not care and Bulma does not
* either: it writes `.notification.is-primary` and `.is-primary.input`. An
* element-first-only regex called the second one absent, which is how a live
* modifier could have read as dead.
* A single class is matched literally. A COMPOUND (`notification.is-primary`)
* has to appear as one SIMPLE selector carrying exactly those classes, which
* is narrower than it sounds and deliberately so.
*
* Indexed by class rather than scanned per query. Walking every selector for
* every pair took the first case from a third of a second to six, because
* this file asks the question a few thousand times.
* Order does not matter, because CSS does not care and Bulma does not either:
* it writes `.notification.is-primary` and `.is-primary.input`. Matching
* element-first only called the second absent, so a live modifier read as
* dead.
*
* But co-occurrence is not a match. Indexing per comma-separated part made
* `.hero.is-primary .tabs` answer the query `tabs.is-primary`, since all
* three classes sit in that one part, and that is the silent direction: a
* dead modifier reading as live, which is a missed defect rather than a false
* alarm. Parts are split into simple selectors on the combinators, and the
* class set has to match exactly, so a rule needing a third class does not
* answer for two.
*/
function shipsClass(css, cls) {
const parts = cls.split('.');
const index = classIndex(css);
if (parts.length === 1) return index.has(cls);
// Start from the rarest class so the scan is over the shortest list.
const lists = parts.map(part => index.get(part) ?? new Set());
if (lists.some(l => l.size === 0)) return false;
const smallest = lists.reduce((a, b) => (a.size <= b.size ? a : b));
for (const id of smallest) {
if (lists.every(l => l.has(id))) return true;
const wanted = cls.split('.');
const index = simpleSelectors(css);
if (wanted.length === 1) {
return index.some(classes => classes.has(cls));
}
Comment thread
allxsmith marked this conversation as resolved.
Resolved
return false;
return index.some(
classes =>
classes.size === wanted.length && wanted.every(w => classes.has(w))
);
}

/**
* class name → the selector parts carrying it, as a set of part ids.
* The class sets of every simple selector in the stylesheet.
*
* One pass over the stylesheet per file read. A part is one comma-separated
* selector, so a class list matching within a single part is what counts,
* rather than the whole selector list mentioning them somewhere apart.
* One pass per file read. Split rather than matched: a `[^{}]+` scan over a
* minified stylesheet of this size backtracks badly, where splitting is
* linear.
*/
const indexCache = new Map();
function classIndex(css) {
function simpleSelectors(css) {
if (!indexCache.has(css)) {
const index = new Map();
let id = 0;
// Split rather than matched. `/([^{}]+)\{/g` over 800KB of minified CSS
// took six seconds for 6,483 selectors, which is backtracking rather than
// work; this is five milliseconds for the same index. Each `{` is
// preceded by a prelude, and the selector is whatever follows the last
// `}` in it.
const sets = [];
for (const chunk of css.split('{')) {
const prelude = chunk.slice(chunk.lastIndexOf('}') + 1);
// Each `{` is preceded by a prelude; the selector is whatever follows
// the last `}` in it.
// Functional pseudo-class ARGUMENTS go before anything is split, and
// the order matters twice over. `:not(…)` names classes the element
// must not carry, so reading them as present lets
// `.notification:not(.is-light)` answer the query
// `notification.is-light` — a rule that excludes the pair reporting it
// live, which is the silent direction. And the arguments can contain
// commas: `.navbar-item:not(.is-active,.is-selected)` is one selector,
// so splitting the list first fragments it and leaves `is-active`
// looking like a class the element carries. Stripping first solves
// both.
//
// `:is()` and `:where()` lose their classes to the same strip. Those
// are alternatives rather than requirements, so dropping them makes the
// exact match demand fewer classes than the selector really needs and
// under-report, which is the safe way to be wrong. Keeping them would
// mean modelling branches, which is more parser than this file should
// carry.
const prelude = chunk
.slice(chunk.lastIndexOf('}') + 1)
.replace(/:[a-z-]+\([^()]*\)/gi, '');
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
for (const part of prelude.split(',')) {
id += 1;
for (const c of part.matchAll(/\.([A-Za-z0-9_-]+)/g)) {
if (!index.has(c[1])) index.set(c[1], new Set());
index.get(c[1]).add(id);
for (const simple of part.split(/[\s>+~]+/)) {
if (!simple.includes('.')) continue;
Comment thread
allxsmith marked this conversation as resolved.
Resolved
Comment thread
allxsmith marked this conversation as resolved.
Resolved
sets.push(
new Set([...simple.matchAll(/\.([A-Za-z0-9_-]+)/g)].map(m => m[1]))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
}
}
Comment thread
allxsmith marked this conversation as resolved.
Resolved
}
indexCache.set(css, index);
indexCache.set(css, sets);
}
return indexCache.get(css);
}
Expand All @@ -179,7 +199,7 @@ function tupleFrom(path, name) {
`could not find the \`${name}\` tuple in ${path}, so this guard cannot ` +
'run. Fix the pattern in the same change that moved it.'
);
const values = [...block[1].matchAll(/'([^']+)'/g)].map(m => m[1]);
const values = [...block[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
assert.ok(values.length > 0, `the \`${name}\` tuple read as empty`);
return values;
}
Expand All @@ -198,7 +218,7 @@ function colorKeywords() {
const source = codeOnly(readFileSync(COLOR_CLASSES, 'utf8'));
const copies = [
...source.matchAll(/\[\s*\.\.\.validColors\s*,([^\]]*)\]/g),
].map(m => [...m[1].matchAll(/'([^']+)'/g)].map(x => x[1]));
].map(m => [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1]));
// Counted against the spellings that EXIST, not against zero. A `> 0`
// guard let a copy this pattern stopped matching drop out silently while
// the function claimed to read every one of them.
Expand Down Expand Up @@ -300,7 +320,7 @@ function warningCallers() {
break;
}
}
const named = /warnUnstyledColor\(\s*'([^']+)'/.exec(text);
const named = /warnUnstyledColor\(\s*['"]([^'"]+)['"]/.exec(text);
// A call whose component is not a string literal cannot be attributed
// to an element, and is counted below rather than skipped quietly.
if (!named) continue;
Expand Down Expand Up @@ -351,7 +371,7 @@ function warningCallers() {
// every bracketed span in the call text let a bracketed expression
// in an earlier argument contribute a value, failing with a message
// naming something the library never declared.
extraUnstyled: [...(args[2] ?? '').matchAll(/'([^']+)'/g)].map(
extraUnstyled: [...(args[2] ?? '').matchAll(/['"]([^'"]+)['"]/g)].map(
x => x[1]
),
});
Expand Down Expand Up @@ -399,7 +419,7 @@ function acceptedKeywords(component, keywords) {
/** The colours `CSS_BACKED` names to the developer as ones that work. */
function cssBackedColors() {
const source = readFileSync(DEPRECATIONS, 'utf8');
const line = /const CSS_BACKED =\s*\n?\s*'([^']+)'/.exec(source);
const line = /const CSS_BACKED =\s*\n?\s*['"]([^'"]+)['"]/.exec(source);
assert.ok(line, 'could not find `CSS_BACKED` in colorDeprecations.ts');
return line[1].split(',').map(v => v.trim());
}
Expand Down