Skip to content

Commit e1a8060

Browse files
committed
Add support for @at-root rules
1 parent a42925c commit e1a8060

File tree

6 files changed

+202
-6
lines changed

6 files changed

+202
-6
lines changed

lib/src/parse/stylesheet.dart

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -748,12 +748,12 @@ abstract class StylesheetParser extends Parser {
748748
buffer.writeCharCode($lparen);
749749
whitespace();
750750

751-
buffer.add(_expression());
751+
_addOrInject(buffer, _expression());
752752
if (scanner.scanChar($colon)) {
753753
whitespace();
754754
buffer.writeCharCode($colon);
755755
buffer.writeCharCode($space);
756-
buffer.add(_expression());
756+
_addOrInject(buffer, _expression());
757757
}
758758

759759
scanner.expectChar($rparen);
@@ -3519,6 +3519,16 @@ abstract class StylesheetParser extends Parser {
35193519
span());
35203520
}
35213521

3522+
/// Adds [expression] to [buffer], or if it's an unquoted string adds the
3523+
/// interpolation it contains instead.
3524+
void _addOrInject(InterpolationBuffer buffer, Expression expression) {
3525+
if (expression is StringExpression && !expression.hasQuotes) {
3526+
buffer.addInterpolation(expression.text);
3527+
} else {
3528+
buffer.add(expression);
3529+
}
3530+
}
3531+
35223532
// ## Abstract Methods
35233533

35243534
/// Whether this is parsing the indented syntax.

pkg/sass-parser/lib/src/sass-internal.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ declare namespace SassInternal {
6666
readonly children: T;
6767
}
6868

69+
class AtRootRule extends ParentStatement<Statement[]> {
70+
readonly name: Interpolation;
71+
readonly query?: Interpolation;
72+
}
73+
6974
class AtRule extends ParentStatement<Statement[]> {
7075
readonly name: Interpolation;
7176
readonly value?: Interpolation;
@@ -106,6 +111,7 @@ export type SassNode = SassInternal.SassNode;
106111
export type Statement = SassInternal.Statement;
107112
export type ParentStatement<T extends Statement[] | null> =
108113
SassInternal.ParentStatement<T>;
114+
export type AtRootRule = SassInternal.AtRootRule;
109115
export type AtRule = SassInternal.AtRule;
110116
export type Stylesheet = SassInternal.Stylesheet;
111117
export type StyleRule = SassInternal.StyleRule;
@@ -115,6 +121,7 @@ export type BinaryOperationExpression = SassInternal.BinaryOperationExpression;
115121
export type StringExpression = SassInternal.StringExpression;
116122

117123
export interface StatementVisitorObject<T> {
124+
visitAtRootRule(node: AtRootRule): T;
118125
visitAtRule(node: AtRule): T;
119126
visitStyleRule(node: StyleRule): T;
120127
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import {GenericAtRule, Rule, scss} from '../..';
6+
7+
describe('an @at-root rule', () => {
8+
let node: GenericAtRule;
9+
10+
describe('with no params', () => {
11+
beforeEach(
12+
() => void (node = scss.parse('@at-root {}').nodes[0] as GenericAtRule)
13+
);
14+
15+
it('has a name', () => expect(node.name.toString()).toBe('at-root'));
16+
17+
it('has no paramsInterpolation', () =>
18+
expect(node.paramsInterpolation).toBeUndefined());
19+
20+
it('has no params', () => expect(node.params).toBe(''));
21+
});
22+
23+
describe('with no interpolation', () => {
24+
beforeEach(
25+
() =>
26+
void (node = scss.parse('@at-root (with: rule) {}')
27+
.nodes[0] as GenericAtRule)
28+
);
29+
30+
it('has a name', () => expect(node.name.toString()).toBe('at-root'));
31+
32+
it('has a paramsInterpolation', () =>
33+
expect(node).toHaveInterpolation('paramsInterpolation', '(with: rule)'));
34+
35+
it('has matching params', () => expect(node.params).toBe('(with: rule)'));
36+
});
37+
38+
// TODO: test a variable used directly without interpolation
39+
40+
describe('with interpolation', () => {
41+
beforeEach(
42+
() =>
43+
void (node = scss.parse('@at-root (with: #{rule}) {}')
44+
.nodes[0] as GenericAtRule)
45+
);
46+
47+
it('has a name', () => expect(node.name.toString()).toBe('at-root'));
48+
49+
it('has a paramsInterpolation', () => {
50+
const params = node.paramsInterpolation!;
51+
expect(params.nodes[0]).toBe('(with: ');
52+
expect(params).toHaveStringExpression(1, 'rule');
53+
expect(params.nodes[2]).toBe(')');
54+
});
55+
56+
it('has matching params', () =>
57+
expect(node.params).toBe('(with: #{rule})'));
58+
});
59+
60+
describe('with style rule shorthand', () => {
61+
beforeEach(
62+
() =>
63+
void (node = scss.parse('@at-root .foo {}').nodes[0] as GenericAtRule)
64+
);
65+
66+
it('has a name', () => expect(node.name.toString()).toBe('at-root'));
67+
68+
it('has no paramsInterpolation', () =>
69+
expect(node.paramsInterpolation).toBeUndefined());
70+
71+
it('has no params', () => expect(node.params).toBe(''));
72+
73+
it('contains a Rule', () => {
74+
const rule = node.nodes[0] as Rule;
75+
expect(rule).toHaveInterpolation('selectorInterpolation', '.foo ');
76+
expect(rule.parent).toBe(node);
77+
});
78+
});
79+
80+
describe('stringifies', () => {
81+
describe('to SCSS', () => {
82+
it('with atRootShorthand: false', () =>
83+
expect(
84+
new GenericAtRule({
85+
name: 'at-root',
86+
nodes: [{selector: '.foo'}],
87+
raws: {atRootShorthand: false},
88+
}).toString()
89+
).toBe('@at-root {\n .foo {}\n}'));
90+
91+
describe('with atRootShorthand: true', () => {
92+
it('with no params and only a style rule child', () =>
93+
expect(
94+
new GenericAtRule({
95+
name: 'at-root',
96+
nodes: [{selector: '.foo'}],
97+
raws: {atRootShorthand: true},
98+
}).toString()
99+
).toBe('@at-root .foo {}'));
100+
101+
it('with no params and multiple children', () =>
102+
expect(
103+
new GenericAtRule({
104+
name: 'at-root',
105+
nodes: [{selector: '.foo'}, {selector: '.bar'}],
106+
raws: {atRootShorthand: true},
107+
}).toString()
108+
).toBe('@at-root {\n .foo {}\n .bar {}\n}'));
109+
110+
it('with no params and a non-style-rule child', () =>
111+
expect(
112+
new GenericAtRule({
113+
name: 'at-root',
114+
nodes: [{name: 'foo'}],
115+
raws: {atRootShorthand: true},
116+
}).toString()
117+
).toBe('@at-root {\n @foo\n}'));
118+
119+
it('with params and only a style rule child', () =>
120+
expect(
121+
new GenericAtRule({
122+
name: 'at-root',
123+
params: '(with: rule)',
124+
nodes: [{selector: '.foo'}],
125+
raws: {atRootShorthand: true},
126+
}).toString()
127+
).toBe('@at-root (with: rule) {\n .foo {}\n}'));
128+
129+
it("that's not @at-root", () =>
130+
expect(
131+
new GenericAtRule({
132+
name: 'at-wrong',
133+
nodes: [{selector: '.foo'}],
134+
raws: {atRootShorthand: true},
135+
}).toString()
136+
).toBe('@at-wrong {\n .foo {}\n}'));
137+
});
138+
});
139+
});
140+
});

pkg/sass-parser/lib/src/statement/generic-at-rule.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,20 @@ import * as sassParser from '../..';
2626
/**
2727
* The set of raws supported by {@link GenericAtRule}.
2828
*
29-
* Sass doesn't support PostCSS's `params` raws, since the param interpolation
30-
* is lexed and made directly available to the caller.
29+
* Sass doesn't support PostCSS's `params` raws, since
30+
* {@link GenericAtRule.paramInterpolation} has its own raws.
3131
*
3232
* @category Statement
3333
*/
34-
export type GenericAtRuleRaws = Omit<PostcssAtRuleRaws, 'params'>;
34+
export interface GenericAtRuleRaws extends Omit<PostcssAtRuleRaws, 'params'> {
35+
/**
36+
* Whether to collapse the nesting for an `@at-root` with no params that
37+
* contains only a single style rule.
38+
*
39+
* This is ignored for rules that don't meet all of those criteria.
40+
*/
41+
atRootShorthand?: boolean;
42+
}
3543

3644
/**
3745
* The initializer properties for {@link GenericAtRule}.

pkg/sass-parser/lib/src/statement/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import * as postcss from 'postcss';
66

7+
import {Interpolation} from '../interpolation';
8+
import {LazySource} from '../lazy-source';
79
import {Node, NodeProps} from '../node';
810
import * as sassInternal from '../sass-internal';
911
import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule';
@@ -93,6 +95,17 @@ export interface Statement extends postcss.Node, Node {
9395

9496
/** The visitor to use to convert internal Sass nodes to JS. */
9597
const visitor = sassInternal.createStatementVisitor<Statement>({
98+
visitAtRootRule: inner => {
99+
const rule = new GenericAtRule({
100+
name: 'at-root',
101+
paramsInterpolation: inner.query
102+
? new Interpolation(undefined, inner.query)
103+
: undefined,
104+
source: new LazySource(inner),
105+
});
106+
appendInternalChildren(rule, inner.children);
107+
return rule;
108+
},
96109
visitAtRule: inner => new GenericAtRule(undefined, inner),
97110
visitStyleRule: inner => new Rule(undefined, inner),
98111
});
@@ -196,7 +209,7 @@ export function normalize(
196209
) {
197210
result.push(new Rule(node));
198211
} else if ('name' in node || 'nameInterpolation' in node) {
199-
result.push(new GenericAtRule(node));
212+
result.push(new GenericAtRule(node as GenericAtRuleProps));
200213
} else {
201214
result.push(...postcssNormalizeAndConvertToSass(self, node, sample));
202215
}

pkg/sass-parser/lib/src/stringifier.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ export class Stringifier extends PostCssStringifier {
6868
}
6969

7070
private atrule(node: GenericAtRule, semicolon: boolean): void {
71+
// In the @at-root shorthand, stringify `@at-root {.foo {...}}` as
72+
// `@at-root .foo {...}`.
73+
if (
74+
node.raws.atRootShorthand &&
75+
node.name === 'at-root' &&
76+
node.paramsInterpolation === undefined &&
77+
node.nodes.length === 1 &&
78+
node.nodes[0].sassType === 'rule'
79+
) {
80+
this.block(
81+
node.nodes[0],
82+
'@at-root' +
83+
(node.raws.afterName ?? ' ') +
84+
node.nodes[0].selectorInterpolation
85+
);
86+
return;
87+
}
88+
7189
const start =
7290
`@${node.nameInterpolation}` +
7391
(node.raws.afterName ?? (node.paramsInterpolation ? ' ' : '')) +

0 commit comments

Comments
 (0)