Skip to content
Merged
Changes from 3 commits
Commits
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
273 changes: 222 additions & 51 deletions test/test-frontend-audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -1957,8 +1957,48 @@ test('die Küchen-Listen teilen eine Zeilen-Grammatik', () => {
// Eine Kappung ist eine Kappung, egal wie buchstabiert: die logischen Formen
// wirken im Schreibmodus dieser App auf dieselbe Achse. Dasselbe Paar prüft
// der Modul-Root-Breiten-Guard weiter unten schon.
const WIDTH_CAP = ['width', 'max-width', 'inline-size', 'max-inline-size'];
// ZWEI Gruppen, nicht eine Liste: `width` und `max-width` konkurrieren nicht,
// sie beschränken die Box gemeinsam. Als eine Liste gelesen gewönne bei
// `max-width: 20rem; width: 100%` das erlaubte `100%` - und die Kappung auf
// 20rem stünde ungeprüft daneben. Innerhalb einer Gruppe konkurrieren die
// Schreibweisen sehr wohl (logisch gegen physisch, gleiche Achse).
const WIDTH_AXES = [['width', 'inline-size'], ['max-width', 'max-inline-size']];
const MAX_WIDTH = ['max-width', 'max-inline-size'];
// Werte, die dem Scroller NICHTS wegnehmen. Ein Modul darf `max-width: none`
// ausdrücklich hinschreiben - verboten ist die Kappung, nicht die Erwähnung.
const FREE_WIDTH = ['none', 'auto', 'initial', 'unset', 'revert', '100%'];
Comment thread
ulsklyc marked this conversation as resolved.
// Ausrichtungen, die das Element seine Spur füllen lassen.
const FILLS = ['stretch', 'normal', 'auto', 'initial', 'unset', 'revert'];
Comment thread
ulsklyc marked this conversation as resolved.

// Die WIRKSAMEN Inline-Margen einer Regel. In Deklarationsreihenfolge
// aufgelöst, weil der Shorthand die Langformen zurücksetzt: nach
// `margin-inline-end: 20rem; margin: 0` ist die Marge null, und wer nur
// sammelt statt zu kaskadieren, meldet dort einen Verstoß, den es
// nicht gibt.
const inlineMargins = (body) => {
let start = null;
let end = null;
const pattern = /(?:^|;)\s*(margin|margin-inline|margin-inline-start|margin-inline-end|margin-left|margin-right)\s*:\s*([^;]+)/gim;
for (const [, rawProp, rawValue] of body.matchAll(pattern)) {
const prop = rawProp.toLowerCase();
const value = rawValue.replace(/!\s*important$/i, '').trim();
Comment thread
ulsklyc marked this conversation as resolved.
const parts = value.split(/\s+/);
if (prop === 'margin') {
const [top, right = top, , left = right] = parts;
start = left;
end = right;
} else if (prop === 'margin-inline') {
const [first, second = first] = parts;
start = first;
end = second;
} else if (prop === 'margin-inline-start' || prop === 'margin-left') {
start = value;
} else {
end = value;
}
}
return [['margin-inline-start', start], ['margin-inline-end', end]].filter(([, value]) => value !== null);
};

// Zielt der Selektor auf das Element selbst, nicht auf einen Nachfahren?
// Geprüft wird der LETZTE Compound, damit auch `.kitchen-list#items-list`,
Expand Down Expand Up @@ -2022,12 +2062,14 @@ test('die Küchen-Listen teilen eine Zeilen-Grammatik', () => {
`${page}.js setzt eine Inline-Breite am Scroller - die schlägt jede Regel im Stylesheet und damit auch diesen Guard`);
assert.doesNotMatch(src, new RegExp(`\\b${name}\\.style\\.(?:alignSelf|placeSelf)\\s*=`),
`${page}.js setzt align-self inline am Scroller - das nimmt ihm die volle Breite`);
assert.doesNotMatch(src, new RegExp(`\\b${name}\\.style\\.setProperty\\(\\s*['"\`](?:max-)?(?:width|inline-size|align-self|place-self)`, 'i'),
`${page}.js setzt eine Breite oder Ausrichtung inline am Scroller (setProperty)`);
assert.doesNotMatch(src, new RegExp(`\\b${name}\\.style\\.setProperty\\(\\s*['"\`](?:(?:max-)?(?:width|inline-size)|align-self|place-self|margin(?:-inline)?(?:-start|-end)?|margin-left|margin-right)`, 'i'),
Comment thread
ulsklyc marked this conversation as resolved.
`${page}.js setzt eine Breite, Ausrichtung oder Marge inline am Scroller (setProperty)`);
assert.doesNotMatch(src, new RegExp(`\\b${name}\\.style\\.cssText\\s*=`),
`${page}.js überschreibt den Stil des Scrollers per cssText - was darin steht, sieht dieser Guard nicht`);
assert.doesNotMatch(src, new RegExp(`\\b${name}\\.setAttribute\\(\\s*['"\`]style`, 'i'),
`${page}.js setzt den Stil des Scrollers per setAttribute - derselbe Inline-Stil über einen anderen Weg`);
assert.doesNotMatch(src, new RegExp(`\\b${name}\\.style\\.margin(?:Inline|Left|Right)?[A-Za-z]*\\s*=`),
`${page}.js setzt eine Inline-Marge am Scroller - die zieht als gestrecktes Flex-Item direkt von seiner Breite ab`);
Comment thread
ulsklyc marked this conversation as resolved.
Comment on lines +2087 to +2088

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject bracket-notation inline margins

Fresh evidence after the setProperty fix is that bracket notation still bypasses every inline-margin pattern. On a JavaScript-built scroller, list.style['marginInlineEnd'] = '20rem' shortens the stretched element's wheel-hit area just like the dot-property form, but the audit remains green because this regex requires .style.margin...; include equivalent bracket-property assignments in the scan.

Useful? React with 👍 / 👎.

}
(src.match(/<[^>]*\bkitchen-list\b[^>]*>/g) ?? []).forEach((tag) => {
assert.doesNotMatch(tag, /\sstyle\s*=/,
Expand All @@ -2038,16 +2080,27 @@ test('die Küchen-Listen teilen eine Zeilen-Grammatik', () => {
'.kitchen-list ist nirgends definiert: ein leerer Treffer darf hier nicht still grün bleiben');
for (const cls of scrollerTokens) {
for (const { file, selectors, body } of rulesFor(cls)) {
assert.equal(declaredValue(body, WIDTH_CAP), null,
`${file} ${selectors.join(', ')}: der Scroller darf nicht gekappt werden, sonst endet sein Mausrad-Trefferbereich an der Lesespalten-Kante`);
for (const axis of WIDTH_AXES) {
const cap = declaredValue(body, axis);
assert.ok(cap === null || FREE_WIDTH.includes(cap),
`${file} ${selectors.join(', ')}: ${axis[0]}: ${cap} kappt den Scroller - dann endet sein Mausrad-Trefferbereich an der Lesespalten-Kante`);
}

// Dieselbe Verengung ohne Breitenangabe: als gestrecktes Flex-Item zieht
// eine Inline-Marge direkt von der Randbox ab. `margin-inline-end: 20rem`
// beendet den Trefferbereich 20rem vor der Seitenkante.
for (const [prop, value] of inlineMargins(body)) {
assert.ok(/^0[a-z%]*$/.test(value),
Comment thread
ulsklyc marked this conversation as resolved.
`${file} ${selectors.join(', ')}: ${prop}: ${value} nimmt dem Scroller Breite - der Trefferbereich endet dann davor`);
}

// Dieselbe Kante ohne jede Breitenangabe: der Scroller ist Flex-Item
// seines Modul-Roots (.recipes-page & Co. sind flex column). Ein
// `align-self: start` nimmt ihm das voreingestellte Strecken und lässt
// ihn auf Inhaltsbreite schrumpfen - der Trefferbereich fürs Mausrad
// endet dann genau dort. Erlaubt bleibt nur, was ihn füllen lässt.
const spread = declaredValue(body, ALIGN_SELF);
assert.ok(spread === null || ['stretch', 'normal', 'auto', 'initial', 'unset'].includes(spread),
assert.ok(spread === null || FILLS.includes(spread),
`${file} ${selectors.join(', ')}: align-self: ${spread} nimmt dem Scroller die volle Breite - dann greift das Mausrad rechts daneben ins Leere`);

// `all` setzt jede der oben geprüften Eigenschaften mit zurück, ohne
Expand Down Expand Up @@ -2075,10 +2128,20 @@ test('die Küchen-Listen teilen eine Zeilen-Grammatik', () => {
// `var(--content-max-width-narrow)` ungültig und das max-width fällt auf
// `none` zurück - die Listen liefen bildschirmbreit, während dieser Test
// weiter zwei Texte vergleicht, die zueinander passen.
assert.ok(allRules.some(({ file, selectors, body, conditional }) =>
file === 'tokens.css' && !conditional && selectors.some((sel) => /^:root\b/.test(sel))
&& declaredValue(body, '--content-max-width-narrow') !== null),
'tokens.css muss --content-max-width-narrow unbedingt in :root definieren - ohne die Deklaration löst var(…) auf nichts auf und die Kappung entfällt');
// Die GEWINNENDE Deklaration über alle kanonischen Regeln. Weder „die
// letzte" noch „die erste" genügt: `!important` schlägt die
// Quellreihenfolge auch zwischen zwei :root-Blöcken. Deshalb werden die
// Rümpfe in Quellreihenfolge aneinandergehängt und einmal ausgewertet -
// declaredValue() kennt die Vorrangregel bereits.
const canonicalBodies = allRules
.filter(({ file, selectors, conditional }) => file === 'tokens.css' && !conditional
&& selectors.some((sel) => /^:root\b/.test(sel)))
.map(({ body }) => body).join(';');
Comment on lines +2152 to +2155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor selector specificity for canonical token rules

Fresh evidence after the cross-rule !important fix is that concatenating rule bodies still reduces the cascade to source order and discards selector specificity. With an earlier :root:not(.unused) { --content-max-width-narrow: none; } followed by the normal :root declaration, the more-specific earlier rule wins in the browser and leaves the lists uncapped, while declaredValue() selects the later 720px body and the audit passes. Either carry selector specificity into winner selection or accept only the exact canonical :root selector.

Useful? React with 👍 / 👎.

const tokenValue = declaredValue(canonicalBodies, '--content-max-width-narrow');
assert.ok(tokenValue !== null,
'tokens.css muss --content-max-width-narrow unbedingt in :root definieren - ohne die Deklaration löst var(…) auf nichts auf und die Kappung entfällt');
assert.match(tokenValue, /^(?:\d+(?:\.\d+)?(?:px|rem|em|ch|ex|vw|vmin|vmax|%)|(?:min|max|clamp|calc)\(.*\))$/,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate function bodies in the canonical width token

Fresh evidence beyond the previously fixed prefix match is that the anchored regex accepts arbitrary contents inside supported function names. For example, --content-max-width-narrow: calc(1px +) passes this assertion, but substitution produces an invalid max-width, so the browser falls back to an uncapped list while the audit stays green; validate the function grammar rather than only its outer name and parentheses.

Useful? React with 👍 / 👎.

`--content-max-width-narrow ist auf "${tokenValue}" gesetzt - das ist keine Breite, und die Kappung der Kinder läuft ins Leere`);

// 2. Tragen muss die Kappung stattdessen jedes Kind, das ALLEIN Kind des
// Scrollers sein kann: .kitchen-group bei gruppierten Tabs (Einkauf,
Expand All @@ -2087,13 +2150,23 @@ test('die Küchen-Listen teilen eine Zeilen-Grammatik', () => {
// Block darf sie auch nicht auf einen abweichenden Wert ziehen.
for (const cls of ['.kitchen-group', '.kitchen-rows']) {
const rules = rulesFor(cls);
// Unbedingt heißt zweierlei: nicht hinter einem Breakpoint UND nicht an
// einen Zustand gebunden. `.kitchen-rows:hover` kappt nur unter dem
// Mauszeiger; auf Touch und per Tastatur liefe die Liste voll breit.
const unstated = (sel) => !/:(?!(?:is|where)\()/.test(sel);
// Unbedingt heißt dreierlei: nicht hinter einem Breakpoint, nicht an einen
// Zustand gebunden, und nicht an einen Vorfahren geknüpft.
// `.kitchen-rows:hover` kappt nur unter dem Mauszeiger;
// `.shopping-page .kitchen-rows` kappt die Rezeptliste gar nicht, obwohl
// der Selektor die Klasse nennt und dieser Scan ihn findet.
// Anders als in targets() bleiben :not() und :has() hier STEHEN. Dort
// sagen sie nur, dass die genannte Klasse nicht das Subjekt ist; hier
// sagen sie, dass die Kappung an eine Bedingung geknüpft ist -
// `.kitchen-rows:not(.uncapped)` lässt jede Zeile mit dieser Klasse
// ungekappt. `:is()`/`:where()` gehören zum Subjekt: Inhalt behalten.
const plain = (sel) => {
const bare = sel.replace(/:(?:is|where)\(([^)]*)\)/g, '$1');
return !/:/.test(bare) && !/[\s>+~,]/.test(bare);
};
assert.ok(rules.some(({ body, conditional, selectors }) =>
!conditional && selectors.some(unstated) && declaredValue(body, MAX_WIDTH) === NARROW),
`${cls} muss das Lesemaß UNBEDINGT tragen: eine Kappung hinter einem Breakpoint oder an einem Zustand (:hover) fehlt im Normalfall`);
!conditional && selectors.some(plain) && declaredValue(body, MAX_WIDTH) === NARROW),
`${cls} muss das Lesemaß UNBEDINGT tragen: eine Kappung hinter einem Breakpoint, an einem Zustand (:hover) oder unter einem Vorfahren (.foo ${cls}) greift nicht in jedem Kontext, in dem das Element gerendert wird`);
for (const { file, body } of rules) {
// Eine feste Breite schlägt die Kappung, ohne sie anzufassen: mit
// `width: 20rem` bleibt das max-width korrekt stehen und die Liste steht
Expand All @@ -2108,7 +2181,7 @@ test('die Küchen-Listen teilen eine Zeilen-Grammatik', () => {
// das, und die auto-Breite fällt auf den Inhalt zusammen - das Lesemaß
// bleibt dabei unangetastet und unwirksam.
const inline = declaredValue(body, ['justify-self', 'place-self'], 'inline');
assert.ok(inline === null || ['stretch', 'normal', 'auto', 'initial', 'unset'].includes(inline),
assert.ok(inline === null || FILLS.includes(inline),
`${file}: ${cls} bekommt justify-self: ${inline} - dann schrumpft die Gruppe auf ihren Inhalt, statt das Lesemaß auszufüllen`);
assert.equal(declaredValue(body, 'all'), null,
`${file}: ${cls} wird per all-Kurzschreibweise zurückgesetzt - das nimmt Kappung und Ausrichtung mit`);
Expand Down Expand Up @@ -2140,7 +2213,50 @@ test('die Küchen-Listen teilen eine Zeilen-Grammatik', () => {
// sie steht ÜBER dem Scroller (siehe dort) und trägt das Lesemaß, clippt
// aber nicht. Käme dort ein `overflow: hidden` dazu, meldet dieser Guard
// einen Fall, den ein Mensch entscheiden muss.
for (const { selectors, body } of cssRules(shared)) {
// Kombiniert wird über REGELGRENZEN hinweg: der Browser sammelt die
// Deklarationen aller passenden Regeln, bevor er den Wert bestimmt.
// Stünden Kappung und `overflow` in zwei getrennten Blöcken, sähe eine
// Prüfung pro Block in keinem von beiden ein gekapptes, clippendes
// Element - und genau das ist es.
// Gruppiert wird nach dem ELEMENT, nicht nach dem Selektortext: `.kitchen-rows`
// und `ul.kitchen-rows` treffen dasselbe `ul`, stünden als zwei Einträge
// aber je unvollständig da. Maßgeblich sind die Klassen und IDs im
// Subjekt; eine Regel zählt zu jedem Element, dessen Merkmale sie
// vollständig enthält.
const subjectKeys = (selector) => {
const subject = selector
.replace(/:(?:not|has)\([^)]*\)/g, '')
.replace(/:(?:is|where)\(([^)]*)\)/g, '$1')
.trim().split(/[\s>+~]+/).pop() ?? '';
return new Set(subject.match(/[.#][\w-]+/g) ?? []);
Comment thread
ulsklyc marked this conversation as resolved.
Comment thread
ulsklyc marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve element types when combining clipping rules

Fresh evidence beyond the ancestor/state fixes is that subjectKeys() still discards the subject's element type. If the base .kitchen-rows rule keeps its cap and overflow: hidden but the alignment fix moves to div.kitchen-rows { align-self: start; }, this combiner applies that fix to the same key and the audit passes; however, the recipe list is an ul.kitchen-rows, so it remains stretched and clips its rows. Keep type selectors in the element identity while still allowing an untyped rule to match every type.

Useful? React with 👍 / 👎.

};
// Der Vorfahren-Kontext bleibt dabei erhalten. Ohne ihn landeten
// `.context-a .kitchen-rows { overflow: hidden }` und
// `.context-b .kitchen-rows { align-self: start }` im selben Topf,
// obwohl kein Element je beide Regeln sieht - der Guard hielte das
// Clipping für ausgeglichen, das es in Kontext A nicht ist.
const contextOf = (selector) => {
const parts = selector.replace(/:(?:is|where)\(([^)]*)\)/g, '$1').trim().split(/[\s>+~]+/);
return parts.slice(0, -1).join(' ');
};
const sharedRules = scopedRules(shared)
.flatMap(({ selectors, body }) => selectors.map((sel) => ({
keys: subjectKeys(sel), context: contextOf(sel), sel, body,
})))
Comment on lines +2266 to +2269

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve conditional contexts when combining clipping rules

Fresh evidence after the at-rule parsing fixes is that this projection drops each rule's conditional flag before declarations are combined. Removing the base align-self and placing .kitchen-rows { align-self: start; } inside @media (max-width: 1px) leaves the audit green because the conditional declaration is merged with the unconditional cap and overflow, although at every normal viewport the list remains stretched and clipped. Conditional rules must not satisfy the unconditional clipping fix.

Useful? React with 👍 / 👎.

.filter(({ keys }) => keys.size > 0);
const elements = new Map();
for (const { keys, context, sel } of sharedRules) {
const id = `${context}|${[...keys].sort().join('')}`;
if (!elements.has(id)) elements.set(id, { keys, context, label: sel });
}
for (const [, { keys, context, label }] of elements) {
const body = sharedRules
// Eine kontextfreie Regel trifft das Element in JEDEM Kontext; eine
// kontextgebundene nur in ihrem eigenen.
.filter(({ keys: own, context: own2 }) => [...own].every((key) => keys.has(key))
&& (own2 === '' || own2 === context))
.map(({ body: part }) => part).join(';');
const selectors = [label];
if (declaredValue(body, MAX_WIDTH) !== NARROW) continue;
// `clip` kappt wie `hidden`, nur ohne Scrollport - und die Block-Achse
// lässt sich auch als Langform setzen. Der Grund für die Zusicherung ist
Expand Down Expand Up @@ -4745,45 +4861,100 @@ test('housekeeping exposes its page title as the primary heading', () => {
// Erwähnung im Fließtext erfüllbar.
const stripCssComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');

// Wie cssRules(), aber jede Regel weiß zusätzlich, ob sie in einer At-Rule
// steht (`conditional`). Für eine GEFORDERTE Deklaration ist das der
// Unterschied zwischen „gilt immer" und „gilt unterhalb von 640px": cssRules()
// wirft das Präludium weg und kann beides nicht auseinanderhalten.
function scopedRules(css) {
const live = stripCssComments(css);
const rules = [];
let depth = 0; // Tiefe der offenen At-Rule-Blöcke
let from = 0; // Beginn des laufenden Präludiums
for (let i = 0; i < live.length; i += 1) {
const char = live[i];
// Wie cssRules(), aber jede Regel kennt zusaetzlich ihren Kontext:
//
// - `conditional` sagt, ob sie nur unter einer Bedingung gilt. Fuer eine
// GEFORDERTE Deklaration ist das der Unterschied zwischen „gilt immer" und
// „gilt unterhalb von 640px". Entscheidend ist die SEMANTIK der At-Rule,
// nicht ihr '@': `@media`/`@supports`/`@container`/`@scope` schraenken ein,
// `@layer` ordnet nur die Kaskade und gilt ueberall.
// - Verschachtelte Regeln werden mitgelesen, mit aufgeloestem Selektor.
// Ein flacher Scanner nimmt die erste schliessende Klammer als Rumpfende
// und uebersieht `.foo { & { max-width: 20rem } }` vollstaendig - er
// prueft dann still weniger, als er behauptet.
const CONDITIONAL_AT_RULE = /^@(?:media|supports|container|scope|document|starting-style)\b/i;

// Deklarationen dieser Ebene, ohne die Rumpfe verschachtelter Regeln (die
// kommen als eigene Eintraege) und ohne deren Praeludien.
function ownDeclarations(body) {
let out = '';
let depth = 0;
for (let i = 0; i < body.length; i += 1) {
const char = body[i];
if (char === '{') {
const prelude = live.slice(from, i).trim();
if (prelude.startsWith('@')) {
depth += 1;
from = i + 1;
continue;
if (depth === 0) {
const cut = Math.max(out.lastIndexOf(';'), out.lastIndexOf('}'));
out = out.slice(0, cut + 1);
}
const end = live.indexOf('}', i);
if (end === -1) break;
rules.push({
selectors: prelude.replace(/\s+/g, ' ').split(',').map((s) => s.trim()).filter(Boolean),
body: live.slice(i + 1, end),
conditional: depth > 0,
});
i = end;
from = i + 1;
depth += 1;
} else if (char === '}') {
depth = Math.max(0, depth - 1);
from = i + 1;
} else if (char === ';') {
// Statement-At-Rules (@import, @charset, @layer x;) enden mit Semikolon
// und oeffnen keinen Block. Ohne diesen Zweig waechst das Praeludium
// ueber sie hinaus, beginnt mit '@' - und die erste echte Regel der
// Datei wird still als At-Rule-Rumpf verschluckt. Deklarations-Semikola
// landen hier nie: ueber Regelbloecke springt die Schleife hinweg.
from = i + 1;
} else if (depth === 0) {
out += char;
}
}
return out;
}

function scopedRules(css) {
const live = stripCssComments(css);
const rules = [];

const parse = (from, to, conditional, parents) => {
let i = from;
let start = from;
while (i < to) {
const char = live[i];
// Statement-At-Rules (@import, @charset, @layer x;) oeffnen keinen Block;
// ohne diesen Zweig waechst das Praeludium ueber sie hinaus und die
// naechste echte Regel wird als At-Rule-Rumpf verschluckt.
if (char === ';' || char === '}') {
i += 1;
start = i;
continue;
}
if (char !== '{') {
i += 1;
continue;
}

const prelude = live.slice(start, i).replace(/\s+/g, ' ').trim();
let depth = 1;
let j = i + 1;
while (j < to && depth > 0) {
if (live[j] === '{') depth += 1;
else if (live[j] === '}') depth -= 1;
j += 1;
Comment thread
ulsklyc marked this conversation as resolved.
}
const close = j - 1;

if (prelude.startsWith('@')) {
const inner = conditional || CONDITIONAL_AT_RULE.test(prelude);
// Steht die Gruppe IN einer Style-Regel, gelten ihre eigenen
// Deklarationen dem Elternselektor: `.kitchen-list { @media … {
// max-width: 20rem } }`. Ohne diesen Zweig verschwindet die Kappung.
if (parents.length) {
const own = ownDeclarations(live.slice(i + 1, close));
if (own.trim()) rules.push({ selectors: parents, body: own, conditional: inner });
}
parse(i + 1, close, inner, parents);
} else {
const own = prelude.split(',').map((sel) => sel.trim()).filter(Boolean);
Comment thread
ulsklyc marked this conversation as resolved.
const selectors = parents.length
? own.flatMap((sel) => parents.map((parent) => (sel.includes('&')
? sel.replace(/&/g, parent)
: `${parent} ${sel}`)))
: own;
rules.push({ selectors, body: ownDeclarations(live.slice(i + 1, close)), conditional });
parse(i + 1, close, conditional, selectors);
}

i = close + 1;
start = i;
}
};

parse(0, live.length, false, []);
return rules;
}

Expand Down
Loading