feat: update css formatter class to add AtRule, PositionTryRule, PropertyRule, FunctionRule (#2717)

Adds formatting support for modern CSS at-rules

Co-authored-by: Samiya Caur <samiyac@chromium.org>
This commit is contained in:
Samiya Caur
2026-09-10 19:39:35 +00:00
committed by GitHub
co-authored by Samiya Caur
parent d05cbc0511
commit d4a0620ce6
4 changed files with 699 additions and 1 deletions
+176 -1
View File
@@ -126,12 +126,53 @@ export interface PseudoElementRule {
properties: StructuredCssProperty[];
}
export interface AtRule {
type: 'at-rule';
atRuleType: string;
name?: string;
subsection?: string;
selector: string;
source?: string;
ancestors?: AncestorCSSRule[];
properties: StructuredCssProperty[];
}
export interface PositionTryRule {
type: 'position-try';
name: string;
active: boolean;
selector: string;
source?: string;
ancestors?: AncestorCSSRule[];
properties: StructuredCssProperty[];
}
export interface PropertyRule {
type: 'property';
name: string;
selector: string;
source?: string;
properties: StructuredCssProperty[];
}
export interface FunctionRule {
type: 'function';
name: string;
selector: string;
source?: string;
properties: StructuredCssProperty[];
}
export type CascadeRule =
| NodeStyleRule
| AnimationRule
| MatchedRule
| InheritedRule
| PseudoElementRule;
| PseudoElementRule
| AtRule
| PositionTryRule
| PropertyRule
| FunctionRule;
export interface StructuredCssStyles {
element: {
@@ -470,6 +511,9 @@ function getCascadeRuleHeader(rule: CascadeRule): string {
case 'animation':
case 'attributes':
case 'matched':
case 'at-rule':
case 'property':
case 'function':
selector = rule.selector;
break;
case 'inherited':
@@ -478,6 +522,11 @@ function getCascadeRuleHeader(rule: CascadeRule): string {
case 'pseudo':
selector = rule.selector ?? rule.pseudoType;
break;
case 'position-try': {
const statePrefix = rule.active ? '' : '[inactive] ';
selector = `${statePrefix}${rule.selector}`;
break;
}
}
const source = 'source' in rule ? rule.source : undefined;
return source ? `${selector} (${source})` : selector;
@@ -605,6 +654,10 @@ export class CssFormatter {
const rules: CascadeRule[] = [];
CssFormatter.#collectNodeStyles(rules, matchedStyles, options);
CssFormatter.#collectPseudoStyles(rules, matchedStyles, options);
CssFormatter.#collectAtRules(rules, matchedStyles);
CssFormatter.#collectPositionTryRules(rules, matchedStyles);
CssFormatter.#collectRegisteredProperties(rules, matchedStyles);
CssFormatter.#collectFunctionRules(rules, matchedStyles);
return rules;
}
@@ -834,6 +887,128 @@ export class CssFormatter {
}
}
static #collectAtRules(
rules: CascadeRule[],
matchedStyles: MatchedStyles,
): void {
const atRules = matchedStyles.atRules?.() ?? [];
for (const atRule of atRules) {
const properties = CssFormatter.#getStyleProperties(atRule.style);
if (!properties.length) {
continue;
}
const subsection = atRule.subsection() ?? undefined;
const name = atRule.name()?.text;
const type = atRule.type();
const selector = subsection
? `@${subsection}`
: name
? `@${type} ${name}`
: `@${type}`;
const ancestors: AncestorCSSRule[] = [];
if (subsection) {
ancestors.push({
type: 'at-rule',
atRuleType: type,
...(name ? {name} : {}),
});
}
rules.push({
type: 'at-rule',
atRuleType: type,
...(name ? {name} : {}),
...(subsection ? {subsection} : {}),
selector,
...(ancestors.length > 0 ? {ancestors} : {}),
source: getSourceLocation(atRule),
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
}
}
static #collectPositionTryRules(
rules: CascadeRule[],
matchedStyles: MatchedStyles,
): void {
const positionTryRules = matchedStyles.positionTryRules?.() ?? [];
for (const positionTryRule of positionTryRules) {
const properties = CssFormatter.#getStyleProperties(
positionTryRule.style,
);
if (!properties.length) {
continue;
}
const name = positionTryRule.name?.()?.text ?? '';
const active = positionTryRule.active?.() ?? false;
const source = getSourceLocation(positionTryRule);
rules.push({
type: 'position-try',
name,
active,
selector: `@position-try ${name}`,
...(source ? {source} : {}),
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
}
}
static #collectRegisteredProperties(
rules: CascadeRule[],
matchedStyles: MatchedStyles,
): void {
const registeredProperties = matchedStyles.registeredProperties?.() ?? [];
for (const propertyRule of registeredProperties) {
const style = propertyRule.style?.();
if (!style) {
continue;
}
const properties = CssFormatter.#getStyleProperties(style);
if (!properties.length) {
continue;
}
const name = propertyRule.propertyName?.() ?? '';
const parentRule = style.parentRule;
const source = parentRule
? getSourceLocation(parentRule)
: 'CSS.registerProperty';
rules.push({
type: 'property',
name,
selector: `@property ${name}`,
...(source ? {source} : {}),
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
}
}
static #collectFunctionRules(
rules: CascadeRule[],
matchedStyles: MatchedStyles,
): void {
const functionRules = matchedStyles.functionRules?.() ?? [];
for (const functionRule of functionRules) {
const properties = CssFormatter.#getStyleProperties(functionRule.style);
if (!properties.length) {
continue;
}
const name = functionRule.functionName?.()?.text ?? '';
const nameWithParameters = functionRule.nameWithParameters?.() || name;
const source = getSourceLocation(functionRule);
rules.push({
type: 'function',
name,
selector: `@function ${nameWithParameters}`,
...(source ? {source} : {}),
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
}
}
static #formatProperties(
props: DevTools.CSSProperty.CSSProperty[],
matchedStyles: MatchedStyles,
@@ -1,3 +1,112 @@
exports[`CssFormatter > formats @font-face at-rule without name toJSON 1`] = `
{
"element": {
"uid": "elem-at-2",
"selector": "button"
},
"rules": [
{
"type": "at-rule",
"atRuleType": "font-face",
"selector": "@font-face",
"source": "<style>",
"properties": [
{
"name": "font-family",
"value": "Open Sans",
"status": "active"
},
{
"name": "src",
"value": "url(font.woff2)",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats @font-face at-rule without name toString 1`] = `
Styles for button (uid: "elem-at-2"):
@font-face (<style>) {
font-family: Open Sans;
src: url(font.woff2);
}
`;
exports[`CssFormatter > formats @font-palette-values at-rule with name toJSON 1`] = `
{
"element": {
"uid": "elem-at-1",
"selector": "button"
},
"rules": [
{
"type": "at-rule",
"atRuleType": "font-palette-values",
"name": "--my-palette",
"selector": "@font-palette-values --my-palette",
"source": "fonts.css:11",
"properties": [
{
"name": "font-family",
"value": "Bixa",
"status": "active"
},
{
"name": "base-palette",
"value": "3",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats @font-palette-values at-rule with name toString 1`] = `
Styles for button (uid: "elem-at-1"):
@font-palette-values --my-palette (fonts.css:11) {
font-family: Bixa;
base-palette: 3;
}
`;
exports[`CssFormatter > formats @function custom function rule with parameters and declarations toJSON 1`] = `
{
"element": {
"uid": "elem-func",
"selector": "button"
},
"rules": [
{
"type": "function",
"name": "--double",
"selector": "@function --double(--x)",
"source": "math.css:5",
"properties": [
{
"name": "result",
"value": "calc(var(--x) * 2)",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats @function custom function rule with parameters and declarations toString 1`] = `
Styles for button (uid: "elem-func"):
@function --double(--x) (math.css:5) {
result: calc(var(--x) * 2);
}
`;
exports[`CssFormatter > formats @layer, @media, @supports, and @starting-style ancestor rules toJSON 1`] = `
{
"element": {
@@ -93,6 +202,79 @@ Styles for button (uid: "elem-nav"):
}
`;
exports[`CssFormatter > formats @property rules defined in stylesheets and programmatically toJSON 1`] = `
{
"element": {
"uid": "elem-prop",
"selector": "button"
},
"rules": [
{
"type": "property",
"name": "--brand-color",
"selector": "@property --brand-color",
"source": "theme.css:11",
"properties": [
{
"name": "syntax",
"value": "\\"<color>\\"",
"status": "active"
},
{
"name": "inherits",
"value": "false",
"status": "active"
},
{
"name": "initial-value",
"value": "#1a73e8",
"status": "active"
}
]
},
{
"type": "property",
"name": "--runtime-var",
"selector": "@property --runtime-var",
"source": "CSS.registerProperty",
"properties": [
{
"name": "syntax",
"value": "\\"<length>\\"",
"status": "active"
},
{
"name": "inherits",
"value": "true",
"status": "active"
},
{
"name": "initial-value",
"value": "10px",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats @property rules defined in stylesheets and programmatically toString 1`] = `
Styles for button (uid: "elem-prop"):
@property --brand-color (theme.css:11) {
syntax: "<color>";
inherits: false;
initial-value: #1a73e8;
}
@property --runtime-var (CSS.registerProperty) {
syntax: "<length>";
inherits: true;
initial-value: 10px;
}
`;
exports[`CssFormatter > formats @scope ancestor rule toJSON 1`] = `
{
"element": {
@@ -132,6 +314,90 @@ Styles for button (uid: "item-1"):
}
`;
exports[`CssFormatter > formats active and inactive @position-try rules toJSON 1`] = `
{
"element": {
"uid": "elem-pos",
"selector": "button"
},
"rules": [
{
"type": "position-try",
"name": "--bottom",
"active": true,
"selector": "@position-try --bottom",
"source": "anchor.css:21",
"properties": [
{
"name": "top",
"value": "anchor(bottom)",
"status": "active"
}
]
},
{
"type": "position-try",
"name": "--top",
"active": false,
"selector": "@position-try --top",
"source": "anchor.css:26",
"properties": [
{
"name": "bottom",
"value": "anchor(top)",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats active and inactive @position-try rules toString 1`] = `
Styles for button (uid: "elem-pos"):
@position-try --bottom (anchor.css:21) {
top: anchor(bottom);
}
[inactive] @position-try --top (anchor.css:26) {
bottom: anchor(top);
}
`;
exports[`CssFormatter > formats at-rule when present in atRules toJSON 1`] = `
{
"element": {
"uid": "elem-at-4",
"selector": "button"
},
"rules": [
{
"type": "at-rule",
"atRuleType": "counter-style",
"name": "thumbs",
"selector": "@counter-style thumbs",
"source": "<style>",
"properties": [
{
"name": "system",
"value": "cyclic",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats at-rule when present in atRules toString 1`] = `
Styles for button (uid: "elem-at-4"):
@counter-style thumbs (<style>) {
system: cyclic;
}
`;
exports[`CssFormatter > formats constructed stylesheets with and without sourceURL pragma toJSON 1`] = `
{
"element": {
+104
View File
@@ -15,12 +15,16 @@ import {
} from '../../src/formatters/CssFormatter.js';
import {DevTools} from '../../src/third_party/index.js';
import {
createMockCSSAtRule,
createMockCSSInlineStyle,
createMockCSSMatchedStyles,
createMockCSSProperty,
createMockCSSStyleDeclaration,
createMockDOMNode,
createMockCSSStyleRule,
createMockCSSPositionTryRule,
createMockCSSRegisteredProperty,
createMockCSSFunctionRule,
} from '../mocks.js';
describe('CssFormatter', () => {
@@ -518,4 +522,104 @@ describe('CssFormatter', () => {
});
},
);
formatterTest('formats @font-palette-values at-rule with name', () => {
const atRule = createMockCSSAtRule('font-palette-values', {
name: '--my-palette',
properties: [
createMockCSSProperty('font-family', 'Bixa'),
createMockCSSProperty('base-palette', '3'),
],
sourceURL: 'https://example.com/fonts.css',
range: {startLine: 10, startColumn: 0, endLine: 14, endColumn: 1},
});
const matchedStyles = createMockCSSMatchedStyles({atRules: [atRule]});
return new CssFormatter(matchedStyles, {uid: 'elem-at-1'});
});
formatterTest('formats @font-face at-rule without name', () => {
const atRule = createMockCSSAtRule('font-face', {
properties: [
createMockCSSProperty('font-family', 'Open Sans'),
createMockCSSProperty('src', 'url(font.woff2)'),
],
});
const matchedStyles = createMockCSSMatchedStyles({atRules: [atRule]});
return new CssFormatter(matchedStyles, {uid: 'elem-at-2'});
});
formatterTest('formats at-rule when present in atRules', () => {
const atRule = createMockCSSAtRule('counter-style', {
name: 'thumbs',
properties: [createMockCSSProperty('system', 'cyclic')],
});
const matchedStyles = createMockCSSMatchedStyles({
atRules: [atRule],
});
return new CssFormatter(matchedStyles, {uid: 'elem-at-4'});
});
formatterTest('formats active and inactive @position-try rules', () => {
const posActive = createMockCSSPositionTryRule('--bottom', {
active: true,
properties: [createMockCSSProperty('top', 'anchor(bottom)')],
sourceURL: 'anchor.css',
range: {startLine: 20, startColumn: 0, endLine: 22, endColumn: 1},
});
const posInactive = createMockCSSPositionTryRule('--top', {
active: false,
properties: [createMockCSSProperty('bottom', 'anchor(top)')],
sourceURL: 'anchor.css',
range: {startLine: 25, startColumn: 0, endLine: 27, endColumn: 1},
});
const matchedStyles = createMockCSSMatchedStyles({
positionTryRules: [posActive, posInactive],
});
return new CssFormatter(matchedStyles, {uid: 'elem-pos'});
});
formatterTest(
'formats @property rules defined in stylesheets and programmatically',
() => {
const propStylesheet = createMockCSSRegisteredProperty('--brand-color', {
syntax: '"<color>"',
inherits: false,
initialValue: '#1a73e8',
sourceURL: 'theme.css',
range: {startLine: 10, startColumn: 0, endLine: 14, endColumn: 1},
});
const propProgrammatic = createMockCSSRegisteredProperty(
'--runtime-var',
{
syntax: '"<length>"',
inherits: true,
initialValue: '10px',
isProgrammatic: true,
},
);
const matchedStyles = createMockCSSMatchedStyles({
registeredProperties: [propStylesheet, propProgrammatic],
});
return new CssFormatter(matchedStyles, {uid: 'elem-prop'});
},
);
formatterTest(
'formats @function custom function rule with parameters and declarations',
() => {
const funcRule = createMockCSSFunctionRule('--double(--x)', {
functionName: '--double',
properties: [createMockCSSProperty('result', 'calc(var(--x) * 2)')],
sourceURL: 'math.css',
range: {startLine: 4, startColumn: 0, endLine: 6, endColumn: 1},
});
const matchedStyles = createMockCSSMatchedStyles({
functionRules: [funcRule],
});
return new CssFormatter(matchedStyles, {uid: 'elem-func'});
},
);
});
+153
View File
@@ -45,6 +45,14 @@ export type MockCSSMatchedStyles =
sinon.SinonStubbedInstance<DevTools.CSSMatchedStyles.CSSMatchedStyles>;
export type MockCSSStyleRule =
sinon.SinonStubbedInstance<DevTools.CSSRule.CSSStyleRule>;
export type MockCSSAtRule =
sinon.SinonStubbedInstance<DevTools.CSSRule.CSSAtRule>;
export type MockCSSPositionTryRule =
sinon.SinonStubbedInstance<DevTools.CSSRule.CSSPositionTryRule>;
export type MockCSSRegisteredProperty =
sinon.SinonStubbedInstance<DevTools.CSSMatchedStyles.CSSRegisteredProperty>;
export type MockCSSFunctionRule =
sinon.SinonStubbedInstance<DevTools.CSSRule.CSSFunctionRule>;
/**
* A minimal event emitter used to back mocked `on`/`off`/`emit` methods on
@@ -375,10 +383,151 @@ export function createMockCSSStyleRule(
return rule;
}
export function createMockCSSAtRule(
type: string,
options: {
name?: string;
subsection?: string;
properties: DevTools.CSSProperty.CSSProperty[];
sourceURL?: string;
origin?: RuleOrigin;
range?: {
startLine: number;
startColumn: number;
endLine: number;
endColumn: number;
};
},
): MockCSSAtRule {
const rule = sinon.createStubInstance(DevTools.CSSRule.CSSAtRule);
attachRuleMeta(rule, options.sourceURL, options.origin);
const style = createMockCSSStyleDeclaration(options.properties, {
rule,
range: options.range,
});
rule.type.returns(type);
rule.name.returns(options.name ? createCSSValue(options.name) : null);
rule.subsection.returns(options.subsection ?? null);
Object.assign(rule, {style});
return rule;
}
export function createMockCSSPositionTryRule(
name: string,
options: {
active?: boolean;
properties: DevTools.CSSProperty.CSSProperty[];
sourceURL?: string;
range?: {
startLine: number;
startColumn: number;
endLine: number;
endColumn: number;
};
},
): MockCSSPositionTryRule {
const rule = sinon.createStubInstance(DevTools.CSSRule.CSSPositionTryRule);
attachRuleMeta(rule, options.sourceURL);
const style = createMockCSSStyleDeclaration(options.properties, {
rule,
range: options.range,
});
rule.name.returns(createCSSValue(name));
rule.active.returns(options.active ?? false);
Object.assign(rule, {style});
return rule;
}
export function createMockCSSRegisteredProperty(
name: string,
options: {
syntax?: string;
inherits?: boolean;
initialValue?: string;
sourceURL?: string;
range?: {
startLine: number;
startColumn: number;
endLine: number;
endColumn: number;
};
isProgrammatic?: boolean;
} = {},
): MockCSSRegisteredProperty {
const properties = [
createMockCSSProperty('syntax', options.syntax ?? '"*"'),
createMockCSSProperty('inherits', String(options.inherits ?? false)),
];
if (options.initialValue) {
properties.push(
createMockCSSProperty('initial-value', options.initialValue),
);
}
let parentRule: sinon.SinonStubbedInstance<DevTools.CSSRule.CSSPropertyRule> | null =
null;
if (!options.isProgrammatic) {
const mockRule = sinon.createStubInstance(DevTools.CSSRule.CSSPropertyRule);
attachRuleMeta(mockRule, options.sourceURL);
mockRule.propertyName.returns(createCSSValue(name));
parentRule = mockRule;
}
const style = createMockCSSStyleDeclaration(properties, {
rule: parentRule,
range: options.range,
});
if (parentRule) {
Object.assign(parentRule, {style});
}
const prop = sinon.createStubInstance(
DevTools.CSSMatchedStyles.CSSRegisteredProperty,
);
prop.propertyName.returns(name);
prop.inherits.returns(options.inherits ?? false);
prop.syntax.returns(options.syntax ?? '"*"');
prop.initialValue.returns(options.initialValue ?? null);
prop.style.returns(style);
return prop;
}
export function createMockCSSFunctionRule(
nameWithParams: string,
options: {
functionName?: string;
properties: DevTools.CSSProperty.CSSProperty[];
sourceURL?: string;
range?: {
startLine: number;
startColumn: number;
endLine: number;
endColumn: number;
};
},
): MockCSSFunctionRule {
const rule = sinon.createStubInstance(DevTools.CSSRule.CSSFunctionRule);
attachRuleMeta(rule, options.sourceURL);
const style = createMockCSSStyleDeclaration(options.properties, {
rule,
range: options.range,
});
const baseName =
options.functionName ?? nameWithParams.split('(')[0] ?? nameWithParams;
rule.functionName.returns(createCSSValue(baseName));
rule.nameWithParameters.returns(nameWithParams);
Object.assign(rule, {style});
return rule;
}
export interface MockCSSMatchedStylesParams {
node?: string | DevTools.DOMModel.DOMNode;
nodeStyles?: DevTools.CSSStyleDeclaration.CSSStyleDeclaration[];
inheritedStyles?: DevTools.CSSStyleDeclaration.CSSStyleDeclaration[];
atRules?: DevTools.CSSRule.CSSAtRule[];
positionTryRules?: DevTools.CSSRule.CSSPositionTryRule[];
registeredProperties?: DevTools.CSSMatchedStyles.CSSRegisteredProperty[];
functionRules?: DevTools.CSSRule.CSSFunctionRule[];
parentNode?: string | DevTools.DOMModel.DOMNode;
nodeForStyleMap?: Map<
DevTools.CSSStyleDeclaration.CSSStyleDeclaration,
@@ -427,6 +576,10 @@ export function createMockCSSMatchedStyles(
mock.node.returns(mockNode);
mock.nodeStyles.returns(nodeStyles);
mock.inheritedStyles.returns(inheritedStyles);
mock.atRules.returns(params.atRules ?? []);
mock.positionTryRules.returns(params.positionTryRules ?? []);
mock.registeredProperties.returns(params.registeredProperties ?? []);
mock.functionRules.returns(params.functionRules ?? []);
mock.pseudoTypes.returns(pseudoTypes);
mock.customHighlightPseudoNames.returns(new Set(customHighlights.keys()));