Skip to content

Commit aed7839

Browse files
nex3Goodwine
andauthored
Add support for the @content rule (#2501)
Co-authored-by: Carlos (Goodwine) <[email protected]>
1 parent 3b46880 commit aed7839

File tree

10 files changed

+495
-8
lines changed

10 files changed

+495
-8
lines changed

lib/src/js/parser.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,10 @@ void _updateAstPrototypes() {
9393
(Expression self, ExpressionVisitor<Object?> visitor) =>
9494
self.accept(visitor));
9595
var arguments = ArgumentList([], {}, bogusSpan);
96-
var include = IncludeRule('a', arguments, bogusSpan);
97-
getJSClass(include)
96+
getJSClass(IncludeRule('a', arguments, bogusSpan))
9897
.defineGetter('arguments', (IncludeRule self) => self.arguments);
98+
getJSClass(ContentRule(arguments, bogusSpan))
99+
.defineGetter('arguments', (ContentRule self) => self.arguments);
99100

100101
_addSupportsConditionToInterpolation();
101102

pkg/sass-parser/lib/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ export {
107107
ParameterProps,
108108
Parameter,
109109
} from './src/parameter';
110+
export {
111+
ContentRule,
112+
ContentRuleProps,
113+
ContentRuleRaws,
114+
} from './src/statement/content-rule';
110115
export {
111116
CssComment,
112117
CssCommentProps,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ declare namespace SassInternal {
121121
readonly parameters: ParameterList;
122122
}
123123

124+
class ContentRule extends Statement {
125+
readonly arguments: ArgumentList;
126+
}
127+
124128
class DebugRule extends Statement {
125129
readonly expression: Expression;
126130
}
@@ -348,6 +352,7 @@ export type ArgumentList = SassInternal.ArgumentList;
348352
export type AtRootRule = SassInternal.AtRootRule;
349353
export type AtRule = SassInternal.AtRule;
350354
export type ContentBlock = SassInternal.ContentBlock;
355+
export type ContentRule = SassInternal.ContentRule;
351356
export type DebugRule = SassInternal.DebugRule;
352357
export type Declaration = SassInternal.Declaration;
353358
export type EachRule = SassInternal.EachRule;
@@ -388,6 +393,7 @@ export type NumberExpression = SassInternal.NumberExpression;
388393
export interface StatementVisitorObject<T> {
389394
visitAtRootRule(node: AtRootRule): T;
390395
visitAtRule(node: AtRule): T;
396+
visitContentRule(node: ContentRule): T;
391397
visitDebugRule(node: DebugRule): T;
392398
visitDeclaration(node: Declaration): T;
393399
visitEachRule(node: EachRule): T;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`a @content rule toJSON 1`] = `
4+
{
5+
"contentArguments": <(bar)>,
6+
"inputs": [
7+
{
8+
"css": "@mixin foo {@content(bar)}",
9+
"hasBOM": false,
10+
"id": "<input css _____>",
11+
},
12+
],
13+
"name": "content",
14+
"params": "(bar)",
15+
"raws": {},
16+
"sassType": "content-rule",
17+
"source": <1:13-1:26 in 0>,
18+
"type": "atrule",
19+
}
20+
`;
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
// Copyright 2025 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 {ArgumentList, ContentRule, MixinRule, sass, scss} from '../..';
6+
import * as utils from '../../../test/utils';
7+
8+
describe('a @content rule', () => {
9+
let node: ContentRule;
10+
describe('without arguments', () => {
11+
function describeNode(
12+
description: string,
13+
create: () => ContentRule,
14+
): void {
15+
describe(description, () => {
16+
beforeEach(() => void (node = create()));
17+
18+
it('has a sassType', () => expect(node.sassType).toBe('content-rule'));
19+
20+
it('has a name', () => expect(node.name.toString()).toBe('content'));
21+
22+
it('has no arguments', () =>
23+
expect(node.contentArguments.nodes).toHaveLength(0));
24+
25+
it('has matching params', () => expect(node.params).toBe(''));
26+
27+
it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
28+
});
29+
}
30+
31+
describe('parsed as SCSS', () => {
32+
describeNode(
33+
'without parens',
34+
() =>
35+
(scss.parse('@mixin foo {@content}').nodes[0] as MixinRule)
36+
.nodes[0] as ContentRule,
37+
);
38+
39+
describeNode(
40+
'with parens',
41+
() =>
42+
(scss.parse('@mixin foo {@content()}').nodes[0] as MixinRule)
43+
.nodes[0] as ContentRule,
44+
);
45+
});
46+
47+
describe('parsed as Sass', () => {
48+
describeNode(
49+
'without parens',
50+
() =>
51+
(sass.parse('@mixin foo\n @content').nodes[0] as MixinRule)
52+
.nodes[0] as ContentRule,
53+
);
54+
55+
describeNode(
56+
'with parens',
57+
() =>
58+
(sass.parse('@mixin foo\n @content()').nodes[0] as MixinRule)
59+
.nodes[0] as ContentRule,
60+
);
61+
});
62+
63+
describe('constructed manually', () => {
64+
describeNode(
65+
'with defined contentArguments',
66+
() => new ContentRule({contentArguments: []}),
67+
);
68+
69+
describeNode(
70+
'with undefined contentArguments',
71+
() => new ContentRule({contentArguments: undefined}),
72+
);
73+
74+
describeNode('without contentArguments', () => new ContentRule());
75+
});
76+
77+
describe('constructed from ChildProps', () => {
78+
describeNode('with defined contentArguments', () =>
79+
utils.fromChildProps({contentArguments: []}),
80+
);
81+
82+
describeNode('with undefined contentArguments', () =>
83+
utils.fromChildProps({contentArguments: undefined}),
84+
);
85+
});
86+
});
87+
88+
describe('with arguments', () => {
89+
function describeNode(
90+
description: string,
91+
create: () => ContentRule,
92+
): void {
93+
describe(description, () => {
94+
beforeEach(() => void (node = create()));
95+
96+
it('has a sassType', () => expect(node.sassType).toBe('content-rule'));
97+
98+
it('has a name', () => expect(node.name.toString()).toBe('content'));
99+
100+
it('has an argument', () =>
101+
expect(node.contentArguments.nodes[0]).toHaveStringExpression(
102+
'value',
103+
'bar',
104+
));
105+
106+
it('has matching params', () => expect(node.params).toBe('(bar)'));
107+
108+
it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
109+
});
110+
}
111+
112+
describeNode(
113+
'parsed as SCSS',
114+
() =>
115+
(scss.parse('@mixin foo {@content(bar)}').nodes[0] as MixinRule)
116+
.nodes[0] as ContentRule,
117+
);
118+
119+
describeNode(
120+
'parsed as Sass',
121+
() =>
122+
(sass.parse('@mixin foo\n @content(bar)').nodes[0] as MixinRule)
123+
.nodes[0] as ContentRule,
124+
);
125+
126+
describeNode(
127+
'constructed manually',
128+
() => new ContentRule({contentArguments: [{text: 'bar'}]}),
129+
);
130+
131+
describeNode('constructed from ChildProps', () =>
132+
utils.fromChildProps({contentArguments: [{text: 'bar'}]}),
133+
);
134+
});
135+
136+
describe('throws an error when assigned a new', () => {
137+
beforeEach(
138+
() => void (node = new ContentRule({contentArguments: [{text: 'bar'}]})),
139+
);
140+
141+
it('name', () => expect(() => (node.name = 'qux')).toThrow());
142+
143+
it('params', () => expect(() => (node.params = '(zap)')).toThrow());
144+
});
145+
146+
describe('assigned new arguments', () => {
147+
beforeEach(
148+
() => void (node = new ContentRule({contentArguments: [{text: 'bar'}]})),
149+
);
150+
151+
it("removes the old arguments' parent", () => {
152+
const oldArguments = node.contentArguments;
153+
node.contentArguments = [{text: 'qux'}];
154+
expect(oldArguments.parent).toBeUndefined();
155+
});
156+
157+
it("assigns the new arguments' parent", () => {
158+
const args = new ArgumentList([{text: 'qux'}]);
159+
node.contentArguments = args;
160+
expect(args.parent).toBe(node);
161+
});
162+
163+
it('assigns the arguments explicitly', () => {
164+
const args = new ArgumentList([{text: 'qux'}]);
165+
node.contentArguments = args;
166+
expect(node.contentArguments).toBe(args);
167+
});
168+
169+
it('assigns the expression as ArgumentProps', () => {
170+
node.contentArguments = [{text: 'qux'}];
171+
expect(node.contentArguments.nodes[0]).toHaveStringExpression(
172+
'value',
173+
'qux',
174+
);
175+
expect(node.contentArguments.parent).toBe(node);
176+
});
177+
});
178+
179+
describe('stringifies', () => {
180+
describe('to SCSS', () => {
181+
describe('with default raws', () => {
182+
it('with no arguments', () =>
183+
expect(new ContentRule().toString()).toBe('@content'));
184+
185+
it('with an argument', () =>
186+
expect(
187+
new ContentRule({contentArguments: [{text: 'bar'}]}).toString(),
188+
).toBe('@content(bar)'));
189+
});
190+
191+
it('with afterName', () =>
192+
expect(
193+
new ContentRule({
194+
contentArguments: [{text: 'bar'}],
195+
raws: {afterName: '/**/'},
196+
}).toString(),
197+
).toBe('@content/**/(bar)'));
198+
199+
it('with showArguments = true', () =>
200+
expect(new ContentRule({raws: {showArguments: true}}).toString()).toBe(
201+
'@content()',
202+
));
203+
204+
it('ignores showArguments with an argument', () =>
205+
expect(
206+
new ContentRule({
207+
contentArguments: [{text: 'bar'}],
208+
raws: {showArguments: false},
209+
}).toString(),
210+
).toBe('@content(bar)'));
211+
});
212+
});
213+
214+
describe('clone', () => {
215+
let original: ContentRule;
216+
beforeEach(() => {
217+
original = (
218+
scss.parse('@mixin foo {@content(bar)}').nodes[0] as MixinRule
219+
).nodes[0] as ContentRule;
220+
// TODO: remove this once raws are properly parsed
221+
original.raws.afterName = ' ';
222+
});
223+
224+
describe('with no overrides', () => {
225+
let clone: ContentRule;
226+
beforeEach(() => void (clone = original.clone()));
227+
228+
describe('has the same properties:', () => {
229+
it('params', () => expect(clone.params).toBe('(bar)'));
230+
231+
it('contentArguments', () => {
232+
expect(clone.contentArguments.nodes[0]).toHaveStringExpression(
233+
'value',
234+
'bar',
235+
);
236+
expect(clone.contentArguments.parent).toBe(clone);
237+
});
238+
239+
it('raws', () => expect(clone.raws).toEqual({afterName: ' '}));
240+
241+
it('source', () => expect(clone.source).toBe(original.source));
242+
});
243+
244+
describe('creates a new', () => {
245+
it('self', () => expect(clone).not.toBe(original));
246+
247+
for (const attr of ['contentArguments', 'raws'] as const) {
248+
it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
249+
}
250+
});
251+
});
252+
253+
describe('overrides', () => {
254+
describe('raws', () => {
255+
it('defined', () =>
256+
expect(original.clone({raws: {showArguments: true}}).raws).toEqual({
257+
showArguments: true,
258+
}));
259+
260+
it('undefined', () =>
261+
expect(original.clone({raws: undefined}).raws).toEqual({
262+
afterName: ' ',
263+
}));
264+
});
265+
266+
describe('contentArguments', () => {
267+
describe('defined', () => {
268+
let clone: ContentRule;
269+
beforeEach(() => {
270+
clone = original.clone({contentArguments: [{text: 'qux'}]});
271+
});
272+
273+
it('changes params', () => expect(clone.params).toBe('(qux)'));
274+
275+
it('changes arguments', () => {
276+
expect(clone.contentArguments.nodes[0]).toHaveStringExpression(
277+
'value',
278+
'qux',
279+
);
280+
expect(clone.contentArguments.parent).toBe(clone);
281+
});
282+
});
283+
284+
describe('undefined', () => {
285+
let clone: ContentRule;
286+
beforeEach(() => {
287+
clone = original.clone({contentArguments: undefined});
288+
});
289+
290+
it('preserves params', () => expect(clone.params).toBe('(bar)'));
291+
292+
it('preserves arguments', () =>
293+
expect(clone.contentArguments.nodes[0]).toHaveStringExpression(
294+
'value',
295+
'bar',
296+
));
297+
});
298+
});
299+
});
300+
});
301+
302+
it('toJSON', () =>
303+
expect(
304+
(scss.parse('@mixin foo {@content(bar)}').nodes[0] as MixinRule).nodes[0],
305+
).toMatchSnapshot());
306+
});

0 commit comments

Comments
 (0)