mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-28 11:22:57 +08:00
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:
co-authored by
Samiya Caur
parent
3d7e7bb438
commit
df1469af57
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) ?? [],
|
||||
|
||||
Reference in New Issue
Block a user