feat: update css formatter class to add pseudo element (#2716)

Adds support for direct and inherited pseudo-element rules in
CssFormatter.

It also implements inheritance filtering specific to pseudo-elements.

Co-authored-by: Samiya Caur <samiyac@chromium.org>
This commit is contained in:
Samiya Caur
2026-09-10 14:07:49 +00:00
committed by GitHub
co-authored by Samiya Caur
parent 3d7e7bb438
commit df1469af57
4 changed files with 468 additions and 1 deletions
+116 -1
View File
@@ -112,8 +112,26 @@ export interface InheritedRule {
properties: StructuredCssProperty[];
}
export interface PseudoElementRule {
type: 'pseudo';
pseudoType: string;
node?: {
uid?: string;
selector: string;
};
selector?: string;
matchingSelectors?: string[];
source?: string;
ancestors?: AncestorCSSRule[];
properties: StructuredCssProperty[];
}
export type CascadeRule =
NodeStyleRule | AnimationRule | MatchedRule | InheritedRule;
| NodeStyleRule
| AnimationRule
| MatchedRule
| InheritedRule
| PseudoElementRule;
export interface StructuredCssStyles {
element: {
@@ -397,12 +415,21 @@ function formatPropertyLine(prop: StructuredCssProperty): string {
/**
* Filters properties to only those that can be inherited from an ancestor element.
*
* For highlight pseudo-elements, custom properties (`--*`) are not inherited.
* For other CSS custom properties, check registered @property inheritance
* rules if available.
* For all remaining properties, use standard CSSMetadata inheritance check.
*/
function getInheritableProperties(
properties: DevTools.CSSProperty.CSSProperty[],
matchedStyles: MatchedStyles,
isHighlight = false,
): DevTools.CSSProperty.CSSProperty[] {
return properties.filter(prop => {
if (isHighlight) {
return !DevTools.CSSMetadata.cssMetadata().isCustomProperty(prop.name);
}
if (DevTools.CSSMetadata.cssMetadata().isCustomProperty(prop.name)) {
const registered = matchedStyles.getRegisteredProperty?.(prop.name);
if (registered) {
@@ -448,6 +475,9 @@ function getCascadeRuleHeader(rule: CascadeRule): string {
case 'inherited':
selector = rule.selector ?? 'element.style';
break;
case 'pseudo':
selector = rule.selector ?? rule.pseudoType;
break;
}
const source = 'source' in rule ? rule.source : undefined;
return source ? `${selector} (${source})` : selector;
@@ -544,6 +574,14 @@ function appendCssSectionsToString(
writer.indent();
appendRuleWithAncestors(writer, rule);
writer.dedent();
} else if (rule.type === 'pseudo') {
let inheritedStr = '';
if (rule.node) {
const uidPart = rule.node.uid ? `, uid: "${rule.node.uid}"` : '';
inheritedStr = ` (inherited from ${rule.node.selector}${uidPart})`;
}
writer.writeComment(`Pseudo ${rule.pseudoType} element${inheritedStr}`);
appendRuleWithAncestors(writer, rule);
} else {
appendRuleWithAncestors(writer, rule);
}
@@ -566,6 +604,7 @@ export class CssFormatter {
): CascadeRule[] {
const rules: CascadeRule[] = [];
CssFormatter.#collectNodeStyles(rules, matchedStyles, options);
CssFormatter.#collectPseudoStyles(rules, matchedStyles, options);
return rules;
}
@@ -719,6 +758,82 @@ export class CssFormatter {
};
}
static #collectPseudoStyles(
rules: CascadeRule[],
matchedStyles: MatchedStyles,
options: CssFormatterOptions,
): void {
const customHighlightNames =
matchedStyles.customHighlightPseudoNames?.() ?? [];
for (const highlightName of customHighlightNames) {
const pseudoStyles =
matchedStyles.customHighlightPseudoStyles?.(highlightName) ?? [];
CssFormatter.#collectPseudoList(
rules,
`::highlight(${highlightName})`,
pseudoStyles,
matchedStyles,
options,
true,
);
}
// Standard Pseudos (::before, ::after, ::marker, ::selection, etc.)
const otherPseudoTypes = matchedStyles.pseudoTypes?.() ?? [];
for (const pseudoType of otherPseudoTypes) {
const pseudoStyles = matchedStyles.pseudoStyles?.(pseudoType) ?? [];
CssFormatter.#collectPseudoList(
rules,
`::${pseudoType}`,
pseudoStyles,
matchedStyles,
options,
DevTools.CSSMetadata.cssMetadata().isHighlightPseudoType(pseudoType),
);
}
}
static #collectPseudoList(
rules: CascadeRule[],
pseudoType: string,
pseudoStyles: DevTools.CSSStyleDeclaration.CSSStyleDeclaration[],
matchedStyles: MatchedStyles,
options: CssFormatterOptions,
isHighlight = false,
): void {
for (const style of pseudoStyles) {
const allProps = CssFormatter.#getStyleProperties(style);
if (!allProps.length) {
continue;
}
const node = getParentNodeInfo(style, matchedStyles, options.resolveUid);
const properties = node
? getInheritableProperties(allProps, matchedStyles, isHighlight)
: allProps;
if (!properties.length) {
continue;
}
const rule =
style.parentRule instanceof DevTools.CSSRule.CSSStyleRule
? style.parentRule
: undefined;
const meta = getCSSStyleRuleMetadata(
rule,
matchedStyles,
options.containerDetails,
);
rules.push({
type: 'pseudo',
pseudoType,
...(node ? {node} : {}),
...(rule ? {selector: rule.selectorText()} : {}),
...meta,
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
}
}
static #formatProperties(
props: DevTools.CSSProperty.CSSProperty[],
matchedStyles: MatchedStyles,
@@ -242,6 +242,176 @@ Styles for div#main (uid: "1_1"):
(no styles)
`;
exports[`CssFormatter > formats inherited pseudo-elements with ancestor node and resolves uid toJSON 1`] = `
{
"element": {
"uid": "para-1",
"selector": "p.paragraph"
},
"rules": [
{
"type": "inherited",
"node": {
"uid": "cont-10",
"selector": "div.container"
},
"selector": "div.container::selection",
"source": "theme.css:6",
"properties": [
{
"name": "color",
"value": "white",
"status": "active"
},
{
"name": "--selection-var",
"value": "red",
"status": "active"
}
]
},
{
"type": "inherited",
"node": {
"uid": "cont-10",
"selector": "div.container"
},
"properties": [
{
"name": "color",
"value": "yellow",
"status": "active"
},
{
"name": "--highlight-color",
"value": "gold",
"status": "active"
}
]
},
{
"type": "inherited",
"node": {
"uid": "cont-10",
"selector": "div.container"
},
"properties": [
{
"name": "color",
"value": "green",
"status": "active"
}
]
},
{
"type": "pseudo",
"pseudoType": "::highlight(search)",
"node": {
"uid": "cont-10",
"selector": "div.container"
},
"properties": [
{
"name": "color",
"value": "yellow",
"status": "active"
}
]
},
{
"type": "pseudo",
"pseudoType": "::selection",
"node": {
"uid": "cont-10",
"selector": "div.container"
},
"selector": "div.container::selection",
"source": "theme.css:6",
"properties": [
{
"name": "color",
"value": "white",
"status": "active"
},
{
"name": "background-color",
"value": "navy",
"status": "active"
}
]
},
{
"type": "pseudo",
"pseudoType": "::marker",
"properties": [
{
"name": "content",
"value": "\\"•\\"",
"status": "active"
}
]
},
{
"type": "pseudo",
"pseudoType": "::marker",
"node": {
"uid": "cont-10",
"selector": "div.container"
},
"properties": [
{
"name": "color",
"value": "green",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats inherited pseudo-elements with ancestor node and resolves uid toString 1`] = `
Styles for p.paragraph (uid: "para-1"):
Inherited from div.container (uid: "cont-10"):
div.container::selection (theme.css:6) {
color: white;
--selection-var: red;
}
Inherited from div.container (uid: "cont-10"):
element.style {
color: yellow;
--highlight-color: gold;
}
Inherited from div.container (uid: "cont-10"):
element.style {
color: green;
}
/* Pseudo ::highlight(search) element (inherited from div.container, uid: "cont-10") */
::highlight(search) {
color: yellow;
}
/* Pseudo ::selection element (inherited from div.container, uid: "cont-10") */
div.container::selection (theme.css:6) {
color: white;
background-color: navy;
}
/* Pseudo ::marker element */
::marker {
content: "•";
}
/* Pseudo ::marker element (inherited from div.container, uid: "cont-10") */
::marker {
color: green;
}
`;
exports[`CssFormatter > formats inherited styles from ancestors and ignores non-inheritable ones toJSON 1`] = `
{
"element": {
@@ -520,6 +690,72 @@ Styles for button (uid: "elem-child"):
}
`;
exports[`CssFormatter > formats pseudo-elements with rules and inline pseudo styles toJSON 1`] = `
{
"element": {
"uid": "btn-pseudo",
"selector": "button"
},
"rules": [
{
"type": "pseudo",
"pseudoType": "::before",
"selector": "button.btn::before",
"ancestors": [
{
"type": "nesting",
"selector": ".btn-group"
}
],
"matchingSelectors": [
"button.btn::before"
],
"source": "styles.css:21",
"properties": [
{
"name": "content",
"value": "\\"→\\"",
"status": "active"
},
{
"name": "color",
"value": "blue",
"status": "active"
}
]
},
{
"type": "pseudo",
"pseudoType": "::after",
"properties": [
{
"name": "content",
"value": "\\"*\\"",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats pseudo-elements with rules and inline pseudo styles toString 1`] = `
Styles for button (uid: "btn-pseudo"):
/* Pseudo ::before element */
.btn-group {
button.btn::before (styles.css:21) {
content: "→";
color: blue;
}
}
/* Pseudo ::after element */
::after {
content: "*";
}
`;
exports[`CssFormatter > formats transition, animation, and attributes styles toJSON 1`] = `
{
"element": {
+98
View File
@@ -11,6 +11,7 @@ import {
type ContainerQuery,
CssFormatter,
type ResolvedContainerDetails,
type UidResolver,
} from '../../src/formatters/CssFormatter.js';
import {DevTools} from '../../src/third_party/index.js';
import {
@@ -420,4 +421,101 @@ describe('CssFormatter', () => {
return new CssFormatter(matchedStyles, {uid: 'child-elem'});
},
);
formatterTest(
'formats pseudo-elements with rules and inline pseudo styles',
() => {
const pseudoRule = createMockCSSStyleRule('button.btn::before', {
sourceURL: 'styles.css',
lineNumber: 20,
columnNumber: 4,
selectors: [{text: 'button.btn::before'}, {text: 'a.link::before'}],
nestingSelectors: ['.btn-group'],
});
const beforeStyle = createMockCSSStyleDeclaration(
[
createMockCSSProperty('content', '"→"'),
createMockCSSProperty('color', 'blue'),
],
{rule: pseudoRule},
);
const afterStyle = createMockCSSStyleDeclaration([
createMockCSSProperty('content', '"*"'),
]);
const matchedStyles = createMockCSSMatchedStyles({
pseudoStyles: new Map([
[DevTools.Protocol.DOM.PseudoType.Before, [beforeStyle]],
[DevTools.Protocol.DOM.PseudoType.After, [afterStyle]],
]),
matchingSelectorsMap: new Map([[pseudoRule, [0]]]),
});
return new CssFormatter(matchedStyles, {uid: 'btn-pseudo'});
},
);
formatterTest(
'formats inherited pseudo-elements with ancestor node and resolves uid',
() => {
const selectionRule = createMockCSSStyleRule('div.container::selection', {
sourceURL: 'theme.css',
lineNumber: 5,
columnNumber: 1,
});
const inheritedSelectionStyle = createMockCSSStyleDeclaration(
[
createMockCSSProperty('color', 'white'),
createMockCSSProperty('background-color', 'navy'),
createMockCSSProperty('--selection-var', 'red'),
],
{rule: selectionRule},
);
const inheritedHighlightStyle = createMockCSSStyleDeclaration([
createMockCSSProperty('color', 'yellow'),
createMockCSSProperty('--highlight-color', 'gold'),
]);
const inheritedMarkerStyle = createMockCSSStyleDeclaration([
createMockCSSProperty('color', 'green'),
createMockCSSProperty('padding', '5px'),
]);
const directMarkerStyle = createMockCSSStyleDeclaration([
createMockCSSProperty('content', '"•"'),
]);
const parentNode = createMockDOMNode({
selector: 'div.container',
backendNodeId: 10,
});
const matchedStyles = createMockCSSMatchedStyles({
node: createMockDOMNode({selector: 'p.paragraph', backendNodeId: 1}),
parentNode,
inheritedStyles: [
inheritedSelectionStyle,
inheritedHighlightStyle,
inheritedMarkerStyle,
],
pseudoStyles: new Map([
[
DevTools.Protocol.DOM.PseudoType.Selection,
[inheritedSelectionStyle],
],
[
DevTools.Protocol.DOM.PseudoType.Marker,
[directMarkerStyle, inheritedMarkerStyle],
],
]),
customHighlights: new Map([['search', [inheritedHighlightStyle]]]),
});
const resolveUid: UidResolver = (backendId: number) =>
backendId === 10 ? 'cont-10' : undefined;
return new CssFormatter(matchedStyles, {
uid: 'para-1',
resolveUid,
});
},
);
});
+18
View File
@@ -384,6 +384,14 @@ export interface MockCSSMatchedStylesParams {
DevTools.CSSStyleDeclaration.CSSStyleDeclaration,
DevTools.DOMModel.DOMNode
>;
pseudoStyles?: Map<
DevTools.Protocol.DOM.PseudoType,
DevTools.CSSStyleDeclaration.CSSStyleDeclaration[]
>;
customHighlights?: Map<
string,
DevTools.CSSStyleDeclaration.CSSStyleDeclaration[]
>;
propertyStates?: Map<DevTools.CSSProperty.CSSProperty, string>;
matchingSelectorsMap?: Map<unknown, number[]>;
}
@@ -407,6 +415,10 @@ export function createMockCSSMatchedStyles(
: params.parentNode;
const nodeForStyleMap = params.nodeForStyleMap ?? new Map();
const pseudoStylesMap = params.pseudoStyles ?? new Map();
const pseudoTypes = new Set(pseudoStylesMap.keys());
const customHighlights = params.customHighlights ?? new Map();
const propertyStates = params.propertyStates ?? new Map();
const mock = sinon.createStubInstance(
@@ -415,6 +427,8 @@ export function createMockCSSMatchedStyles(
mock.node.returns(mockNode);
mock.nodeStyles.returns(nodeStyles);
mock.inheritedStyles.returns(inheritedStyles);
mock.pseudoTypes.returns(pseudoTypes);
mock.customHighlightPseudoNames.returns(new Set(customHighlights.keys()));
mock.nodeForStyle.callsFake(
style => nodeForStyleMap.get(style) ?? defaultParentNode ?? null,
@@ -422,6 +436,10 @@ export function createMockCSSMatchedStyles(
mock.isInherited.callsFake(style =>
Boolean(inheritedStyles.find(inheritedStyle => inheritedStyle === style)),
);
mock.pseudoStyles.callsFake(type => pseudoStylesMap.get(type) ?? []);
mock.customHighlightPseudoStyles.callsFake(
name => customHighlights.get(name) ?? [],
);
mock.propertyState.callsFake(prop => propertyStates.get(prop) ?? 'Active');
mock.getMatchingSelectors.callsFake(
rule => params.matchingSelectorsMap?.get(rule) ?? [],