Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | 6x 6x 6x 6x 11x 11x 11x 4x 4x 4x 11x 11x 13x 13x 13x 13x 13x 13x 16x 6x 3x 3x 3x 3x 6x 6x 8x 8x 1x 7x 6x 3x 7x 7x 8x 1x 3x 3x 6x 6x 6x 2x 6x 3x 10x 6x 6x 5x 5x 6x 1x 1x 3x 3x 3x 1x | import postcss, { Rule, Declaration } from 'postcss';
// Tier-1 CSS attribution: text-diffs bundle A (develop) vs bundle B (this PR) to
// find WHAT changed. Pairing a change to a given page's pixels (Tier 2) and to
// an exact declaration per pixel (Tier 3) are handled elsewhere / out of scope.
export interface ChangedDecl {
selector: string;
prop: string;
// undefined on a pure add (no A value) or pure remove (no B value).
valueA?: string;
valueB?: string;
kind: 'added' | 'removed' | 'changed';
}
// selector -> (prop -> value). Last declaration wins, mirroring the cascade.
type DeclMap = Map<string, Map<string, string>>;
// Flatten a stylesheet into selector→prop→value, namespacing nested at-rules
// (@media etc.) into the key so they don't collide with the same top-level selector.
function flatten(css: string): DeclMap {
const out: DeclMap = new Map();
let root;
try {
root = postcss.parse(css);
} catch {
// Unparseable bundle shouldn't crash the report; just yield no rules.
return out;
}
root.walkRules((rule: Rule) => {
const prefixes: string[] = [];
let parent = rule.parent;
while (parent && parent.type === 'atrule') {
const at = parent as unknown as { name: string; params: string };
prefixes.unshift(`@${at.name} ${at.params}`);
parent = (parent as unknown as { parent?: typeof parent }).parent;
}
const prefix = prefixes.length ? `${prefixes.join(' | ')} | ` : '';
// A comma-separated selector list is recorded one selector at a time.
for (const sel of rule.selectors ?? [rule.selector]) {
const key = `${prefix}${sel.trim()}`;
let props = out.get(key);
Eif (!props) {
props = new Map();
out.set(key, props);
}
rule.walkDecls((decl: Declaration) => {
// PostCSS keeps !important out of decl.value; add it back so toggling it counts as a change.
props!.set(decl.prop, decl.important ? `${decl.value} !important` : decl.value);
});
}
});
return out;
}
export function diffCssBundles(cssA: string, cssB: string): ChangedDecl[] {
const a = flatten(cssA);
const b = flatten(cssB);
const changes: ChangedDecl[] = [];
// Walk B: catches added selectors/props and changed values.
for (const [selector, bProps] of b) {
const aProps = a.get(selector);
for (const [prop, valueB] of bProps) {
const valueA = aProps?.get(prop);
if (valueA === undefined) {
changes.push({ selector, prop, valueB, kind: 'added' });
} else if (valueA !== valueB) {
changes.push({ selector, prop, valueA, valueB, kind: 'changed' });
}
}
}
// Walk A: catches removed selectors/props (present in A, gone in B).
for (const [selector, aProps] of a) {
const bProps = b.get(selector);
for (const [prop, valueA] of aProps) {
if (!bProps || !bProps.has(prop)) {
changes.push({ selector, prop, valueA, kind: 'removed' });
}
}
}
return changes;
}
// Exact match ignoring comments (the build banner embeds the commit SHA). Unlike the declaration
// diff, it also catches rule order and at-rules such as @font-face.
export function sameCss(cssA: string, cssB: string): boolean {
const strip = (css: string) => {
try {
const root = postcss.parse(css);
root.walkComments((c) => {
c.remove();
});
return root.toString();
} catch {
return css;
}
};
return strip(cssA) === strip(cssB);
}
// Pseudo/state selectors that querySelectorAll can't match statically, so a page can't rule them out.
export function isUntestableSelector(sel: string): boolean {
return /::|:hover|:focus|:active|:visited|:checked|:disabled|:enabled|:target|:not\(|:is\(|:where\(|:has\(/.test(
sel,
);
}
// Unique selectors touched by the PR's CSS change, with the at-rule prefix
// stripped so they can be handed to querySelectorAll.
export function changedSelectors(changes: ChangedDecl[]): string[] {
const set = new Set<string>();
for (const c of changes) {
const bare = c.selector.includes(' | ')
? c.selector.slice(c.selector.lastIndexOf(' | ') + 3)
: c.selector;
set.add(bare.trim());
}
return [...set];
}
// Bare selector -> the props the PR changed on it.
export function changedRuleProps(changes: ChangedDecl[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const c of changes) {
const bare = (
c.selector.includes(' | ') ? c.selector.slice(c.selector.lastIndexOf(' | ') + 3) : c.selector
).trim();
const props = (out[bare] ??= []);
Eif (!props.includes(c.prop)) props.push(c.prop);
}
return out;
}
|