diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index 5abcf01..2401eb4 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -370,3 +370,49 @@ function testHTML(html: string, styles: string = "") { document.head.removeChild(style); return result; } + +test("captures shorthand properties containing var() references", () => { + // When a CSS shorthand like border-radius uses var(), browsers store the + // value only on the shorthand and return "" for longhands via getPropertyValue(). + // The fix parses cssText to recover these shorthand declarations. + // Note: jsdom may not fully reproduce this browser behavior, but this test + // ensures the cssText fallback path doesn't break normal operation. + expect( + testHTML( + ` +
+ `, + ` + :root { + --my-radius: 10px; + } + .test { + border-radius: var(--my-radius, 8px); + color: blue; + } + ` + ) + ).toMatch(/border-radius/); +}); + +test("captures shorthand with var() alongside non-var longhands", () => { + // Verify that when some properties use var() and others don't, + // both types are captured correctly. + expect( + testHTML( + ` + + `, + ` + :root { + --my-size: 16px; + } + .test { + font-size: var(--my-size, 14px); + color: green; + margin: 10px; + } + ` + ) + ).toMatch(/color: green/); +}); diff --git a/src/stylesheets.ts b/src/stylesheets.ts index a769b21..27aec47 100644 --- a/src/stylesheets.ts +++ b/src/stylesheets.ts @@ -12,11 +12,11 @@ const pseudoElementRegex = export function getDocumentStyleRules(document: Document) { return Array.from(document.styleSheets) .map((sheet) => - getStyleRulesFromSheet(sheet as CSSStyleSheet, document.defaultView!) + getStyleRulesFromSheet(sheet as CSSStyleSheet, document.defaultView!), ) .reduce(flatten, []) .sort((a, b) => - compare(calculate(b.selectorText), calculate(a.selectorText)) + compare(calculate(b.selectorText), calculate(a.selectorText)), ); } @@ -32,8 +32,8 @@ export function getElementStyles(el: Element, rules: SelectorWithStyles[]) { [(el as HTMLElement).style].concat( rules .filter((rule) => el.matches(rule.selectorText)) - .map(({ style }) => style) - ) + .map(({ style }) => style), + ), ); } @@ -44,7 +44,7 @@ export function getElementStyles(el: Element, rules: SelectorWithStyles[]) { */ export function getPseudoElementStyles( el: Element, - rules: SelectorWithStyles[] + rules: SelectorWithStyles[], ) { const stylesByPseudoElement = rules.reduce((rulesByPseudoElement, rule) => { const { selectorText, style } = rule; @@ -76,7 +76,7 @@ export function getPseudoElementStyles( if (seenPseudos && el.matches(baseSelector || "*")) { for (const name of seenPseudos) { (rulesByPseudoElement[name] || (rulesByPseudoElement[name] = [])).push( - style + style, ); } } @@ -92,7 +92,7 @@ export function getPseudoElementStyles( const styles = getAppliedStylesForElement( el, name, - stylesByPseudoElement[name] + stylesByPseudoElement[name], ); if (styles && shouldIncludePseudoElement(name, styles)) { appliedPseudoElementStyles ||= {}; @@ -105,7 +105,7 @@ export function getPseudoElementStyles( function shouldIncludePseudoElement( pseudoName: string, - styles: { [property: string]: string } + styles: { [property: string]: string }, ): boolean { if (pseudoName !== "::before" && pseudoName !== "::after") { // Other pseudo-elements (::selection, ::first-line, etc.) should always be included. @@ -127,7 +127,7 @@ function shouldIncludePseudoElement( */ function getStyleRulesFromSheet( sheet: CSSStyleSheet | CSSMediaRule | CSSSupportsRule, - window: Window + window: Window, ) { const styleRules: SelectorWithStyles[] = []; const curRules = sheet.cssRules; @@ -152,6 +152,82 @@ function getStyleRulesFromSheet( return styleRules; } +/** + * Parses a CSSStyleDeclaration's cssText into an array of {name, value} pairs. + * This captures shorthand properties that contain var() references which + * browsers cannot expand into longhands at parse time. + */ +function parseCssTextDeclarations( + style: CSSStyleDeclaration, +): { name: string; value: string }[] { + const results: { name: string; value: string }[] = []; + const cssText = style.cssText; + if (!cssText) return results; + + let i = 0; + const len = cssText.length; + + while (i < len) { + // Skip whitespace + while (i < len && (cssText[i] === " " || cssText[i] === "\t")) i++; + + // Read property name + const nameStart = i; + while ( + i < len && + cssText[i] !== ":" && + cssText[i] !== ";" && + cssText[i] !== " " + ) + i++; + const name = cssText.slice(nameStart, i).trim(); + + // Skip whitespace + while (i < len && (cssText[i] === " " || cssText[i] === "\t")) i++; + + // Expect ':' + if (i < len && cssText[i] === ":") { + i++; + } else { + // Malformed or end — skip to next ';' + while (i < len && cssText[i] !== ";") i++; + if (i < len) i++; + continue; + } + + // Skip whitespace + while (i < len && (cssText[i] === " " || cssText[i] === "\t")) i++; + + // Read value, respecting nested parentheses (e.g. var(--x, calc(100% - 20px))) + const valueStart = i; + let parenDepth = 0; + while (i < len) { + const ch = cssText[i]; + if (ch === "(") { + parenDepth++; + } else if (ch === ")") { + parenDepth--; + } else if (ch === ";" && parenDepth === 0) { + break; + } + i++; + } + const value = cssText.slice(valueStart, i).trim(); + if (i < len && cssText[i] === ";") i++; + + // Strip !important suffix (priority is handled separately via getPropertyPriority) + const cleanValue = value.endsWith("!important") + ? value.slice(0, -"!important".length).trim() + : value; + + if (name && cleanValue) { + results.push({ name, value: cleanValue }); + } + } + + return results; +} + /** * Given a list of css rules (in specificity order) returns the properties * applied accounting for !important values. @@ -159,7 +235,7 @@ function getStyleRulesFromSheet( function getAppliedStylesForElement( el: Element, pseudo: string | null, - styles: CSSStyleDeclaration[] + styles: CSSStyleDeclaration[], ) { let properties: { [x: string]: string } | null = null; const defaults = getDefaultStyles(el, pseudo); @@ -167,20 +243,20 @@ function getAppliedStylesForElement( const important: Set