diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index aa2234608..f0b5b9b40 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -93,9 +93,10 @@ void _updateAstPrototypes() { (Expression self, ExpressionVisitor visitor) => self.accept(visitor)); var arguments = ArgumentList([], {}, bogusSpan); - var include = IncludeRule('a', arguments, bogusSpan); - getJSClass(include) + getJSClass(IncludeRule('a', arguments, bogusSpan)) .defineGetter('arguments', (IncludeRule self) => self.arguments); + getJSClass(ContentRule(arguments, bogusSpan)) + .defineGetter('arguments', (ContentRule self) => self.arguments); _addSupportsConditionToInterpolation(); diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 29b39a2a1..1eb6c1546 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -107,6 +107,11 @@ export { ParameterProps, Parameter, } from './src/parameter'; +export { + ContentRule, + ContentRuleProps, + ContentRuleRaws, +} from './src/statement/content-rule'; export { CssComment, CssCommentProps, diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 12ef5908c..22e71e6d8 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -121,6 +121,10 @@ declare namespace SassInternal { readonly parameters: ParameterList; } + class ContentRule extends Statement { + readonly arguments: ArgumentList; + } + class DebugRule extends Statement { readonly expression: Expression; } @@ -348,6 +352,7 @@ export type ArgumentList = SassInternal.ArgumentList; export type AtRootRule = SassInternal.AtRootRule; export type AtRule = SassInternal.AtRule; export type ContentBlock = SassInternal.ContentBlock; +export type ContentRule = SassInternal.ContentRule; export type DebugRule = SassInternal.DebugRule; export type Declaration = SassInternal.Declaration; export type EachRule = SassInternal.EachRule; @@ -388,6 +393,7 @@ export type NumberExpression = SassInternal.NumberExpression; export interface StatementVisitorObject { visitAtRootRule(node: AtRootRule): T; visitAtRule(node: AtRule): T; + visitContentRule(node: ContentRule): T; visitDebugRule(node: DebugRule): T; visitDeclaration(node: Declaration): T; visitEachRule(node: EachRule): T; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/content-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/content-rule.test.ts.snap new file mode 100644 index 000000000..9d50b5b26 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/content-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @content rule toJSON 1`] = ` +{ + "contentArguments": <(bar)>, + "inputs": [ + { + "css": "@mixin foo {@content(bar)}", + "hasBOM": false, + "id": "", + }, + ], + "name": "content", + "params": "(bar)", + "raws": {}, + "sassType": "content-rule", + "source": <1:13-1:26 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/content-rule.test.ts b/pkg/sass-parser/lib/src/statement/content-rule.test.ts new file mode 100644 index 000000000..9daa0d31f --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/content-rule.test.ts @@ -0,0 +1,306 @@ +// Copyright 2025 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {ArgumentList, ContentRule, MixinRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @content rule', () => { + let node: ContentRule; + describe('without arguments', () => { + function describeNode( + description: string, + create: () => ContentRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('content-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('content')); + + it('has no arguments', () => + expect(node.contentArguments.nodes).toHaveLength(0)); + + it('has matching params', () => expect(node.params).toBe('')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describe('parsed as SCSS', () => { + describeNode( + 'without parens', + () => + (scss.parse('@mixin foo {@content}').nodes[0] as MixinRule) + .nodes[0] as ContentRule, + ); + + describeNode( + 'with parens', + () => + (scss.parse('@mixin foo {@content()}').nodes[0] as MixinRule) + .nodes[0] as ContentRule, + ); + }); + + describe('parsed as Sass', () => { + describeNode( + 'without parens', + () => + (sass.parse('@mixin foo\n @content').nodes[0] as MixinRule) + .nodes[0] as ContentRule, + ); + + describeNode( + 'with parens', + () => + (sass.parse('@mixin foo\n @content()').nodes[0] as MixinRule) + .nodes[0] as ContentRule, + ); + }); + + describe('constructed manually', () => { + describeNode( + 'with defined contentArguments', + () => new ContentRule({contentArguments: []}), + ); + + describeNode( + 'with undefined contentArguments', + () => new ContentRule({contentArguments: undefined}), + ); + + describeNode('without contentArguments', () => new ContentRule()); + }); + + describe('constructed from ChildProps', () => { + describeNode('with defined contentArguments', () => + utils.fromChildProps({contentArguments: []}), + ); + + describeNode('with undefined contentArguments', () => + utils.fromChildProps({contentArguments: undefined}), + ); + }); + }); + + describe('with arguments', () => { + function describeNode( + description: string, + create: () => ContentRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('content-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('content')); + + it('has an argument', () => + expect(node.contentArguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + + it('has matching params', () => expect(node.params).toBe('(bar)')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@mixin foo {@content(bar)}').nodes[0] as MixinRule) + .nodes[0] as ContentRule, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@mixin foo\n @content(bar)').nodes[0] as MixinRule) + .nodes[0] as ContentRule, + ); + + describeNode( + 'constructed manually', + () => new ContentRule({contentArguments: [{text: 'bar'}]}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({contentArguments: [{text: 'bar'}]}), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => void (node = new ContentRule({contentArguments: [{text: 'bar'}]})), + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => expect(() => (node.params = '(zap)')).toThrow()); + }); + + describe('assigned new arguments', () => { + beforeEach( + () => void (node = new ContentRule({contentArguments: [{text: 'bar'}]})), + ); + + it("removes the old arguments' parent", () => { + const oldArguments = node.contentArguments; + node.contentArguments = [{text: 'qux'}]; + expect(oldArguments.parent).toBeUndefined(); + }); + + it("assigns the new arguments' parent", () => { + const args = new ArgumentList([{text: 'qux'}]); + node.contentArguments = args; + expect(args.parent).toBe(node); + }); + + it('assigns the arguments explicitly', () => { + const args = new ArgumentList([{text: 'qux'}]); + node.contentArguments = args; + expect(node.contentArguments).toBe(args); + }); + + it('assigns the expression as ArgumentProps', () => { + node.contentArguments = [{text: 'qux'}]; + expect(node.contentArguments.nodes[0]).toHaveStringExpression( + 'value', + 'qux', + ); + expect(node.contentArguments.parent).toBe(node); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no arguments', () => + expect(new ContentRule().toString()).toBe('@content')); + + it('with an argument', () => + expect( + new ContentRule({contentArguments: [{text: 'bar'}]}).toString(), + ).toBe('@content(bar)')); + }); + + it('with afterName', () => + expect( + new ContentRule({ + contentArguments: [{text: 'bar'}], + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@content/**/(bar)')); + + it('with showArguments = true', () => + expect(new ContentRule({raws: {showArguments: true}}).toString()).toBe( + '@content()', + )); + + it('ignores showArguments with an argument', () => + expect( + new ContentRule({ + contentArguments: [{text: 'bar'}], + raws: {showArguments: false}, + }).toString(), + ).toBe('@content(bar)')); + }); + }); + + describe('clone', () => { + let original: ContentRule; + beforeEach(() => { + original = ( + scss.parse('@mixin foo {@content(bar)}').nodes[0] as MixinRule + ).nodes[0] as ContentRule; + // TODO: remove this once raws are properly parsed + original.raws.afterName = ' '; + }); + + describe('with no overrides', () => { + let clone: ContentRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('(bar)')); + + it('contentArguments', () => { + expect(clone.contentArguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + ); + expect(clone.contentArguments.parent).toBe(clone); + }); + + it('raws', () => expect(clone.raws).toEqual({afterName: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['contentArguments', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {showArguments: true}}).raws).toEqual({ + showArguments: true, + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + afterName: ' ', + })); + }); + + describe('contentArguments', () => { + describe('defined', () => { + let clone: ContentRule; + beforeEach(() => { + clone = original.clone({contentArguments: [{text: 'qux'}]}); + }); + + it('changes params', () => expect(clone.params).toBe('(qux)')); + + it('changes arguments', () => { + expect(clone.contentArguments.nodes[0]).toHaveStringExpression( + 'value', + 'qux', + ); + expect(clone.contentArguments.parent).toBe(clone); + }); + }); + + describe('undefined', () => { + let clone: ContentRule; + beforeEach(() => { + clone = original.clone({contentArguments: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('(bar)')); + + it('preserves arguments', () => + expect(clone.contentArguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + }); + }); + }); + }); + + it('toJSON', () => + expect( + (scss.parse('@mixin foo {@content(bar)}').nodes[0] as MixinRule).nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/content-rule.ts b/pkg/sass-parser/lib/src/statement/content-rule.ts new file mode 100644 index 000000000..6ccabde7a --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/content-rule.ts @@ -0,0 +1,128 @@ +// Copyright 2025 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws} from 'postcss/lib/at-rule'; + +import {ArgumentList, ArgumentListProps} from '../argument-list'; +import {LazySource} from '../lazy-source'; +import {NodeProps} from '../node'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link ContentRule}. + * + * @category Statement + */ +export interface ContentRuleRaws extends Omit { + /** + * Whether to include an empty argument list. If the argument list isn't + * empty, this is ignored. + */ + showArguments?: boolean; +} + +/** + * The initializer properties for {@link ContentRule}. + * + * @category Statement + */ +export interface ContentRuleProps extends NodeProps { + raws?: ContentRuleRaws; + contentArguments?: ArgumentList | ArgumentListProps; +} + +/** + * An `@content` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ContentRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'content-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ContentRuleRaws; + declare readonly nodes: undefined; + + /** The arguments to pass to the mixin invocation's `using` block. */ + get contentArguments(): ArgumentList { + return this._contentArguments!; + } + set contentArguments(args: ArgumentList | ArgumentListProps | undefined) { + if (this._contentArguments) { + this._contentArguments.parent = undefined; + } + this._contentArguments = args + ? 'sassType' in args + ? args + : new ArgumentList(args) + : new ArgumentList(); + this._contentArguments.parent = this; + } + private declare _contentArguments: ArgumentList; + + get name(): string { + return 'content'; + } + set name(value: string) { + throw new Error("ContentRule.name can't be overwritten."); + } + + get params(): string { + return !this.raws.showArguments && this.contentArguments.nodes.length === 0 + ? '' + : this.contentArguments.toString(); + } + set params(value: string | number | undefined) { + throw new Error("ContentRule.params can't be overwritten."); + } + + constructor(defaults?: ContentRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ContentRule); + constructor(defaults?: ContentRuleProps, inner?: sassInternal.ContentRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.contentArguments = new ArgumentList(undefined, inner.arguments); + } + this._contentArguments ??= new ArgumentList(); + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'contentArguments']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['name', 'params', 'contentArguments'], inputs); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.contentArguments]; + } +} + +interceptIsClean(ContentRule); diff --git a/pkg/sass-parser/lib/src/statement/include-rule.test.ts b/pkg/sass-parser/lib/src/statement/include-rule.test.ts index 178d65f18..c7cb612f1 100644 --- a/pkg/sass-parser/lib/src/statement/include-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/include-rule.test.ts @@ -355,7 +355,7 @@ describe('a @include rule', () => { new IncludeRule({ includeName: 'foo', arguments: [{text: 'bar'}], - raws: {showArguments: true}, + raws: {showArguments: false}, }).toString(), ).toBe('@include foo(bar)')); diff --git a/pkg/sass-parser/lib/src/statement/include-rule.ts b/pkg/sass-parser/lib/src/statement/include-rule.ts index 74290fcf6..1b5e14277 100644 --- a/pkg/sass-parser/lib/src/statement/include-rule.ts +++ b/pkg/sass-parser/lib/src/statement/include-rule.ts @@ -116,11 +116,15 @@ export class IncludeRule get arguments(): ArgumentList { return this._arguments!; } - set arguments(args: ArgumentList | ArgumentListProps) { + set arguments(args: ArgumentList | ArgumentListProps | undefined) { if (this._arguments) { this._arguments.parent = undefined; } - this._arguments = 'sassType' in args ? args : new ArgumentList(args); + this._arguments = args + ? 'sassType' in args + ? args + : new ArgumentList(args) + : new ArgumentList(); this._arguments.parent = this; } private declare _arguments: ArgumentList; diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index 012c5ed94..2aa3c9df1 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -12,6 +12,7 @@ import * as sassInternal from '../sass-internal'; import {CssComment, CssCommentProps} from './css-comment'; import {SassComment, SassCommentChildProps} from './sass-comment'; import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule'; +import {ContentRule, ContentRuleProps} from './content-rule'; import {DebugRule, DebugRuleProps} from './debug-rule'; import {Declaration, DeclarationProps} from './declaration'; import {EachRule, EachRuleProps} from './each-rule'; @@ -56,6 +57,7 @@ export type StatementType = | 'rule' | 'atrule' | 'comment' + | 'content-rule' | 'decl' | 'debug-rule' | 'each-rule' @@ -81,6 +83,7 @@ export type StatementType = * @category Statement */ export type AtRule = + | ContentRule | DebugRule | EachRule | ElseRule @@ -130,6 +133,12 @@ export type ChildNode = Rule | AtRule | Comment | AnyDeclaration; */ export type ChildProps = | postcss.ChildProps + // In a ChildProps context, `ContentProps` requires an explicit + // `contentArguments: undefined` so that an empty object isn't a valid + // `ChildProps`. + | (ContentRuleProps & { + contentArguments: ContentRuleProps['contentArguments']; + }) | CssCommentProps | DebugRuleProps | DeclarationProps @@ -203,6 +212,7 @@ const visitor = sassInternal.createStatementVisitor({ return rule; }, visitAtRule: inner => new GenericAtRule(undefined, inner), + visitContentRule: inner => new ContentRule(undefined, inner), visitDebugRule: inner => new DebugRule(undefined, inner), visitDeclaration: inner => new Declaration(undefined, inner), visitErrorRule: inner => new ErrorRule(undefined, inner), @@ -367,6 +377,8 @@ export function normalize( result.push(new Rule(node)); } else if ('name' in node || 'nameInterpolation' in node) { result.push(new GenericAtRule(node as GenericAtRuleProps)); + } else if ('contentArguments' in node) { + result.push(new ContentRule(node)); } else if ('debugExpression' in node) { result.push(new DebugRule(node)); } else if ('eachExpression' in node) { diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 4abcb9337..8d72e7452 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -28,7 +28,7 @@ import * as postcss from 'postcss'; -import {AnyStatement} from './statement'; +import {AnyStatement, AtRule} from './statement'; import {DebugRule} from './statement/debug-rule'; import {Declaration} from './statement/declaration'; import {EachRule} from './statement/each-rule'; @@ -85,6 +85,10 @@ export class Stringifier extends PostCssStringifier { )(statement, semicolon); } + private ['content-rule'](node: EachRule): void { + this.sassAtRule(node); + } + private ['debug-rule'](node: DebugRule, semicolon: boolean): void { this.sassAtRule(node, semicolon); } @@ -266,11 +270,12 @@ export class Stringifier extends PostCssStringifier { } /** Helper method for non-generic Sass at-rules. */ - private sassAtRule(node: postcss.AtRule, semicolon?: boolean): void { + private sassAtRule(node: AtRule, semicolon?: boolean): void { const start = '@' + node.name + - (node.raws.afterName ?? (node.params === '' ? '' : ' ')) + + (node.raws.afterName ?? + (node.params === '' || node.sassType === 'content-rule' ? '' : ' ')) + node.params; if (node.nodes) { this.block(node, start);